Commit 6c23daaaba

6c23daaaba2a954220ece2afa7193dbdcad55c22

parent: dcf4135ec3

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 18:47 UTC

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

Layout: unified · split

DomainDig/AppVersion.swift +2 −2
@@ -1,7 +1,7 @@
11import Foundation
22
33enum AppVersion {
4 static var current: String {
5 "3.1.0"
4 nonisolated static var current: String {
5 "3.2.0"
66 }
77}
DomainDig/ContentView.swift +364 −74
@@ -71,62 +71,11 @@ struct ContentView: View {
7171 .padding(.top, appDensity.metrics.cardSpacing)
7272 }
7373 }
74 DomainSectionView(
75 isCollapsed: sectionCollapsedBinding(.domain),
76 rows: viewModel.domainRows,
77 suggestions: viewModel.suggestionRows,
78 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
79 availabilityLoading: viewModel.availabilityLoading,
80 suggestionsLoading: viewModel.suggestionsLoading,
81 provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
82 confidence: viewModel.currentSnapshot.availabilityConfidence,
83 snapshotNote: viewModel.currentSnapshot.note,
84 trackedDomain: viewModel.currentTrackedDomain,
85 workflows: viewModel.currentDomainWorkflows,
86 trackingLimitMessage: viewModel.trackingLimitMessage,
87 onTrack: {
88 _ = viewModel.trackCurrentDomain()
89 },
90 onTogglePinned: {
91 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
92 viewModel.togglePinned(for: trackedDomain)
93 },
94 onEditNote: {
95 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
96 trackingNoteDraft = trackedDomain.note ?? ""
97 editingTrackedDomain = trackedDomain
98 },
99 onAddToWorkflow: {
100 showingCurrentDomainWorkflowSheet = true
101 },
102 onOpenWorkflow: { workflow in
103 navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
104 },
105 onRunWorkflow: { workflow in
106 viewModel.rerunCurrentDomain(in: workflow)
107 }
108 )
74 domainOverviewSection
10975 .padding(.top, appDensity.metrics.sectionSpacing)
110 OwnershipSectionView(
111 isCollapsed: sectionCollapsedBinding(.ownership),
112 rows: viewModel.ownershipRows,
113 loading: viewModel.ownershipLoading,
114 error: viewModel.ownershipError,
115 provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
116 confidence: viewModel.currentSnapshot.ownershipConfidence,
117 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
118 )
76 ownershipSection
11977 .padding(.top, appDensity.metrics.sectionSpacing)
120 SubdomainsSectionView(
121 isCollapsed: sectionCollapsedBinding(.subdomains),
122 rows: viewModel.subdomainRows,
123 groups: viewModel.currentSubdomainGroups,
124 loading: viewModel.subdomainsLoading,
125 error: viewModel.subdomainsError,
126 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
127 confidence: viewModel.currentSnapshot.subdomainConfidence,
128 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
129 )
78 subdomainsSection
13079 .padding(.top, appDensity.metrics.sectionSpacing)
13180 if !viewModel.currentDiffSections.isEmpty {
13281 DomainDiffView(
@@ -137,17 +86,7 @@ struct ContentView: View {
13786 )
13887 .padding(.top, appDensity.metrics.sectionSpacing)
13988 }
140 DNSSectionView(
141 isCollapsed: sectionCollapsedBinding(.dns),
142 dnssecLabel: viewModel.dnssecLabel,
143 patternSummary: viewModel.currentDNSPatterns,
144 sections: viewModel.dnsRows,
145 ptrMessage: viewModel.ptrMessage,
146 loading: viewModel.dnsLoading || viewModel.ptrLoading,
147 dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
148 ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
149 sectionError: viewModel.dnsError
150 )
89 dnsSection
15190 .padding(.top, appDensity.metrics.sectionSpacing)
15291 WebSectionView(
15392 isCollapsed: sectionCollapsedBinding(.web),
@@ -240,6 +179,9 @@ struct ContentView: View {
240179 .onAppear {
241180 domainFieldFocused = true
242181 }
182 .task {
183 await viewModel.refreshUsageCredits()
184 }
243185 .onChange(of: viewModel.searchedDomain) { _, _ in
244186 collapsedSections = defaultCollapsedSections
245187 }
@@ -372,6 +314,117 @@ struct ContentView: View {
372314 .padding(.vertical, appDensity.metrics.sectionSpacing)
373315 }
374316
317 private var domainOverviewSection: some View {
318 let trackedDomain = viewModel.currentTrackedDomain
319 let workflows = viewModel.currentDomainWorkflows
320
321 return DomainSectionView(
322 isCollapsed: sectionCollapsedBinding(.domain),
323 rows: viewModel.domainRows,
324 suggestions: viewModel.suggestionRows,
325 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
326 availabilityLoading: viewModel.availabilityLoading,
327 suggestionsLoading: viewModel.suggestionsLoading,
328 provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
329 confidence: viewModel.currentSnapshot.availabilityConfidence,
330 snapshotNote: viewModel.currentSnapshot.note,
331 trackedDomain: trackedDomain,
332 workflows: workflows,
333 trackingLimitMessage: viewModel.trackingLimitMessage,
334 pricingLoading: viewModel.domainPricingLoading,
335 pricingError: viewModel.domainPricingError,
336 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
337 onTrack: {
338 _ = viewModel.trackCurrentDomain()
339 },
340 onTogglePinned: {
341 guard let trackedDomain else { return }
342 viewModel.togglePinned(for: trackedDomain)
343 },
344 onEditNote: {
345 guard let trackedDomain else { return }
346 trackingNoteDraft = trackedDomain.note ?? ""
347 editingTrackedDomain = trackedDomain
348 },
349 onAddToWorkflow: {
350 showingCurrentDomainWorkflowSheet = true
351 },
352 onOpenWorkflow: { workflow in
353 navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
354 },
355 onRunWorkflow: { workflow in
356 viewModel.rerunCurrentDomain(in: workflow)
357 }
358 )
359 }
360
361 private var ownershipSection: some View {
362 OwnershipSectionView(
363 isCollapsed: sectionCollapsedBinding(.ownership),
364 rows: viewModel.ownershipRows,
365 loading: viewModel.ownershipLoading,
366 error: viewModel.ownershipError,
367 provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
368 confidence: viewModel.currentSnapshot.ownershipConfidence,
369 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
370 history: viewModel.ownershipHistory,
371 historyLoading: viewModel.ownershipHistoryLoading,
372 historyError: viewModel.ownershipHistoryError,
373 historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
374 onLoadHistory: {
375 Task {
376 await viewModel.loadOwnershipHistory()
377 }
378 }
379 )
380 }
381
382 private var subdomainsSection: some View {
383 SubdomainsSectionView(
384 isCollapsed: sectionCollapsedBinding(.subdomains),
385 rows: viewModel.subdomainRows,
386 groups: viewModel.currentSubdomainGroups,
387 loading: viewModel.subdomainsLoading,
388 error: viewModel.subdomainsError,
389 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
390 confidence: viewModel.currentSnapshot.subdomainConfidence,
391 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
392 extendedCount: viewModel.extendedSubdomains.count,
393 extendedLoading: viewModel.extendedSubdomainsLoading,
394 extendedError: viewModel.extendedSubdomainsError,
395 extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
396 onLoadExtended: {
397 Task {
398 await viewModel.loadExtendedSubdomains()
399 }
400 }
401 )
402 }
403
404 private var dnsSection: some View {
405 DNSSectionView(
406 isCollapsed: sectionCollapsedBinding(.dns),
407 dnssecLabel: viewModel.dnssecLabel,
408 patternSummary: viewModel.currentDNSPatterns,
409 sections: viewModel.dnsRows,
410 ptrMessage: viewModel.ptrMessage,
411 loading: viewModel.dnsLoading || viewModel.ptrLoading,
412 dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
413 ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
414 sectionError: viewModel.dnsError,
415 history: viewModel.dnsHistory,
416 historyLoading: viewModel.dnsHistoryLoading,
417 historyError: viewModel.dnsHistoryError,
418 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
419 historyCreditStatus: viewModel.dnsHistoryCreditStatus,
420 onLoadHistory: {
421 Task {
422 await viewModel.loadDNSHistory()
423 }
424 }
425 )
426 }
427
375428 private var actionButtons: some View {
376429 HStack {
377430 Spacer()
@@ -1192,6 +1245,9 @@ struct DomainSectionView: View {
11921245 let trackedDomain: TrackedDomain?
11931246 let workflows: [DomainWorkflow]
11941247 let trackingLimitMessage: String?
1248 let pricingLoading: Bool
1249 let pricingError: String?
1250 let showsPricingPlaceholder: Bool
11951251 let onTrack: () -> Void
11961252 let onTogglePinned: () -> Void
11971253 let onEditNote: (() -> Void)?
@@ -1313,12 +1369,24 @@ struct DomainSectionView: View {
13131369 }
13141370 }
13151371 }
1372 if pricingLoading {
1373 ProgressView("Loading external pricing…")
1374 .appLoadingStyle()
1375 .padding(.top, 4)
1376 } else if let pricingError {
1377 MessageRowView(text: pricingError, isError: false)
1378 .padding(.top, 4)
1379 } else if showsPricingPlaceholder {
1380 MessageRowView(text: "Pricing signals available in Data+", isError: false)
1381 .padding(.top, 4)
1382 }
13161383 }
13171384 }
13181385 }
13191386}
13201387
13211388struct OwnershipSectionView: View {
1389 @Environment(\.appDensity) private var appDensity
13221390 @Binding var isCollapsed: Bool
13231391 let rows: [InfoRowViewData]
13241392 let loading: Bool
@@ -1326,6 +1394,39 @@ struct OwnershipSectionView: View {
13261394 let provenance: SectionProvenance?
13271395 let confidence: ConfidenceLevel?
13281396 let showsHistoryPlaceholder: Bool
1397 let history: [DomainOwnershipHistoryEvent]
1398 let historyLoading: Bool
1399 let historyError: String?
1400 let historyCreditStatus: UsageCreditStatus?
1401 let onLoadHistory: (() -> Void)?
1402
1403 init(
1404 isCollapsed: Binding<Bool>,
1405 rows: [InfoRowViewData],
1406 loading: Bool,
1407 error: String?,
1408 provenance: SectionProvenance?,
1409 confidence: ConfidenceLevel?,
1410 showsHistoryPlaceholder: Bool,
1411 history: [DomainOwnershipHistoryEvent] = [],
1412 historyLoading: Bool = false,
1413 historyError: String? = nil,
1414 historyCreditStatus: UsageCreditStatus? = nil,
1415 onLoadHistory: (() -> Void)? = nil
1416 ) {
1417 _isCollapsed = isCollapsed
1418 self.rows = rows
1419 self.loading = loading
1420 self.error = error
1421 self.provenance = provenance
1422 self.confidence = confidence
1423 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1424 self.history = history
1425 self.historyLoading = historyLoading
1426 self.historyError = historyError
1427 self.historyCreditStatus = historyCreditStatus
1428 self.onLoadHistory = onLoadHistory
1429 }
13291430
13301431 var body: some View {
13311432 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
@@ -1342,9 +1443,41 @@ struct OwnershipSectionView: View {
13421443 MessageRowView(text: error, isError: error != "Unavailable")
13431444 .padding(.top, 4)
13441445 }
1345 if showsHistoryPlaceholder {
1346 MessageRowView(text: "Ownership history (coming soon)", isError: false)
1347 .padding(.top, 4)
1446 VStack(alignment: .leading, spacing: 8) {
1447 HStack {
1448 Text("History")
1449 .font(appDensity.font(.caption))
1450 .foregroundStyle(.secondary)
1451 Spacer()
1452 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1453 Button("Load (\(historyCreditStatus.remaining) left)") {
1454 onLoadHistory()
1455 }
1456 .buttonStyle(.bordered)
1457 .font(appDensity.font(.caption2))
1458 }
1459 }
1460 if historyLoading {
1461 ProgressView("Loading history…")
1462 .appLoadingStyle()
1463 } else if !history.isEmpty {
1464 ForEach(history) { event in
1465 VStack(alignment: .leading, spacing: 3) {
1466 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1467 .font(appDensity.font(.caption2))
1468 .foregroundStyle(.secondary)
1469 Text(event.summary)
1470 .font(appDensity.font(.caption))
1471 Text(event.source)
1472 .font(appDensity.font(.caption2))
1473 .foregroundStyle(.secondary)
1474 }
1475 }
1476 } else if let historyError {
1477 MessageRowView(text: historyError, isError: false)
1478 } else if showsHistoryPlaceholder {
1479 MessageRowView(text: "Ownership history available in Data+", isError: false)
1480 }
13481481 }
13491482 }
13501483 }
@@ -1362,6 +1495,41 @@ struct SubdomainsSectionView: View {
13621495 let provenance: SectionProvenance?
13631496 let confidence: ConfidenceLevel?
13641497 let showsExtendedPlaceholder: Bool
1498 let extendedCount: Int
1499 let extendedLoading: Bool
1500 let extendedError: String?
1501 let extendedCreditStatus: UsageCreditStatus?
1502 let onLoadExtended: (() -> Void)?
1503
1504 init(
1505 isCollapsed: Binding<Bool>,
1506 rows: [SubdomainRowViewData],
1507 groups: [SubdomainGroup],
1508 loading: Bool,
1509 error: String?,
1510 provenance: SectionProvenance?,
1511 confidence: ConfidenceLevel?,
1512 showsExtendedPlaceholder: Bool,
1513 extendedCount: Int = 0,
1514 extendedLoading: Bool = false,
1515 extendedError: String? = nil,
1516 extendedCreditStatus: UsageCreditStatus? = nil,
1517 onLoadExtended: (() -> Void)? = nil
1518 ) {
1519 _isCollapsed = isCollapsed
1520 self.rows = rows
1521 self.groups = groups
1522 self.loading = loading
1523 self.error = error
1524 self.provenance = provenance
1525 self.confidence = confidence
1526 self.showsExtendedPlaceholder = showsExtendedPlaceholder
1527 self.extendedCount = extendedCount
1528 self.extendedLoading = extendedLoading
1529 self.extendedError = extendedError
1530 self.extendedCreditStatus = extendedCreditStatus
1531 self.onLoadExtended = onLoadExtended
1532 }
13651533
13661534 var body: some View {
13671535 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
@@ -1373,10 +1541,17 @@ struct SubdomainsSectionView: View {
13731541 } else if rows.isEmpty {
13741542 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
13751543 if showsExtendedPlaceholder {
1376 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
1544 MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
13771545 .padding(.top, 4)
13781546 }
13791547 } else {
1548 if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1549 Button("Load extended results (\(extendedCreditStatus.remaining) left)") {
1550 onLoadExtended()
1551 }
1552 .buttonStyle(.bordered)
1553 .font(appDensity.font(.caption2))
1554 }
13801555 if !groups.isEmpty {
13811556 Text("Groups")
13821557 .font(appDensity.font(.caption2))
@@ -1411,8 +1586,18 @@ struct SubdomainsSectionView: View {
14111586 }
14121587 }
14131588 }
1414 if showsExtendedPlaceholder {
1415 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
1589 if extendedLoading {
1590 ProgressView("Loading extended subdomains…")
1591 .appLoadingStyle()
1592 .padding(.top, 4)
1593 } else if extendedCount > 0 {
1594 MessageRowView(text: "\(extendedCount) extended results included", isError: false)
1595 .padding(.top, 4)
1596 } else if let extendedError {
1597 MessageRowView(text: extendedError, isError: false)
1598 .padding(.top, 4)
1599 } else if showsExtendedPlaceholder {
1600 MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
14161601 .padding(.top, 4)
14171602 }
14181603 }
@@ -1422,6 +1607,7 @@ struct SubdomainsSectionView: View {
14221607}
14231608
14241609struct DNSSectionView: View {
1610 @Environment(\.appDensity) private var appDensity
14251611 @Binding var isCollapsed: Bool
14261612 let dnssecLabel: String?
14271613 let patternSummary: DNSPatternSummary?
@@ -1431,6 +1617,46 @@ struct DNSSectionView: View {
14311617 let dnsProvenance: SectionProvenance?
14321618 let ptrProvenance: SectionProvenance?
14331619 let sectionError: String?
1620 let history: [DNSHistoryEvent]
1621 let historyLoading: Bool
1622 let historyError: String?
1623 let showsHistoryPlaceholder: Bool
1624 let historyCreditStatus: UsageCreditStatus?
1625 let onLoadHistory: (() -> Void)?
1626
1627 init(
1628 isCollapsed: Binding<Bool>,
1629 dnssecLabel: String?,
1630 patternSummary: DNSPatternSummary?,
1631 sections: [DNSRecordSectionViewData],
1632 ptrMessage: SectionMessageViewData?,
1633 loading: Bool,
1634 dnsProvenance: SectionProvenance?,
1635 ptrProvenance: SectionProvenance?,
1636 sectionError: String?,
1637 history: [DNSHistoryEvent] = [],
1638 historyLoading: Bool = false,
1639 historyError: String? = nil,
1640 showsHistoryPlaceholder: Bool = false,
1641 historyCreditStatus: UsageCreditStatus? = nil,
1642 onLoadHistory: (() -> Void)? = nil
1643 ) {
1644 _isCollapsed = isCollapsed
1645 self.dnssecLabel = dnssecLabel
1646 self.patternSummary = patternSummary
1647 self.sections = sections
1648 self.ptrMessage = ptrMessage
1649 self.loading = loading
1650 self.dnsProvenance = dnsProvenance
1651 self.ptrProvenance = ptrProvenance
1652 self.sectionError = sectionError
1653 self.history = history
1654 self.historyLoading = historyLoading
1655 self.historyError = historyError
1656 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1657 self.historyCreditStatus = historyCreditStatus
1658 self.onLoadHistory = onLoadHistory
1659 }
14341660
14351661 var body: some View {
14361662 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
@@ -1491,6 +1717,50 @@ struct DNSSectionView: View {
14911717 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
14921718 }
14931719 }
1720
1721 CardView(allowsHorizontalScroll: false) {
1722 HStack {
1723 Text("History")
1724 .font(appDensity.font(.subheadline, weight: .semibold))
1725 .foregroundStyle(.cyan)
1726 Spacer()
1727 if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1728 Button("Load (\(historyCreditStatus.remaining) left)") {
1729 onLoadHistory()
1730 }
1731 .buttonStyle(.bordered)
1732 .font(appDensity.font(.caption2))
1733 }
1734 }
1735 if historyLoading {
1736 ProgressView("Loading DNS history…")
1737 .appLoadingStyle()
1738 } else if !history.isEmpty {
1739 ForEach(history) { event in
1740 VStack(alignment: .leading, spacing: 3) {
1741 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1742 .font(appDensity.font(.caption2))
1743 .foregroundStyle(.secondary)
1744 Text(event.summary)
1745 .font(appDensity.font(.caption))
1746 if !event.aRecords.isEmpty {
1747 Text("A: \(event.aRecords.joined(separator: ", "))")
1748 .font(appDensity.font(.caption2))
1749 .foregroundStyle(.secondary)
1750 }
1751 if !event.nameservers.isEmpty {
1752 Text("NS: \(event.nameservers.joined(separator: ", "))")
1753 .font(appDensity.font(.caption2))
1754 .foregroundStyle(.secondary)
1755 }
1756 }
1757 }
1758 } else if let historyError {
1759 MessageRowView(text: historyError, isError: false)
1760 } else if showsHistoryPlaceholder {
1761 MessageRowView(text: "DNS history available in Data+", isError: false)
1762 }
1763 }
14941764 }
14951765 }
14961766 }
@@ -2220,11 +2490,11 @@ struct SettingsView: View {
22202490 }
22212491 }
22222492
2223 Section("Pro") {
2493 Section("Tier") {
22242494 LabeledContent("Status", value: purchaseService.currentTier.title)
22252495
22262496 if purchaseService.currentTier == .free {
2227 Button("Upgrade to Pro") {
2497 Button("Upgrade") {
22282498 viewModel.isPaywallPresented = true
22292499 }
22302500 } else {
@@ -2255,6 +2525,23 @@ struct SettingsView: View {
22552525 }
22562526 }
22572527
2528 Section("Data+ Usage") {
2529 ForEach(UsageCreditFeature.allCases) { feature in
2530 let status = viewModel.usageCredits[feature] ?? UsageCreditStatus(
2531 feature: feature,
2532 remaining: feature.defaultAllowance,
2533 total: feature.defaultAllowance,
2534 resetContext: "Resets with app version \(AppVersion.current)"
2535 )
2536 VStack(alignment: .leading, spacing: 2) {
2537 LabeledContent(feature.title, value: status.summary)
2538 Text(status.resetContext)
2539 .font(appDensity.font(.caption, design: .default))
2540 .foregroundStyle(.secondary)
2541 }
2542 }
2543 }
2544
22582545 Section("Features") {
22592546 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
22602547 Text(label)
@@ -2264,7 +2551,7 @@ struct SettingsView: View {
22642551 Section("About") {
22652552 LabeledContent("Version", value: appVersion)
22662553 LabeledContent("Storage", value: "Local-only")
2267 LabeledContent("Report Schema", value: "3.1.0")
2554 LabeledContent("Report Schema", value: "3.2.0")
22682555 }
22692556 }
22702557 .navigationTitle("Settings")
@@ -2304,6 +2591,9 @@ struct SettingsView: View {
23042591 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
23052592 resolverOption = DNSResolverOption.option(for: currentResolverURL)
23062593 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2594 Task {
2595 await viewModel.refreshUsageCredits()
2596 }
23072597 }
23082598 .onChange(of: resolverOption) { _, newValue in
23092599 guard let presetURL = newValue.urlString else {
DomainDig/DataAccessService.swift +1 −1
@@ -10,7 +10,7 @@ enum DataAccessService {
1010 case .extendedSubdomains:
1111 return FeatureAccessService.hasAccess(to: .extendedSubdomains)
1212 case .domainPricing:
13 return false
13 return FeatureAccessService.hasAccess(to: .domainPricing)
1414 }
1515 }
1616}
DomainDig/DomainViewModel.swift +336 −2
@@ -147,6 +147,9 @@ final class DomainViewModel {
147147 var ownershipResult: DomainOwnership?
148148 var ownershipLoading = false
149149 var ownershipError: String?
150 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
151 var ownershipHistoryLoading = false
152 var ownershipHistoryError: String?
150153
151154 var ptrRecord: String?
152155 var ptrLoading = false
@@ -159,6 +162,16 @@ final class DomainViewModel {
159162 var subdomains: [DiscoveredSubdomain] = []
160163 var subdomainsLoading = false
161164 var subdomainsError: String?
165 var extendedSubdomains: [DiscoveredSubdomain] = []
166 var extendedSubdomainsLoading = false
167 var extendedSubdomainsError: String?
168 var dnsHistory: [DNSHistoryEvent] = []
169 var dnsHistoryLoading = false
170 var dnsHistoryError: String?
171 var domainPricing: DomainPricingInsight?
172 var domainPricingLoading = false
173 var domainPricingError: String?
174 var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
162175
163176 var portScanResults: [PortScanResult] = []
164177 var portScanLoading = false
@@ -263,8 +276,12 @@ final class DomainViewModel {
263276 if sslLoading || hstsLoading { labels.append("TLS") }
264277 if httpHeadersLoading { labels.append("HTTP") }
265278 if ownershipLoading { labels.append("Ownership") }
279 if ownershipHistoryLoading { labels.append("Ownership History") }
266280 if emailSecurityLoading { labels.append("Email") }
267281 if subdomainsLoading { labels.append("Subdomains") }
282 if extendedSubdomainsLoading { labels.append("Extended Subdomains") }
283 if dnsHistoryLoading { labels.append("DNS History") }
284 if domainPricingLoading { labels.append("Pricing") }
268285 if redirectChainLoading { labels.append("Redirects") }
269286 if reachabilityLoading { labels.append("Reachability") }
270287 if ipGeolocationLoading { labels.append("Geolocation") }
@@ -438,12 +455,20 @@ final class DomainViewModel {
438455 emailSecurityError: emailSecurityError,
439456 ownership: ownershipResult,
440457 ownershipError: ownershipError,
458 ownershipHistory: ownershipHistory,
459 ownershipHistoryError: ownershipHistoryError,
441460 ptrRecord: ptrRecord,
442461 ptrError: ptrError,
443462 redirectChain: redirectChain,
444463 redirectChainError: redirectChainError,
445464 subdomains: subdomains,
446465 subdomainsError: subdomainsError,
466 extendedSubdomains: extendedSubdomains,
467 extendedSubdomainsError: extendedSubdomainsError,
468 dnsHistory: dnsHistory,
469 dnsHistoryError: dnsHistoryError,
470 domainPricing: domainPricing,
471 domainPricingError: domainPricingError,
447472 portScanResults: allPortScanResults,
448473 portScanError: combinedPortScanError,
449474 changeSummary: currentChangeSummary,
@@ -482,6 +507,23 @@ final class DomainViewModel {
482507 currentReport?.web
483508 }
484509
510 var ownershipHistoryCreditStatus: UsageCreditStatus {
511 usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory)
512 }
513
514 var dnsHistoryCreditStatus: UsageCreditStatus {
515 usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory)
516 }
517
518 var extendedSubdomainsCreditStatus: UsageCreditStatus {
519 usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains)
520 }
521
522 var combinedSubdomains: [DiscoveredSubdomain] {
523 let existingHosts = Set(subdomains.map { $0.hostname.lowercased() })
524 return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) }
525 }
526
485527 var summaryFields: [SummaryFieldViewData] {
486528 Self.summaryFields(from: currentSnapshot)
487529 }
@@ -527,7 +569,7 @@ final class DomainViewModel {
527569 }
528570
529571 var subdomainRows: [SubdomainRowViewData] {
530 Self.subdomainRows(from: currentSnapshot)
572 Self.subdomainRows(from: combinedSubdomains)
531573 }
532574
533575 var reachabilityRows: [ReachabilityRowViewData] {
@@ -934,6 +976,151 @@ final class DomainViewModel {
934976 return String(data: data, encoding: .utf8)
935977 }
936978
979 func loadOwnershipHistory() async {
980 guard !searchedDomain.isEmpty else { return }
981 guard DataAccessService.hasAccess(to: .ownershipHistory) else {
982 upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory)
983 return
984 }
985 guard ownershipHistory.isEmpty else { return }
986
987 let creditStatus = await UsageCreditService.shared.status(for: .ownershipHistory)
988 guard !creditStatus.isExhausted else {
989 ownershipHistoryError = "No ownership history credits remaining"
990 await refreshUsageCredits()
991 return
992 }
993
994 ownershipHistoryLoading = true
995 ownershipHistoryError = nil
996
997 let outcome = await ExternalDataService.shared.ownershipHistory(
998 domain: searchedDomain,
999 currentOwnership: ownershipResult,
1000 historyEntries: history
1001 )
1002
1003 switch outcome.value {
1004 case let .success(events):
1005 ownershipHistory = events
1006 ownershipHistoryError = nil
1007 if outcome.source != .cached {
1008 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1009 }
1010 case let .empty(message):
1011 ownershipHistory = []
1012 ownershipHistoryError = message
1013 if outcome.source != .cached {
1014 _ = await UsageCreditService.shared.consume(.ownershipHistory)
1015 }
1016 case let .error(message):
1017 ownershipHistory = []
1018 ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable")
1019 }
1020
1021 ownershipHistoryLoading = false
1022 _ = saveHistoryEntry(replaceLatest: true)
1023 await refreshUsageCredits()
1024 }
1025
1026 func loadDNSHistory() async {
1027 guard !searchedDomain.isEmpty else { return }
1028 guard DataAccessService.hasAccess(to: .dnsHistory) else {
1029 upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory)
1030 return
1031 }
1032 guard dnsHistory.isEmpty else { return }
1033
1034 let creditStatus = await UsageCreditService.shared.status(for: .dnsHistory)
1035 guard !creditStatus.isExhausted else {
1036 dnsHistoryError = "No DNS history credits remaining"
1037 await refreshUsageCredits()
1038 return
1039 }
1040
1041 dnsHistoryLoading = true
1042 dnsHistoryError = nil
1043
1044 let outcome = await ExternalDataService.shared.dnsHistory(
1045 domain: searchedDomain,
1046 dnsSections: dnsSections,
1047 historyEntries: history
1048 )
1049
1050 switch outcome.value {
1051 case let .success(events):
1052 dnsHistory = events
1053 dnsHistoryError = nil
1054 if outcome.source != .cached {
1055 _ = await UsageCreditService.shared.consume(.dnsHistory)
1056 }
1057 case let .empty(message):
1058 dnsHistory = []
1059 dnsHistoryError = message
1060 if outcome.source != .cached {
1061 _ = await UsageCreditService.shared.consume(.dnsHistory)
1062 }
1063 case let .error(message):
1064 dnsHistory = []
1065 dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable")
1066 }
1067
1068 dnsHistoryLoading = false
1069 _ = saveHistoryEntry(replaceLatest: true)
1070 await refreshUsageCredits()
1071 }
1072
1073 func loadExtendedSubdomains() async {
1074 guard !searchedDomain.isEmpty else { return }
1075 guard DataAccessService.hasAccess(to: .extendedSubdomains) else {
1076 upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains)
1077 return
1078 }
1079 guard extendedSubdomains.isEmpty else { return }
1080
1081 let creditStatus = await UsageCreditService.shared.status(for: .extendedSubdomains)
1082 guard !creditStatus.isExhausted else {
1083 extendedSubdomainsError = "No extended subdomain credits remaining"
1084 await refreshUsageCredits()
1085 return
1086 }
1087
1088 extendedSubdomainsLoading = true
1089 extendedSubdomainsError = nil
1090
1091 let outcome = await ExternalDataService.shared.extendedSubdomains(
1092 domain: searchedDomain,
1093 existing: subdomains
1094 )
1095
1096 switch outcome.value {
1097 case let .success(results):
1098 extendedSubdomains = results
1099 extendedSubdomainsError = nil
1100 if outcome.source != .cached {
1101 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1102 }
1103 case let .empty(message):
1104 extendedSubdomains = []
1105 extendedSubdomainsError = message
1106 if outcome.source != .cached {
1107 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
1108 }
1109 case let .error(message):
1110 extendedSubdomains = []
1111 extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable")
1112 }
1113
1114 extendedSubdomainsLoading = false
1115 _ = saveHistoryEntry(replaceLatest: true)
1116 await refreshUsageCredits()
1117 }
1118
1119 func refreshUsageCredits() async {
1120 let statuses = await UsageCreditService.shared.allStatuses()
1121 usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) })
1122 }
1123
9371124 func exportBatchText() -> String {
9381125 DomainReportExporter.batchText(
9391126 for: currentBatchReports(),
@@ -1019,11 +1206,18 @@ final class DomainViewModel {
10191206 lastLookupDurationMs = snapshot.totalLookupDurationMs
10201207 refreshingTrackedDomainID = nil
10211208
1209 if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
1210 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
1211 }
1212
10221213 guard snapshot.statusMessage == nil else {
1214 await refreshUsageCredits()
10231215 return history.first(where: { $0.id == snapshot.historyEntryID })
10241216 }
10251217
1026 return saveHistoryEntry(replaceLatest: false)
1218 let entry = saveHistoryEntry(replaceLatest: false)
1219 await refreshUsageCredits()
1220 return entry
10271221 }
10281222
10291223 private func applySnapshot(_ snapshot: LookupSnapshot) {
@@ -1066,12 +1260,20 @@ final class DomainViewModel {
10661260 emailSecurityError = snapshot.emailSecurityError
10671261 ownershipResult = snapshot.ownership
10681262 ownershipError = snapshot.ownershipError
1263 ownershipHistory = snapshot.ownershipHistory
1264 ownershipHistoryError = snapshot.ownershipHistoryError
10691265 ptrRecord = snapshot.ptrRecord
10701266 ptrError = snapshot.ptrError
10711267 redirectChain = snapshot.redirectChain
10721268 redirectChainError = snapshot.redirectChainError
10731269 subdomains = snapshot.subdomains
10741270 subdomainsError = snapshot.subdomainsError
1271 extendedSubdomains = snapshot.extendedSubdomains
1272 extendedSubdomainsError = snapshot.extendedSubdomainsError
1273 dnsHistory = snapshot.dnsHistory
1274 dnsHistoryError = snapshot.dnsHistoryError
1275 domainPricing = snapshot.domainPricing
1276 domainPricingError = snapshot.domainPricingError
10751277 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
10761278 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
10771279 portScanError = snapshot.portScanError
@@ -1087,9 +1289,13 @@ final class DomainViewModel {
10871289 ipGeolocationLoading = false
10881290 emailSecurityLoading = false
10891291 ownershipLoading = false
1292 ownershipHistoryLoading = false
10901293 ptrLoading = false
10911294 redirectChainLoading = false
10921295 subdomainsLoading = false
1296 extendedSubdomainsLoading = false
1297 dnsHistoryLoading = false
1298 domainPricingLoading = false
10931299 portScanLoading = false
10941300 customPortScanLoading = false
10951301 }
@@ -1144,12 +1350,20 @@ final class DomainViewModel {
11441350 emailSecurityError: previousSnapshot.emailSecurityError,
11451351 ownership: previousSnapshot.ownership,
11461352 ownershipError: previousSnapshot.ownershipError,
1353 ownershipHistory: previousSnapshot.ownershipHistory,
1354 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
11471355 ptrRecord: previousSnapshot.ptrRecord,
11481356 ptrError: previousSnapshot.ptrError,
11491357 redirectChain: previousSnapshot.redirectChain,
11501358 redirectChainError: previousSnapshot.redirectChainError,
11511359 subdomains: previousSnapshot.subdomains,
11521360 subdomainsError: previousSnapshot.subdomainsError,
1361 extendedSubdomains: previousSnapshot.extendedSubdomains,
1362 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
1363 dnsHistory: previousSnapshot.dnsHistory,
1364 dnsHistoryError: previousSnapshot.dnsHistoryError,
1365 domainPricing: previousSnapshot.domainPricing,
1366 domainPricingError: previousSnapshot.domainPricingError,
11531367 portScanResults: previousSnapshot.portScanResults,
11541368 portScanError: previousSnapshot.portScanError,
11551369 changeSummary: previousSnapshot.changeSummary,
@@ -1515,9 +1729,13 @@ final class DomainViewModel {
15151729 emailSecurity: snapshot.emailSecurity,
15161730 mtaSts: snapshot.emailSecurity?.mtaSts,
15171731 ownership: snapshot.ownership,
1732 ownershipHistory: snapshot.ownershipHistory,
15181733 ptrRecord: snapshot.ptrRecord,
15191734 redirectChain: snapshot.redirectChain,
15201735 subdomains: snapshot.subdomains,
1736 extendedSubdomains: snapshot.extendedSubdomains,
1737 dnsHistory: snapshot.dnsHistory,
1738 domainPricing: snapshot.domainPricing,
15211739 portScanResults: snapshot.portScanResults,
15221740 hstsPreloaded: snapshot.hstsPreloaded,
15231741 availabilityResult: snapshot.availabilityResult,
@@ -1549,9 +1767,13 @@ final class DomainViewModel {
15491767 ipGeolocationError: snapshot.ipGeolocationError,
15501768 emailSecurityError: snapshot.emailSecurityError,
15511769 ownershipError: snapshot.ownershipError,
1770 ownershipHistoryError: snapshot.ownershipHistoryError,
15521771 ptrError: snapshot.ptrError,
15531772 redirectChainError: snapshot.redirectChainError,
15541773 subdomainsError: snapshot.subdomainsError,
1774 extendedSubdomainsError: snapshot.extendedSubdomainsError,
1775 dnsHistoryError: snapshot.dnsHistoryError,
1776 domainPricingError: snapshot.domainPricingError,
15551777 portScanError: snapshot.portScanError
15561778 )
15571779
@@ -2038,6 +2260,9 @@ final class DomainViewModel {
20382260 ownershipResult = nil
20392261 ownershipError = nil
20402262 ownershipLoading = false
2263 ownershipHistory = []
2264 ownershipHistoryError = nil
2265 ownershipHistoryLoading = false
20412266 ptrRecord = nil
20422267 ptrError = nil
20432268 ptrLoading = false
@@ -2047,6 +2272,15 @@ final class DomainViewModel {
20472272 subdomains = []
20482273 subdomainsError = nil
20492274 subdomainsLoading = false
2275 extendedSubdomains = []
2276 extendedSubdomainsError = nil
2277 extendedSubdomainsLoading = false
2278 dnsHistory = []
2279 dnsHistoryError = nil
2280 dnsHistoryLoading = false
2281 domainPricing = nil
2282 domainPricingError = nil
2283 domainPricingLoading = false
20502284 portScanResults = []
20512285 portScanError = nil
20522286 portScanLoading = false
@@ -2072,9 +2306,13 @@ final class DomainViewModel {
20722306 ipGeolocationLoading = loading
20732307 emailSecurityLoading = loading
20742308 ownershipLoading = loading
2309 ownershipHistoryLoading = false
20752310 ptrLoading = loading
20762311 redirectChainLoading = loading
20772312 subdomainsLoading = loading
2313 extendedSubdomainsLoading = false
2314 dnsHistoryLoading = false
2315 domainPricingLoading = false
20782316 portScanLoading = loading
20792317 }
20802318
@@ -2248,12 +2486,20 @@ final class DomainViewModel {
22482486 emailSecurityError: nil,
22492487 ownership: nil,
22502488 ownershipError: nil,
2489 ownershipHistory: [],
2490 ownershipHistoryError: nil,
22512491 ptrRecord: nil,
22522492 ptrError: nil,
22532493 redirectChain: [],
22542494 redirectChainError: nil,
22552495 subdomains: [],
22562496 subdomainsError: nil,
2497 extendedSubdomains: [],
2498 extendedSubdomainsError: nil,
2499 dnsHistory: [],
2500 dnsHistoryError: nil,
2501 domainPricing: nil,
2502 domainPricingError: nil,
22572503 portScanResults: [],
22582504 portScanError: nil,
22592505 changeSummary: trackedDomain.lastChangeSummary,
@@ -2435,6 +2681,30 @@ final class DomainViewModel {
24352681 at: 3
24362682 )
24372683 }
2684 if let pricing = snapshot.domainPricing {
2685 rows.append(
2686 InfoRowViewData(
2687 label: "External Price",
2688 value: pricing.estimatedPrice ?? "Unavailable",
2689 tone: .secondary
2690 )
2691 )
2692 if let premiumIndicator = pricing.premiumIndicator {
2693 rows.append(
2694 InfoRowViewData(
2695 label: "Premium",
2696 value: premiumIndicator ? "Yes" : "No",
2697 tone: premiumIndicator ? .warning : .secondary
2698 )
2699 )
2700 }
2701 if let resaleSignal = pricing.resaleSignal {
2702 rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary))
2703 }
2704 if let auctionSignal = pricing.auctionSignal {
2705 rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
2706 }
2707 }
24382708 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
24392709 rows.insert(
24402710 InfoRowViewData(
@@ -2460,6 +2730,15 @@ final class DomainViewModel {
24602730 }
24612731 }
24622732
2733 static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] {
2734 subdomains.map { subdomain in
2735 SubdomainRowViewData(
2736 hostname: subdomain.hostname,
2737 isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname)
2738 )
2739 }
2740 }
2741
24632742 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
24642743 snapshot.dnsSections.map { section in
24652744 DNSRecordSectionViewData(
@@ -3098,6 +3377,61 @@ final class DomainViewModel {
30983377 return formatter
30993378 }()
31003379
3380 private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async {
3381 domainPricingLoading = true
3382 let outcome = await ExternalDataService.shared.pricing(domain: domain)
3383
3384 switch outcome.value {
3385 case let .success(pricing):
3386 domainPricing = pricing
3387 domainPricingError = nil
3388 case let .empty(message):
3389 domainPricing = nil
3390 domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
3391 case let .error(message):
3392 domainPricing = nil
3393 domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable")
3394 }
3395
3396 domainPricingLoading = false
3397
3398 if persistAfterFetch {
3399 _ = saveHistoryEntry(replaceLatest: true)
3400 }
3401 }
3402
3403 private func conciseExternalMessage(_ message: String, fallback: String) -> String {
3404 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
3405 if trimmed.isEmpty {
3406 return fallback
3407 }
3408 if trimmed.localizedCaseInsensitiveContains("rate") {
3409 return "Rate limited. Try again later."
3410 }
3411 if trimmed.localizedCaseInsensitiveContains("invalid") {
3412 return "External data was invalid."
3413 }
3414 if trimmed.localizedCaseInsensitiveContains("network") {
3415 return "External data is offline."
3416 }
3417 return trimmed
3418 }
3419
3420 private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] {
3421 Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in
3422 (feature, fallbackCreditStatus(for: feature))
3423 })
3424 }
3425
3426 private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus {
3427 UsageCreditStatus(
3428 feature: feature,
3429 remaining: feature.defaultAllowance,
3430 total: feature.defaultAllowance,
3431 resetContext: "Resets with app version \(AppVersion.current)"
3432 )
3433 }
3434
31013435 private static func csvEscaped(_ value: String) -> String {
31023436 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
31033437 return "\"\(escaped)\""
DomainDig/ExternalDataService.swift added +627
@@ -0,0 +1,627 @@
1import Foundation
2
3actor ExternalDataService {
4 static let shared = ExternalDataService()
5
6 private enum RequestKey: Hashable {
7 case ownershipHistory(String)
8 case dnsHistory(String)
9 case extendedSubdomains(String)
10 case pricing(String)
11 }
12
13 private enum RateLimitBucket: Hashable {
14 case history
15 case subdomains
16 case pricing
17
18 var minimumSpacing: TimeInterval {
19 switch self {
20 case .history:
21 return 1.0
22 case .subdomains:
23 return 1.5
24 case .pricing:
25 return 1.0
26 }
27 }
28 }
29
30 private enum CachedPayload {
31 case ownershipHistory(ServiceResult<[DomainOwnershipHistoryEvent]>)
32 case dnsHistory(ServiceResult<[DNSHistoryEvent]>)
33 case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>)
34 case pricing(ServiceResult<DomainPricingInsight>)
35 }
36
37 private struct CacheEntry {
38 let payload: CachedPayload
39 let expiresAt: Date
40 }
41
42 private struct Configuration {
43 let ownershipHistoryURL: String?
44 let dnsHistoryURL: String?
45 let extendedSubdomainsURL: String?
46 let pricingURL: String?
47 }
48
49 private let ttl: TimeInterval = 900
50 private let session: URLSession = .shared
51 private var cache: [RequestKey: CacheEntry] = [:]
52 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
53 private var nextAllowedAt: [RateLimitBucket: Date] = [:]
54
55 func clearCache() {
56 cache.removeAll()
57 inFlight.values.forEach { $0.cancel() }
58 inFlight.removeAll()
59 nextAllowedAt.removeAll()
60 }
61
62 func ownershipHistory(
63 domain: String,
64 currentOwnership: DomainOwnership?,
65 historyEntries: [HistoryEntry]
66 ) async -> CachedLookupResult<ServiceResult<[DomainOwnershipHistoryEvent]>> {
67 let normalizedDomain = Self.normalize(domain)
68 return await execute(
69 key: .ownershipHistory(normalizedDomain),
70 rateLimitBucket: .history,
71 extract: { payload in
72 guard case let .ownershipHistory(result) = payload else { return nil }
73 return result
74 },
75 operation: { [configuration = configuration()] in
76 let localEvents = Self.localOwnershipHistory(
77 domain: normalizedDomain,
78 currentOwnership: currentOwnership,
79 historyEntries: historyEntries
80 )
81 let externalEvents = await self.fetchOwnershipHistory(domain: normalizedDomain, configuration: configuration)
82 return .ownershipHistory(Self.mergeOwnershipHistory(localEvents: localEvents, externalEvents: externalEvents))
83 }
84 )
85 }
86
87 func dnsHistory(
88 domain: String,
89 dnsSections: [DNSSection],
90 historyEntries: [HistoryEntry]
91 ) async -> CachedLookupResult<ServiceResult<[DNSHistoryEvent]>> {
92 let normalizedDomain = Self.normalize(domain)
93 return await execute(
94 key: .dnsHistory(normalizedDomain),
95 rateLimitBucket: .history,
96 extract: { payload in
97 guard case let .dnsHistory(result) = payload else { return nil }
98 return result
99 },
100 operation: { [configuration = configuration()] in
101 let localEvents = Self.localDNSHistory(
102 domain: normalizedDomain,
103 dnsSections: dnsSections,
104 historyEntries: historyEntries
105 )
106 let externalEvents = await self.fetchDNSHistory(domain: normalizedDomain, configuration: configuration)
107 return .dnsHistory(Self.mergeDNSHistory(localEvents: localEvents, externalEvents: externalEvents))
108 }
109 )
110 }
111
112 func extendedSubdomains(
113 domain: String,
114 existing: [DiscoveredSubdomain]
115 ) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
116 let normalizedDomain = Self.normalize(domain)
117 return await execute(
118 key: .extendedSubdomains(normalizedDomain),
119 rateLimitBucket: .subdomains,
120 extract: { payload in
121 guard case let .extendedSubdomains(result) = payload else { return nil }
122 return result
123 },
124 operation: { [configuration = configuration()] in
125 var merged = existing
126 switch await SubdomainDiscoveryService.discover(for: normalizedDomain, limit: 100) {
127 case let .success(results):
128 merged = Self.mergeSubdomains(primary: merged, additional: results.map {
129 DiscoveredSubdomain(hostname: $0.hostname, source: $0.source ?? "crt.sh", isExtended: true)
130 })
131 case .empty, .error:
132 break
133 }
134
135 if let external = await self.fetchExtendedSubdomains(domain: normalizedDomain, configuration: configuration) {
136 merged = Self.mergeSubdomains(primary: merged, additional: external)
137 }
138
139 if merged.count <= existing.count {
140 return .extendedSubdomains(.empty("No extended subdomains available"))
141 }
142
143 let onlyExtended = merged.filter(\.isExtended)
144 return .extendedSubdomains(.success(onlyExtended.sorted { $0.hostname < $1.hostname }))
145 }
146 )
147 }
148
149 func pricing(domain: String) async -> CachedLookupResult<ServiceResult<DomainPricingInsight>> {
150 let normalizedDomain = Self.normalize(domain)
151 return await execute(
152 key: .pricing(normalizedDomain),
153 rateLimitBucket: .pricing,
154 extract: { payload in
155 guard case let .pricing(result) = payload else { return nil }
156 return result
157 },
158 operation: { [configuration = configuration()] in
159 .pricing(await self.fetchPricing(domain: normalizedDomain, configuration: configuration))
160 }
161 )
162 }
163
164 private func execute<T>(
165 key: RequestKey,
166 rateLimitBucket: RateLimitBucket,
167 extract: @escaping (CachedPayload) -> T?,
168 operation: @escaping @Sendable () async -> CachedPayload
169 ) async -> CachedLookupResult<T> {
170 if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
171 return CachedLookupResult(value: value, source: .cached)
172 }
173
174 if let task = inFlight[key], let value = extract(await task.value) {
175 return CachedLookupResult(value: value, source: .mixed)
176 }
177
178 let task = Task<CachedPayload, Never> {
179 await self.enforceRateLimit(for: rateLimitBucket)
180 return await operation()
181 }
182 inFlight[key] = task
183
184 let payload = await task.value
185 cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
186 inFlight[key] = nil
187
188 guard let value = extract(payload) else {
189 fatalError("ExternalDataService payload extraction mismatch")
190 }
191
192 return CachedLookupResult(value: value, source: .live)
193 }
194
195 private func enforceRateLimit(for bucket: RateLimitBucket) async {
196 let now = Date()
197 if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
198 let delay = nextAllowed.timeIntervalSince(now)
199 if delay > 0 {
200 try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
201 }
202 }
203 nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
204 }
205
206 private func configuration() -> Configuration {
207 let defaults = UserDefaults.standard
208 return Configuration(
209 ownershipHistoryURL: defaults.string(forKey: "externalData.ownershipHistoryURL")
210 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalOwnershipHistoryURL") as? String,
211 dnsHistoryURL: defaults.string(forKey: "externalData.dnsHistoryURL")
212 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalDNSHistoryURL") as? String,
213 extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL")
214 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String,
215 pricingURL: defaults.string(forKey: "externalData.pricingURL")
216 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String
217 )
218 }
219
220 private func fetchOwnershipHistory(
221 domain: String,
222 configuration: Configuration
223 ) async -> [DomainOwnershipHistoryEvent] {
224 guard let template = configuration.ownershipHistoryURL,
225 let url = Self.url(from: template, domain: domain) else {
226 return []
227 }
228
229 switch await requestData(url: url) {
230 case let .success(data):
231 return parseOwnershipHistoryEvents(from: data)
232 case .empty, .error:
233 return []
234 }
235 }
236
237 private func fetchDNSHistory(
238 domain: String,
239 configuration: Configuration
240 ) async -> [DNSHistoryEvent] {
241 guard let template = configuration.dnsHistoryURL,
242 let url = Self.url(from: template, domain: domain) else {
243 return []
244 }
245
246 switch await requestData(url: url) {
247 case let .success(data):
248 return parseDNSHistoryEvents(from: data)
249 case .empty, .error:
250 return []
251 }
252 }
253
254 private func fetchExtendedSubdomains(
255 domain: String,
256 configuration: Configuration
257 ) async -> [DiscoveredSubdomain]? {
258 guard let template = configuration.extendedSubdomainsURL,
259 let url = Self.url(from: template, domain: domain) else {
260 return nil
261 }
262
263 switch await requestData(url: url) {
264 case let .success(data):
265 return parseSubdomains(from: data)
266 case .empty, .error:
267 return nil
268 }
269 }
270
271 private func fetchPricing(
272 domain: String,
273 configuration: Configuration
274 ) async -> ServiceResult<DomainPricingInsight> {
275 guard let template = configuration.pricingURL,
276 let url = Self.url(from: template, domain: domain) else {
277 return .empty("External pricing unavailable")
278 }
279
280 switch await requestData(url: url) {
281 case let .success(data):
282 guard let pricing = parsePricing(from: data) else {
283 return .error("Invalid external response")
284 }
285 return .success(pricing)
286 case let .empty(message):
287 return .empty(message)
288 case let .error(message):
289 return .error(message)
290 }
291 }
292
293 private func requestData(url: URL) async -> ServiceResult<Data> {
294 for attempt in 0..<2 {
295 do {
296 let (data, response) = try await session.data(for: URLRequest(url: url, timeoutInterval: 8))
297 guard let httpResponse = response as? HTTPURLResponse else {
298 return .error("External data unavailable")
299 }
300
301 switch httpResponse.statusCode {
302 case 200:
303 return .success(data)
304 case 204, 404:
305 return .empty("No external data available")
306 case 429:
307 return .error("Rate limit reached")
308 case 500...599 where attempt == 0:
309 continue
310 default:
311 return .error("External provider failed")
312 }
313 } catch is DecodingError {
314 return .error("Invalid external response")
315 } catch {
316 if attempt == 0 {
317 continue
318 }
319 return .error(error.localizedDescription)
320 }
321 }
322
323 return .error("External provider failed")
324 }
325
326 private func parseOwnershipHistoryEvents(from data: Data) -> [DomainOwnershipHistoryEvent] {
327 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
328 let rawEvents = json["events"] as? [[String: Any]] else {
329 return []
330 }
331
332 return rawEvents.compactMap { event in
333 guard let dateString = event["date"] as? String,
334 let date = Self.iso8601DateFormatter.date(from: dateString) else {
335 return nil
336 }
337
338 return DomainOwnershipHistoryEvent(
339 date: date,
340 summary: event["summary"] as? String ?? "Ownership change observed",
341 registrar: event["registrar"] as? String,
342 registrant: event["registrant"] as? String,
343 nameservers: event["nameservers"] as? [String] ?? [],
344 source: event["source"] as? String ?? "Configured external history feed",
345 isExternal: true
346 )
347 }
348 }
349
350 private func parseDNSHistoryEvents(from data: Data) -> [DNSHistoryEvent] {
351 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
352 let rawEvents = json["events"] as? [[String: Any]] else {
353 return []
354 }
355
356 return rawEvents.compactMap { event in
357 guard let dateString = event["date"] as? String,
358 let date = Self.iso8601DateFormatter.date(from: dateString) else {
359 return nil
360 }
361
362 return DNSHistoryEvent(
363 date: date,
364 summary: event["summary"] as? String ?? "DNS change observed",
365 aRecords: event["a_records"] as? [String] ?? [],
366 nameservers: event["nameservers"] as? [String] ?? [],
367 source: event["source"] as? String ?? "Configured external history feed",
368 isExternal: true
369 )
370 }
371 }
372
373 private func parseSubdomains(from data: Data) -> [DiscoveredSubdomain] {
374 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
375 let rawSubdomains = json["subdomains"] as? [[String: Any]] else {
376 return []
377 }
378
379 return rawSubdomains.compactMap { item in
380 guard let hostname = item["hostname"] as? String else { return nil }
381 return DiscoveredSubdomain(
382 hostname: hostname,
383 source: item["source"] as? String ?? "Configured external subdomain feed",
384 isExtended: true
385 )
386 }
387 }
388
389 private func parsePricing(from data: Data) -> DomainPricingInsight? {
390 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
391 return nil
392 }
393
394 return DomainPricingInsight(
395 estimatedPrice: json["estimated_price"] as? String,
396 premiumIndicator: json["premium"] as? Bool,
397 resaleSignal: json["resale_signal"] as? String,
398 auctionSignal: json["auction_signal"] as? String,
399 source: json["source"] as? String ?? "Configured external pricing feed",
400 collectedAt: Date()
401 )
402 }
403
404 private static func localOwnershipHistory(
405 domain: String,
406 currentOwnership: DomainOwnership?,
407 historyEntries: [HistoryEntry]
408 ) -> [DomainOwnershipHistoryEvent] {
409 let domainHistory = historyEntries
410 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
411 .sorted { $0.timestamp < $1.timestamp }
412
413 var events: [DomainOwnershipHistoryEvent] = []
414 var previousOwnership: DomainOwnership?
415
416 for entry in domainHistory {
417 guard let ownership = entry.ownership else { continue }
418 let summary = ownershipSummaryChange(previous: previousOwnership, current: ownership)
419 if let summary {
420 events.append(
421 DomainOwnershipHistoryEvent(
422 date: entry.timestamp,
423 summary: summary,
424 registrar: ownership.registrar,
425 registrant: ownership.registrant,
426 nameservers: ownership.nameservers,
427 source: "Local observations",
428 isExternal: false
429 )
430 )
431 }
432 previousOwnership = ownership
433 }
434
435 if let currentOwnership, events.isEmpty {
436 events.append(
437 DomainOwnershipHistoryEvent(
438 date: Date(),
439 summary: "Current ownership snapshot",
440 registrar: currentOwnership.registrar,
441 registrant: currentOwnership.registrant,
442 nameservers: currentOwnership.nameservers,
443 source: "Local observations",
444 isExternal: false
445 )
446 )
447 }
448
449 return events.sorted { $0.date > $1.date }
450 }
451
452 private static func localDNSHistory(
453 domain: String,
454 dnsSections: [DNSSection],
455 historyEntries: [HistoryEntry]
456 ) -> [DNSHistoryEvent] {
457 let domainHistory = historyEntries
458 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
459 .sorted { $0.timestamp < $1.timestamp }
460
461 var events: [DNSHistoryEvent] = []
462 var previousARecords: [String] = []
463 var previousNameservers: [String] = []
464
465 for entry in domainHistory {
466 let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections)
467 let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections)
468 let summary = dnsSummaryChange(
469 previousARecords: previousARecords,
470 currentARecords: aRecords,
471 previousNameservers: previousNameservers,
472 currentNameservers: nameservers
473 )
474
475 if let summary {
476 events.append(
477 DNSHistoryEvent(
478 date: entry.timestamp,
479 summary: summary,
480 aRecords: aRecords,
481 nameservers: nameservers,
482 source: "Local observations",
483 isExternal: false
484 )
485 )
486 }
487
488 previousARecords = aRecords
489 previousNameservers = nameservers
490 }
491
492 if events.isEmpty {
493 let currentARecords = Self.dnsValues(for: .A, in: dnsSections)
494 let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections)
495 if !currentARecords.isEmpty || !currentNameservers.isEmpty {
496 events.append(
497 DNSHistoryEvent(
498 date: Date(),
499 summary: "Current DNS snapshot",
500 aRecords: currentARecords,
501 nameservers: currentNameservers,
502 source: "Local observations",
503 isExternal: false
504 )
505 )
506 }
507 }
508
509 return events.sorted { $0.date > $1.date }
510 }
511
512 private static func mergeOwnershipHistory(
513 localEvents: [DomainOwnershipHistoryEvent],
514 externalEvents: [DomainOwnershipHistoryEvent]
515 ) -> ServiceResult<[DomainOwnershipHistoryEvent]> {
516 let merged = (externalEvents + localEvents)
517 .sorted { $0.date > $1.date }
518 .reduce(into: [DomainOwnershipHistoryEvent]()) { partialResult, event in
519 let duplicate = partialResult.contains {
520 $0.date == event.date
521 && $0.summary == event.summary
522 && $0.registrar == event.registrar
523 && $0.nameservers == event.nameservers
524 }
525 if !duplicate {
526 partialResult.append(event)
527 }
528 }
529
530 return merged.isEmpty ? .empty("No ownership history available") : .success(merged)
531 }
532
533 private static func mergeDNSHistory(
534 localEvents: [DNSHistoryEvent],
535 externalEvents: [DNSHistoryEvent]
536 ) -> ServiceResult<[DNSHistoryEvent]> {
537 let merged = (externalEvents + localEvents)
538 .sorted { $0.date > $1.date }
539 .reduce(into: [DNSHistoryEvent]()) { partialResult, event in
540 let duplicate = partialResult.contains {
541 $0.date == event.date
542 && $0.summary == event.summary
543 && $0.aRecords == event.aRecords
544 && $0.nameservers == event.nameservers
545 }
546 if !duplicate {
547 partialResult.append(event)
548 }
549 }
550
551 return merged.isEmpty ? .empty("No DNS history available") : .success(merged)
552 }
553
554 private static func mergeSubdomains(
555 primary: [DiscoveredSubdomain],
556 additional: [DiscoveredSubdomain]
557 ) -> [DiscoveredSubdomain] {
558 var seen = Set(primary.map { $0.hostname.lowercased() })
559 var merged = primary
560
561 for subdomain in additional where seen.insert(subdomain.hostname.lowercased()).inserted {
562 merged.append(subdomain)
563 }
564
565 return merged.sorted { $0.hostname < $1.hostname }
566 }
567
568 private static func ownershipSummaryChange(previous: DomainOwnership?, current: DomainOwnership) -> String? {
569 guard let previous else {
570 return "Initial ownership observation"
571 }
572
573 var changes: [String] = []
574 if previous.registrar != current.registrar {
575 changes.append("Registrar changed")
576 }
577 if previous.registrant != current.registrant, current.registrant != nil {
578 changes.append("Ownership changed")
579 }
580 if previous.nameservers != current.nameservers {
581 changes.append("Nameservers changed")
582 }
583
584 return changes.isEmpty ? nil : changes.joined(separator: " • ")
585 }
586
587 private static func dnsSummaryChange(
588 previousARecords: [String],
589 currentARecords: [String],
590 previousNameservers: [String],
591 currentNameservers: [String]
592 ) -> String? {
593 var changes: [String] = []
594 if previousARecords != currentARecords, !currentARecords.isEmpty {
595 changes.append("A records changed")
596 }
597 if previousNameservers != currentNameservers, !currentNameservers.isEmpty {
598 changes.append("NS records changed")
599 }
600 if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) {
601 changes.append("Initial DNS observation")
602 }
603 return changes.isEmpty ? nil : changes.joined(separator: " • ")
604 }
605
606 private static func url(from template: String, domain: String) -> URL? {
607 URL(string: template.replacingOccurrences(of: "{domain}", with: domain))
608 }
609
610 private static func normalize(_ domain: String) -> String {
611 domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
612 }
613
614 private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
615 sections
616 .first(where: { $0.recordType == type })?
617 .records
618 .map(\.value)
619 .sorted() ?? []
620 }
621
622 private static let iso8601DateFormatter: ISO8601DateFormatter = {
623 let formatter = ISO8601DateFormatter()
624 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
625 return formatter
626 }()
627}
DomainDig/FeatureAccessService.swift +13 −3
@@ -29,6 +29,7 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
2929 case ownershipHistory
3030 case dnsHistory
3131 case extendedSubdomains
32 case domainPricing
3233
3334 var id: String { rawValue }
3435
@@ -52,6 +53,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
5253 return "DNS history"
5354 case .extendedSubdomains:
5455 return "Extended subdomains"
56 case .domainPricing:
57 return "Domain pricing"
5558 }
5659 }
5760}
@@ -139,7 +142,7 @@ enum FeatureAccessService {
139142 switch capability {
140143 case .workflows, .batchOperations, .advancedExports:
141144 return "Available in Pro"
142 case .ownershipHistory, .dnsHistory, .extendedSubdomains:
145 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
143146 return "Available in Data+"
144147 case .limitedTracking:
145148 return "Tracking is limited on Free"
@@ -199,8 +202,15 @@ enum FeatureAccessService {
199202 }
200203
201204 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
202 UpgradePromptContext(
203 title: "Available in Pro",
205 let title: String
206 switch capability {
207 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
208 title = "Available in Data+"
209 default:
210 title = "Available in Pro"
211 }
212 return UpgradePromptContext(
213 title: title,
204214 message: upgradeMessage(for: capability),
205215 capability: capability
206216 )
DomainDig/HistoryView.swift +3
@@ -198,6 +198,9 @@ struct HistoryDetailView: View {
198198 trackedDomain: trackedDomain,
199199 workflows: viewModel.workflowsContaining(domain: entry.domain),
200200 trackingLimitMessage: nil,
201 pricingLoading: false,
202 pricingError: snapshot.domainPricingError,
203 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
201204 onTrack: {
202205 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
203206 },
DomainDig/Models.swift +155 −3
@@ -370,6 +370,117 @@ enum DataCapability: String, Codable {
370370 case domainPricing
371371}
372372
373enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
374 case ownershipHistory
375 case dnsHistory
376 case extendedSubdomains
377
378 nonisolated var id: String { rawValue }
379
380 nonisolated var title: String {
381 switch self {
382 case .ownershipHistory:
383 return "Ownership history"
384 case .dnsHistory:
385 return "DNS history"
386 case .extendedSubdomains:
387 return "Extended subdomains"
388 }
389 }
390
391 nonisolated var defaultAllowance: Int {
392 switch self {
393 case .ownershipHistory, .dnsHistory:
394 return 8
395 case .extendedSubdomains:
396 return 12
397 }
398 }
399}
400
401struct UsageCreditStatus: Codable, Equatable, Sendable {
402 let feature: UsageCreditFeature
403 let remaining: Int
404 let total: Int
405 let resetContext: String
406
407 nonisolated var summary: String {
408 "\(remaining) uses remaining"
409 }
410
411 nonisolated var isExhausted: Bool {
412 remaining <= 0
413 }
414}
415
416struct DomainOwnershipHistoryEvent: Identifiable, Codable, Equatable, Sendable {
417 let id: UUID
418 let date: Date
419 let summary: String
420 let registrar: String?
421 let registrant: String?
422 let nameservers: [String]
423 let source: String
424 let isExternal: Bool
425
426 nonisolated init(
427 id: UUID = UUID(),
428 date: Date,
429 summary: String,
430 registrar: String? = nil,
431 registrant: String? = nil,
432 nameservers: [String] = [],
433 source: String,
434 isExternal: Bool
435 ) {
436 self.id = id
437 self.date = date
438 self.summary = summary
439 self.registrar = registrar
440 self.registrant = registrant
441 self.nameservers = nameservers
442 self.source = source
443 self.isExternal = isExternal
444 }
445}
446
447struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
448 let id: UUID
449 let date: Date
450 let summary: String
451 let aRecords: [String]
452 let nameservers: [String]
453 let source: String
454 let isExternal: Bool
455
456 nonisolated init(
457 id: UUID = UUID(),
458 date: Date,
459 summary: String,
460 aRecords: [String] = [],
461 nameservers: [String] = [],
462 source: String,
463 isExternal: Bool
464 ) {
465 self.id = id
466 self.date = date
467 self.summary = summary
468 self.aRecords = aRecords
469 self.nameservers = nameservers
470 self.source = source
471 self.isExternal = isExternal
472 }
473}
474
475struct DomainPricingInsight: Codable, Equatable, Sendable {
476 let estimatedPrice: String?
477 let premiumIndicator: Bool?
478 let resaleSignal: String?
479 let auctionSignal: String?
480 let source: String
481 let collectedAt: Date
482}
483
373484enum HistoryDateFilter: String, CaseIterable, Identifiable {
374485 case today
375486 case last7Days
@@ -691,6 +802,7 @@ struct IPGeolocation: Codable {
691802
692803struct DomainOwnership: Codable, Equatable {
693804 let registrar: String?
805 let registrant: String?
694806 let createdDate: Date?
695807 let expirationDate: Date?
696808 let status: [String]
@@ -699,6 +811,7 @@ struct DomainOwnership: Codable, Equatable {
699811
700812 init(
701813 registrar: String? = nil,
814 registrant: String? = nil,
702815 createdDate: Date? = nil,
703816 expirationDate: Date? = nil,
704817 status: [String] = [],
@@ -706,6 +819,7 @@ struct DomainOwnership: Codable, Equatable {
706819 abuseEmail: String? = nil
707820 ) {
708821 self.registrar = registrar
822 self.registrant = registrant
709823 self.createdDate = createdDate
710824 self.expirationDate = expirationDate
711825 self.status = status
@@ -716,6 +830,7 @@ struct DomainOwnership: Codable, Equatable {
716830 init(from decoder: Decoder) throws {
717831 let container = try decoder.container(keyedBy: CodingKeys.self)
718832 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
833 registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
719834 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
720835 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
721836 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
@@ -724,9 +839,17 @@ struct DomainOwnership: Codable, Equatable {
724839 }
725840}
726841
727struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable {
842struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
728843 var id: String { hostname }
729844 let hostname: String
845 let source: String?
846 let isExtended: Bool
847
848 nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
849 self.hostname = hostname
850 self.source = source
851 self.isExtended = isExtended
852 }
730853}
731854
732855// MARK: - Email Security Models
@@ -857,9 +980,13 @@ struct HistoryEntry: Identifiable, Codable {
857980 var emailSecurity: EmailSecurityResult?
858981 var mtaSts: MTASTSResult?
859982 var ownership: DomainOwnership?
983 var ownershipHistory: [DomainOwnershipHistoryEvent]
860984 var ptrRecord: String?
861985 var redirectChain: [RedirectHop]
862986 var subdomains: [DiscoveredSubdomain]
987 var extendedSubdomains: [DiscoveredSubdomain]
988 var dnsHistory: [DNSHistoryEvent]
989 var domainPricing: DomainPricingInsight?
863990 var portScanResults: [PortScanResult]
864991 var hstsPreloaded: Bool?
865992 var availabilityResult: DomainAvailabilityResult?
@@ -891,16 +1018,23 @@ struct HistoryEntry: Identifiable, Codable {
8911018 var ipGeolocationError: String?
8921019 var emailSecurityError: String?
8931020 var ownershipError: String?
1021 var ownershipHistoryError: String?
8941022 var ptrError: String?
8951023 var redirectChainError: String?
8961024 var subdomainsError: String?
1025 var extendedSubdomainsError: String?
1026 var dnsHistoryError: String?
1027 var domainPricingError: String?
8971028 var portScanError: String?
8981029
8991030 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
9001031 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
9011032 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
9021033 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
1034 ownershipHistory: [DomainOwnershipHistoryEvent] = [],
9031035 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
1036 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
1037 domainPricing: DomainPricingInsight? = nil,
9041038 portScanResults: [PortScanResult] = [],
9051039 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
9061040 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
@@ -915,8 +1049,10 @@ struct HistoryEntry: Identifiable, Codable {
9151049 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
9161050 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
9171051 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
918 emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
919 redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
1052 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
1053 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
1054 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
1055 domainPricingError: String? = nil, portScanError: String? = nil) {
9201056 self.domain = domain
9211057 self.timestamp = timestamp
9221058 self.trackedDomainID = trackedDomainID
@@ -929,9 +1065,13 @@ struct HistoryEntry: Identifiable, Codable {
9291065 self.emailSecurity = emailSecurity
9301066 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
9311067 self.ownership = ownership
1068 self.ownershipHistory = ownershipHistory
9321069 self.ptrRecord = ptrRecord
9331070 self.redirectChain = redirectChain
9341071 self.subdomains = subdomains
1072 self.extendedSubdomains = extendedSubdomains
1073 self.dnsHistory = dnsHistory
1074 self.domainPricing = domainPricing
9351075 self.portScanResults = portScanResults
9361076 self.hstsPreloaded = hstsPreloaded
9371077 self.availabilityResult = availabilityResult
@@ -963,9 +1103,13 @@ struct HistoryEntry: Identifiable, Codable {
9631103 self.ipGeolocationError = ipGeolocationError
9641104 self.emailSecurityError = emailSecurityError
9651105 self.ownershipError = ownershipError
1106 self.ownershipHistoryError = ownershipHistoryError
9661107 self.ptrError = ptrError
9671108 self.redirectChainError = redirectChainError
9681109 self.subdomainsError = subdomainsError
1110 self.extendedSubdomainsError = extendedSubdomainsError
1111 self.dnsHistoryError = dnsHistoryError
1112 self.domainPricingError = domainPricingError
9691113 self.portScanError = portScanError
9701114 }
9711115
@@ -984,9 +1128,13 @@ struct HistoryEntry: Identifiable, Codable {
9841128 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
9851129 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
9861130 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
1131 ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
9871132 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
9881133 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
9891134 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
1135 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
1136 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
1137 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
9901138 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
9911139 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
9921140 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -1018,9 +1166,13 @@ struct HistoryEntry: Identifiable, Codable {
10181166 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
10191167 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
10201168 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
1169 ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
10211170 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
10221171 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
10231172 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
1173 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
1174 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
1175 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
10241176 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
10251177 }
10261178
DomainDig/PaywallView.swift +12 −1
@@ -10,7 +10,7 @@ struct PaywallView: View {
1010 NavigationStack {
1111 List {
1212 Section {
13 Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
13 Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.")
1414 .font(appDensity.font(.body, design: .default))
1515 .foregroundStyle(.secondary)
1616 .fixedSize(horizontal: false, vertical: true)
@@ -23,6 +23,13 @@ struct PaywallView: View {
2323 featureRow("Advanced exports")
2424 }
2525
26 Section("What Data+ Unlocks") {
27 featureRow("Ownership history")
28 featureRow("DNS history")
29 featureRow("Extended subdomains")
30 featureRow("External pricing signals")
31 }
32
2633 Section("Subscription") {
2734 if purchaseService.isLoadingProducts {
2835 ProgressView("Loading pricing…")
@@ -116,6 +123,10 @@ struct PaywallView: View {
116123 return "Pro Monthly"
117124 case PurchaseService.yearlyProductID:
118125 return "Pro Yearly"
126 case PurchaseService.dataPlusMonthlyProductID:
127 return "Data+ Monthly"
128 case PurchaseService.dataPlusYearlyProductID:
129 return "Data+ Yearly"
119130 default:
120131 return product.displayName
121132 }
DomainDig/PurchaseService.swift +31 −5
@@ -17,7 +17,14 @@ final class PurchaseService {
1717 static let shared = PurchaseService()
1818 static let monthlyProductID = "domaindig.pro.monthly"
1919 static let yearlyProductID = "domaindig.pro.yearly"
20 static let productIDs = [monthlyProductID, yearlyProductID]
20 static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly"
21 static let dataPlusYearlyProductID = "domaindig.dataplus.yearly"
22 static let productIDs = [
23 monthlyProductID,
24 yearlyProductID,
25 dataPlusMonthlyProductID,
26 dataPlusYearlyProductID
27 ]
2128
2229 private static let entitlementCacheKey = "purchase.cachedEntitlement"
2330
@@ -52,7 +59,11 @@ final class PurchaseService {
5259 }
5360
5461 var hasProAccess: Bool {
55 currentTier == .pro
62 currentTier != .free
63 }
64
65 var hasDataPlusAccess: Bool {
66 currentTier == .dataPlus
5667 }
5768
5869 func refreshProducts() async {
@@ -91,7 +102,7 @@ final class PurchaseService {
91102 .productID
92103
93104 self.activeProductID = activeProductID
94 currentTier = activeProductID == nil ? .free : .pro
105 currentTier = tier(for: activeProductID)
95106 persistCurrentEntitlement()
96107 }
97108
@@ -109,7 +120,7 @@ final class PurchaseService {
109120 apply(transaction: transaction)
110121 await transaction.finish()
111122 await refreshEntitlements()
112 statusMessage = "Pro is active."
123 statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active."
113124 case .userCancelled:
114125 break
115126 case .pending:
@@ -177,7 +188,7 @@ final class PurchaseService {
177188 }
178189
179190 activeProductID = transaction.productID
180 currentTier = .pro
191 currentTier = tier(for: transaction.productID)
181192 persistCurrentEntitlement()
182193 }
183194
@@ -224,11 +235,26 @@ final class PurchaseService {
224235 return 0
225236 case Self.yearlyProductID:
226237 return 1
238 case Self.dataPlusMonthlyProductID:
239 return 2
240 case Self.dataPlusYearlyProductID:
241 return 3
227242 default:
228243 return Int.max
229244 }
230245 }
231246
247 private func tier(for productID: String?) -> FeatureTier {
248 switch productID {
249 case Self.monthlyProductID, Self.yearlyProductID:
250 return .pro
251 case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID:
252 return .dataPlus
253 default:
254 return .free
255 }
256 }
257
232258 private func storeMessage(for error: Error, fallback: String) -> String {
233259 if let storeKitError = error as? StoreKitError {
234260 switch storeKitError {
DomainDig/RDAPService.swift +5
@@ -25,6 +25,7 @@ enum RDAPService {
2525 case let .success(response):
2626 let ownership = DomainOwnership(
2727 registrar: response.registrarName,
28 registrant: response.registrantName,
2829 createdDate: response.createdDate,
2930 expirationDate: response.expirationDate,
3031 status: response.status,
@@ -113,6 +114,10 @@ private struct RDAPDomainResponse: Decodable, Sendable {
113114 entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
114115 }
115116
117 var registrantName: String? {
118 entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName
119 }
120
116121 var createdDate: Date? {
117122 eventDate(for: ["registration", "registered"])
118123 }
DomainDig/SubdomainDiscoveryService.swift +1 −1
@@ -59,7 +59,7 @@ enum SubdomainDiscoveryService {
5959 guard seen.insert(sanitized).inserted else {
6060 continue
6161 }
62 results.append(DiscoveredSubdomain(hostname: sanitized))
62 results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh"))
6363 if results.count == limit {
6464 return results
6565 }
DomainDig/UsageCreditService.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2
3actor UsageCreditService {
4 static let shared = UsageCreditService()
5
6 private struct CreditLedger: Codable {
7 let appVersion: String
8 var remainingByFeature: [UsageCreditFeature: Int]
9 }
10
11 private let storageKey = "usageCredits.ledger"
12 private var ledger: CreditLedger
13
14 init(defaults: UserDefaults = .standard) {
15 if let data = defaults.data(forKey: storageKey),
16 let decoded = try? JSONDecoder().decode(CreditLedger.self, from: data),
17 decoded.appVersion == AppVersion.current {
18 ledger = decoded
19 } else {
20 ledger = Self.makeLedger()
21 if let data = try? JSONEncoder().encode(ledger) {
22 defaults.set(data, forKey: storageKey)
23 }
24 }
25 }
26
27 func status(for feature: UsageCreditFeature) -> UsageCreditStatus {
28 let total = feature.defaultAllowance
29 let remaining = ledger.remainingByFeature[feature] ?? total
30 return UsageCreditStatus(
31 feature: feature,
32 remaining: remaining,
33 total: total,
34 resetContext: "Resets with app version \(ledger.appVersion)"
35 )
36 }
37
38 func allStatuses() -> [UsageCreditStatus] {
39 UsageCreditFeature.allCases.map { status(for: $0) }
40 }
41
42 func canUse(_ feature: UsageCreditFeature) -> Bool {
43 status(for: feature).remaining > 0
44 }
45
46 @discardableResult
47 func consume(_ feature: UsageCreditFeature) -> UsageCreditStatus {
48 let current = ledger.remainingByFeature[feature] ?? feature.defaultAllowance
49 ledger.remainingByFeature[feature] = max(0, current - 1)
50 persist()
51 return status(for: feature)
52 }
53
54 func resetForCurrentVersion() {
55 ledger = Self.makeLedger()
56 persist()
57 }
58
59 private static func makeLedger() -> CreditLedger {
60 CreditLedger(
61 appVersion: AppVersion.current,
62 remainingByFeature: Dictionary(
63 uniqueKeysWithValues: UsageCreditFeature.allCases.map { ($0, $0.defaultAllowance) }
64 )
65 )
66 }
67
68 private func persist(defaults: UserDefaults = .standard) {
69 if let data = try? JSONEncoder().encode(ledger) {
70 defaults.set(data, forKey: storageKey)
71 }
72 }
73}
DomainDigCLI.swift +205 −3
@@ -6,12 +6,17 @@ struct DomainDigCLI {
66 let arguments = Array(CommandLine.arguments.dropFirst())
77
88 guard let command = CommandLine.arguments.first else {
9 fputs("usage: domaindig <domain> [--json]\n", stderr)
9 fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
1010 Foundation.exit(1)
1111 }
1212 _ = command
1313
1414 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
15 let wantsOwnershipHistory = arguments.contains("--ownership-history")
16 let wantsDNSHistory = arguments.contains("--dns-history")
17 let wantsExtendedSubdomains = arguments.contains("--extended-subdomains")
18 let wantsPricing = arguments.contains("--pricing")
19 let wantsUsage = arguments.contains("--show-usage")
1520 let domains = arguments.filter { !$0.hasPrefix("-") }
1621
1722 let requestedDomains = domains
@@ -19,18 +24,29 @@ struct DomainDigCLI {
1924 .filter { !$0.isEmpty }
2025
2126 guard !requestedDomains.isEmpty else {
22 fputs("usage: domaindig <domain> [--json]\n", stderr)
27 fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr)
2328 Foundation.exit(1)
2429 }
2530
2631 let inspectionService = DomainInspectionService()
32 let reportBuilder = DomainReportBuilder()
2733 var reports: [DomainReport] = []
2834 var seen = Set<String>()
35 var usageImpact: [String] = []
2936
3037 for domain in requestedDomains {
3138 let normalizedDomain = domain.lowercased()
3239 guard seen.insert(normalizedDomain).inserted else { continue }
33 reports.append(await inspectionService.inspect(domain: domain))
40 let snapshot = await inspectionService.inspectSnapshot(domain: domain)
41 let enrichedSnapshot = await enrichSnapshot(
42 snapshot,
43 wantsOwnershipHistory: wantsOwnershipHistory,
44 wantsDNSHistory: wantsDNSHistory,
45 wantsExtendedSubdomains: wantsExtendedSubdomains,
46 wantsPricing: wantsPricing,
47 usageImpact: &usageImpact
48 )
49 reports.append(reportBuilder.build(from: enrichedSnapshot))
3450 }
3551
3652 do {
@@ -47,6 +63,9 @@ struct DomainDigCLI {
4763 title: "DomainDig Batch Report"
4864 )
4965 }
66 if wantsUsage, !usageImpact.isEmpty {
67 FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8))
68 }
5069 FileHandle.standardOutput.write(data)
5170 if data.last != 0x0A {
5271 FileHandle.standardOutput.write(Data([0x0A]))
@@ -56,4 +75,187 @@ struct DomainDigCLI {
5675 Foundation.exit(1)
5776 }
5877 }
78
79 private static func enrichSnapshot(
80 _ snapshot: LookupSnapshot,
81 wantsOwnershipHistory: Bool,
82 wantsDNSHistory: Bool,
83 wantsExtendedSubdomains: Bool,
84 wantsPricing: Bool,
85 usageImpact: inout [String]
86 ) async -> LookupSnapshot {
87 guard FeatureAccessService.currentTier == .dataPlus else {
88 return snapshot
89 }
90
91 let historyEntries = loadHistoryEntries()
92 var ownershipHistory = snapshot.ownershipHistory
93 var ownershipHistoryError = snapshot.ownershipHistoryError
94 var dnsHistory = snapshot.dnsHistory
95 var dnsHistoryError = snapshot.dnsHistoryError
96 var extendedSubdomains = snapshot.extendedSubdomains
97 var extendedSubdomainsError = snapshot.extendedSubdomainsError
98 var domainPricing = snapshot.domainPricing
99 var domainPricingError = snapshot.domainPricingError
100
101 if wantsOwnershipHistory,
102 await UsageCreditService.shared.canUse(.ownershipHistory) {
103 let outcome = await ExternalDataService.shared.ownershipHistory(
104 domain: snapshot.domain,
105 currentOwnership: snapshot.ownership,
106 historyEntries: historyEntries
107 )
108 switch outcome.value {
109 case let .success(events):
110 ownershipHistory = events
111 ownershipHistoryError = nil
112 if outcome.source != .cached {
113 _ = await UsageCreditService.shared.consume(.ownershipHistory)
114 usageImpact.append("ownership history -1")
115 }
116 case let .empty(message):
117 ownershipHistoryError = message
118 if outcome.source != .cached {
119 _ = await UsageCreditService.shared.consume(.ownershipHistory)
120 usageImpact.append("ownership history -1")
121 }
122 case let .error(message):
123 ownershipHistoryError = message
124 }
125 }
126
127 if wantsDNSHistory,
128 await UsageCreditService.shared.canUse(.dnsHistory) {
129 let outcome = await ExternalDataService.shared.dnsHistory(
130 domain: snapshot.domain,
131 dnsSections: snapshot.dnsSections,
132 historyEntries: historyEntries
133 )
134 switch outcome.value {
135 case let .success(events):
136 dnsHistory = events
137 dnsHistoryError = nil
138 if outcome.source != .cached {
139 _ = await UsageCreditService.shared.consume(.dnsHistory)
140 usageImpact.append("dns history -1")
141 }
142 case let .empty(message):
143 dnsHistoryError = message
144 if outcome.source != .cached {
145 _ = await UsageCreditService.shared.consume(.dnsHistory)
146 usageImpact.append("dns history -1")
147 }
148 case let .error(message):
149 dnsHistoryError = message
150 }
151 }
152
153 if wantsExtendedSubdomains,
154 await UsageCreditService.shared.canUse(.extendedSubdomains) {
155 let outcome = await ExternalDataService.shared.extendedSubdomains(
156 domain: snapshot.domain,
157 existing: snapshot.subdomains
158 )
159 switch outcome.value {
160 case let .success(results):
161 extendedSubdomains = results
162 extendedSubdomainsError = nil
163 if outcome.source != .cached {
164 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
165 usageImpact.append("extended subdomains -1")
166 }
167 case let .empty(message):
168 extendedSubdomainsError = message
169 if outcome.source != .cached {
170 _ = await UsageCreditService.shared.consume(.extendedSubdomains)
171 usageImpact.append("extended subdomains -1")
172 }
173 case let .error(message):
174 extendedSubdomainsError = message
175 }
176 }
177
178 if wantsPricing {
179 let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain)
180 switch outcome.value {
181 case let .success(pricing):
182 domainPricing = pricing
183 domainPricingError = nil
184 case let .empty(message), let .error(message):
185 domainPricingError = message
186 }
187 }
188
189 return LookupSnapshot(
190 historyEntryID: snapshot.historyEntryID,
191 domain: snapshot.domain,
192 timestamp: snapshot.timestamp,
193 trackedDomainID: snapshot.trackedDomainID,
194 note: snapshot.note,
195 appVersion: snapshot.appVersion,
196 resolverDisplayName: snapshot.resolverDisplayName,
197 resolverURLString: snapshot.resolverURLString,
198 dataSources: snapshot.dataSources,
199 provenanceBySection: snapshot.provenanceBySection,
200 availabilityConfidence: snapshot.availabilityConfidence,
201 ownershipConfidence: snapshot.ownershipConfidence,
202 subdomainConfidence: snapshot.subdomainConfidence,
203 emailSecurityConfidence: snapshot.emailSecurityConfidence,
204 geolocationConfidence: snapshot.geolocationConfidence,
205 errorDetails: snapshot.errorDetails,
206 isPartialSnapshot: snapshot.isPartialSnapshot,
207 validationIssues: snapshot.validationIssues,
208 totalLookupDurationMs: snapshot.totalLookupDurationMs,
209 dnsSections: snapshot.dnsSections,
210 dnsError: snapshot.dnsError,
211 availabilityResult: snapshot.availabilityResult,
212 suggestions: snapshot.suggestions,
213 sslInfo: snapshot.sslInfo,
214 sslError: snapshot.sslError,
215 hstsPreloaded: snapshot.hstsPreloaded,
216 httpHeaders: snapshot.httpHeaders,
217 httpSecurityGrade: snapshot.httpSecurityGrade,
218 httpStatusCode: snapshot.httpStatusCode,
219 httpResponseTimeMs: snapshot.httpResponseTimeMs,
220 httpProtocol: snapshot.httpProtocol,
221 http3Advertised: snapshot.http3Advertised,
222 httpHeadersError: snapshot.httpHeadersError,
223 reachabilityResults: snapshot.reachabilityResults,
224 reachabilityError: snapshot.reachabilityError,
225 ipGeolocation: snapshot.ipGeolocation,
226 ipGeolocationError: snapshot.ipGeolocationError,
227 emailSecurity: snapshot.emailSecurity,
228 emailSecurityError: snapshot.emailSecurityError,
229 ownership: snapshot.ownership,
230 ownershipError: snapshot.ownershipError,
231 ownershipHistory: ownershipHistory,
232 ownershipHistoryError: ownershipHistoryError,
233 ptrRecord: snapshot.ptrRecord,
234 ptrError: snapshot.ptrError,
235 redirectChain: snapshot.redirectChain,
236 redirectChainError: snapshot.redirectChainError,
237 subdomains: snapshot.subdomains,
238 subdomainsError: snapshot.subdomainsError,
239 extendedSubdomains: extendedSubdomains,
240 extendedSubdomainsError: extendedSubdomainsError,
241 dnsHistory: dnsHistory,
242 dnsHistoryError: dnsHistoryError,
243 domainPricing: domainPricing,
244 domainPricingError: domainPricingError,
245 portScanResults: snapshot.portScanResults,
246 portScanError: snapshot.portScanError,
247 changeSummary: snapshot.changeSummary,
248 resultSource: snapshot.resultSource,
249 cachedSections: snapshot.cachedSections,
250 statusMessage: snapshot.statusMessage
251 )
252 }
253
254 private static func loadHistoryEntries() -> [HistoryEntry] {
255 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
256 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
257 return []
258 }
259 return entries
260 }
59261}
DomainInspectionService.swift +8
@@ -313,12 +313,20 @@ struct DomainInspectionService {
313313 emailSecurityError: emailSecurity.message,
314314 ownership: ownership.value,
315315 ownershipError: ownership.message,
316 ownershipHistory: [],
317 ownershipHistoryError: nil,
316318 ptrRecord: ptrRecord.value,
317319 ptrError: ptrRecord.message,
318320 redirectChain: redirectChain.value,
319321 redirectChainError: redirectChain.message,
320322 subdomains: subdomains.value,
321323 subdomainsError: subdomains.message,
324 extendedSubdomains: [],
325 extendedSubdomainsError: nil,
326 dnsHistory: [],
327 dnsHistoryError: nil,
328 domainPricing: nil,
329 domainPricingError: nil,
322330 portScanResults: portScanResults.value,
323331 portScanError: portScanResults.message,
324332 changeSummary: nil,
DomainReportBuilder.swift +9 −1
@@ -21,11 +21,15 @@ struct DomainReport: Codable {
2121 let emailConfidence: ConfidenceLevel?
2222 let geolocationConfidence: ConfidenceLevel?
2323 let ownership: DomainOwnership?
24 let ownershipHistory: [DomainOwnershipHistoryEvent]
2425 let dns: DNSResultSummary
2526 let web: WebResultSummary
2627 let email: EmailSecuritySummary
2728 let network: NetworkSummary
2829 let subdomains: [String]
30 let extendedSubdomains: [String]
31 let dnsHistory: [DNSHistoryEvent]
32 let domainPricing: DomainPricingInsight?
2933 let subdomainGroups: [SubdomainGroup]
3034 let riskAssessment: DomainRiskAssessment
3135 let insights: [String]
@@ -164,6 +168,7 @@ struct DomainReportBuilder {
164168 emailConfidence: snapshot.emailSecurityConfidence,
165169 geolocationConfidence: snapshot.geolocationConfidence,
166170 ownership: snapshot.ownership,
171 ownershipHistory: snapshot.ownershipHistory,
167172 dns: DNSResultSummary(
168173 resolverDisplayName: snapshot.resolverDisplayName,
169174 resolverURLString: snapshot.resolverURLString,
@@ -216,13 +221,16 @@ struct DomainReportBuilder {
216221 portScanError: snapshot.portScanError
217222 ),
218223 subdomains: snapshot.subdomains.map(\.hostname),
224 extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
225 dnsHistory: snapshot.dnsHistory,
226 domainPricing: snapshot.domainPricing,
219227 subdomainGroups: analysis.subdomainGroups,
220228 riskAssessment: analysis.riskAssessment,
221229 insights: analysis.insights,
222230 changeSummary: changeSummary,
223231 workflowContext: workflowContext,
224232 metadata: DomainReportMetadata(
225 schemaVersion: "3.0.0",
233 schemaVersion: "3.2.0",
226234 resolverDisplayName: snapshot.resolverDisplayName,
227235 resolverURLString: snapshot.resolverURLString,
228236 appVersion: snapshot.appVersion,
DomainReportExporter.swift +74 −1
@@ -67,7 +67,9 @@ enum DomainReportExporter {
6767 "TLS Status: \(report.web.tlsStatus)",
6868 "HTTP: \(httpSummary(for: report))",
6969 "Email: \(report.email.summary)",
70 "Subdomains: \(report.subdomains.count)"
70 "Subdomains: \(report.subdomains.count)",
71 "Extended Subdomains: \(report.extendedSubdomains.count)",
72 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")"
7173 ]
7274 }
7375
@@ -97,6 +99,7 @@ enum DomainReportExporter {
9799 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
98100 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
99101 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
102 "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
100103 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
101104 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
102105 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
@@ -112,6 +115,20 @@ enum DomainReportExporter {
112115 return ownershipLines
113116 }
114117
118 appendSection("Ownership History", to: &lines) {
119 guard !report.ownershipHistory.isEmpty else {
120 return ["No ownership history available"]
121 }
122
123 return report.ownershipHistory.map { event in
124 [
125 textDateFormatter.string(from: event.date),
126 event.summary,
127 "source=\(event.source)"
128 ].joined(separator: " | ")
129 }
130 }
131
115132 appendSection("DNS", to: &lines) {
116133 var dnsLines = [
117134 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
@@ -139,6 +156,22 @@ enum DomainReportExporter {
139156 return dnsLines
140157 }
141158
159 appendSection("DNS History", to: &lines) {
160 guard !report.dnsHistory.isEmpty else {
161 return ["No DNS history available"]
162 }
163
164 return report.dnsHistory.map { event in
165 [
166 textDateFormatter.string(from: event.date),
167 event.summary,
168 "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
169 "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
170 "source=\(event.source)"
171 ].joined(separator: " | ")
172 }
173 }
174
142175 appendSection("Web", to: &lines) {
143176 var webLines = [
144177 "TLS Status: \(report.web.tlsStatus)",
@@ -254,9 +287,28 @@ enum DomainReportExporter {
254287 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
255288 }
256289 values.append(contentsOf: report.subdomains.map { "- \($0)" })
290 if !report.extendedSubdomains.isEmpty {
291 values.append("Extended:")
292 values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
293 }
257294 return values
258295 }
259296
297 appendSection("Pricing", to: &lines) {
298 guard let pricing = report.domainPricing else {
299 return ["External pricing unavailable"]
300 }
301
302 return [
303 "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
304 "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
305 "Resale: \(pricing.resaleSignal ?? "Unavailable")",
306 "Auction: \(pricing.auctionSignal ?? "Unavailable")",
307 "Source: \(pricing.source)",
308 "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
309 ]
310 }
311
260312 appendSection("Changes", to: &lines) {
261313 guard let changeSummary = report.changeSummary else {
262314 return ["No comparison available"]
@@ -342,9 +394,18 @@ enum DomainReportExporter {
342394 "email_grade",
343395 "email_confidence",
344396 "subdomain_count",
397 "extended_subdomain_count",
345398 "subdomain_groups",
346399 "subdomain_confidence",
347400 "subdomains",
401 "extended_subdomains",
402 "ownership_history",
403 "dns_history",
404 "pricing_estimated",
405 "pricing_premium",
406 "pricing_resale_signal",
407 "pricing_auction_signal",
408 "pricing_source",
348409 "open_ports",
349410 "reachability_summary",
350411 "geolocation_summary",
@@ -368,12 +429,15 @@ enum DomainReportExporter {
368429 let httpStatus = report.web.statusCode.map(String.init) ?? ""
369430 let subdomainCount = String(report.subdomains.count)
370431 let subdomains = report.subdomains.joined(separator: " | ")
432 let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
371433 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
372434 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
373435 let insights = report.insights.joined(separator: " | ")
374436 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
375437 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
376438 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
439 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
440 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
377441
378442 return [
379443 report.domain,
@@ -407,9 +471,18 @@ enum DomainReportExporter {
407471 report.email.grade?.rawValue ?? "",
408472 report.emailConfidence?.rawValue ?? "",
409473 subdomainCount,
474 String(report.extendedSubdomains.count),
410475 subdomainGroups,
411476 report.subdomainConfidence?.rawValue ?? "",
412477 subdomains,
478 extendedSubdomains,
479 ownershipHistory,
480 dnsHistory,
481 report.domainPricing?.estimatedPrice ?? "",
482 report.domainPricing?.premiumIndicator == true ? "true" : "false",
483 report.domainPricing?.resaleSignal ?? "",
484 report.domainPricing?.auctionSignal ?? "",
485 report.domainPricing?.source ?? "",
413486 openPorts,
414487 report.network.reachabilitySummary,
415488 report.network.geolocationSummary,
LookupSnapshot.swift +16
@@ -42,12 +42,20 @@ struct LookupSnapshot {
4242 let emailSecurityError: String?
4343 let ownership: DomainOwnership?
4444 let ownershipError: String?
45 let ownershipHistory: [DomainOwnershipHistoryEvent]
46 let ownershipHistoryError: String?
4547 let ptrRecord: String?
4648 let ptrError: String?
4749 let redirectChain: [RedirectHop]
4850 let redirectChainError: String?
4951 let subdomains: [DiscoveredSubdomain]
5052 let subdomainsError: String?
53 let extendedSubdomains: [DiscoveredSubdomain]
54 let extendedSubdomainsError: String?
55 let dnsHistory: [DNSHistoryEvent]
56 let dnsHistoryError: String?
57 let domainPricing: DomainPricingInsight?
58 let domainPricingError: String?
5159 let portScanResults: [PortScanResult]
5260 let portScanError: String?
5361 let changeSummary: DomainChangeSummary?
@@ -104,12 +112,20 @@ extension HistoryEntry {
104112 emailSecurityError: emailSecurityError,
105113 ownership: ownership,
106114 ownershipError: ownershipError,
115 ownershipHistory: ownershipHistory,
116 ownershipHistoryError: ownershipHistoryError,
107117 ptrRecord: ptrRecord,
108118 ptrError: ptrError,
109119 redirectChain: redirectChain,
110120 redirectChainError: redirectChainError,
111121 subdomains: subdomains,
112122 subdomainsError: subdomainsError,
123 extendedSubdomains: extendedSubdomains,
124 extendedSubdomainsError: extendedSubdomainsError,
125 dnsHistory: dnsHistory,
126 dnsHistoryError: dnsHistoryError,
127 domainPricing: domainPricing,
128 domainPricingError: domainPricingError,
113129 portScanResults: portScanResults,
114130 portScanError: portScanError,
115131 changeSummary: changeSummary,