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 @@
1import Foundation 1import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 static var current: String { 4 nonisolated static var current: String {
5 "3.1.0" 5 "3.2.0"
6 } 6 }
7} 7}
DomainDig/ContentView.swift +364 −74
@@ -71,62 +71,11 @@ struct ContentView: View {
71 .padding(.top, appDensity.metrics.cardSpacing) 71 .padding(.top, appDensity.metrics.cardSpacing)
72 } 72 }
73 } 73 }
74 DomainSectionView( 74 domainOverviewSection
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 )
109 .padding(.top, appDensity.metrics.sectionSpacing) 75 .padding(.top, appDensity.metrics.sectionSpacing)
110 OwnershipSectionView( 76 ownershipSection
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 )
119 .padding(.top, appDensity.metrics.sectionSpacing) 77 .padding(.top, appDensity.metrics.sectionSpacing)
120 SubdomainsSectionView( 78 subdomainsSection
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 )
130 .padding(.top, appDensity.metrics.sectionSpacing) 79 .padding(.top, appDensity.metrics.sectionSpacing)
131 if !viewModel.currentDiffSections.isEmpty { 80 if !viewModel.currentDiffSections.isEmpty {
132 DomainDiffView( 81 DomainDiffView(
@@ -137,17 +86,7 @@ struct ContentView: View {
137 ) 86 )
138 .padding(.top, appDensity.metrics.sectionSpacing) 87 .padding(.top, appDensity.metrics.sectionSpacing)
139 } 88 }
140 DNSSectionView( 89 dnsSection
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 )
151 .padding(.top, appDensity.metrics.sectionSpacing) 90 .padding(.top, appDensity.metrics.sectionSpacing)
152 WebSectionView( 91 WebSectionView(
153 isCollapsed: sectionCollapsedBinding(.web), 92 isCollapsed: sectionCollapsedBinding(.web),
@@ -240,6 +179,9 @@ struct ContentView: View {
240 .onAppear { 179 .onAppear {
241 domainFieldFocused = true 180 domainFieldFocused = true
242 } 181 }
182 .task {
183 await viewModel.refreshUsageCredits()
184 }
243 .onChange(of: viewModel.searchedDomain) { _, _ in 185 .onChange(of: viewModel.searchedDomain) { _, _ in
244 collapsedSections = defaultCollapsedSections 186 collapsedSections = defaultCollapsedSections
245 } 187 }
@@ -372,6 +314,117 @@ struct ContentView: View {
372 .padding(.vertical, appDensity.metrics.sectionSpacing) 314 .padding(.vertical, appDensity.metrics.sectionSpacing)
373 } 315 }
374 316
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
375 private var actionButtons: some View { 428 private var actionButtons: some View {
376 HStack { 429 HStack {
377 Spacer() 430 Spacer()
@@ -1192,6 +1245,9 @@ struct DomainSectionView: View {
1192 let trackedDomain: TrackedDomain? 1245 let trackedDomain: TrackedDomain?
1193 let workflows: [DomainWorkflow] 1246 let workflows: [DomainWorkflow]
1194 let trackingLimitMessage: String? 1247 let trackingLimitMessage: String?
1248 let pricingLoading: Bool
1249 let pricingError: String?
1250 let showsPricingPlaceholder: Bool
1195 let onTrack: () -> Void 1251 let onTrack: () -> Void
1196 let onTogglePinned: () -> Void 1252 let onTogglePinned: () -> Void
1197 let onEditNote: (() -> Void)? 1253 let onEditNote: (() -> Void)?
@@ -1313,12 +1369,24 @@ struct DomainSectionView: View {
1313 } 1369 }
1314 } 1370 }
1315 } 1371 }
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 }
1316 } 1383 }
1317 } 1384 }
1318 } 1385 }
1319} 1386}
1320 1387
1321struct OwnershipSectionView: View { 1388struct OwnershipSectionView: View {
1389 @Environment(\.appDensity) private var appDensity
1322 @Binding var isCollapsed: Bool 1390 @Binding var isCollapsed: Bool
1323 let rows: [InfoRowViewData] 1391 let rows: [InfoRowViewData]
1324 let loading: Bool 1392 let loading: Bool
@@ -1326,6 +1394,39 @@ struct OwnershipSectionView: View {
1326 let provenance: SectionProvenance? 1394 let provenance: SectionProvenance?
1327 let confidence: ConfidenceLevel? 1395 let confidence: ConfidenceLevel?
1328 let showsHistoryPlaceholder: Bool 1396 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 }
1329 1430
1330 var body: some View { 1431 var body: some View {
1331 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { 1432 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
@@ -1342,9 +1443,41 @@ struct OwnershipSectionView: View {
1342 MessageRowView(text: error, isError: error != "Unavailable") 1443 MessageRowView(text: error, isError: error != "Unavailable")
1343 .padding(.top, 4) 1444 .padding(.top, 4)
1344 } 1445 }
1345 if showsHistoryPlaceholder { 1446 VStack(alignment: .leading, spacing: 8) {
1346 MessageRowView(text: "Ownership history (coming soon)", isError: false) 1447 HStack {
1347 .padding(.top, 4) 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 }
1348 } 1481 }
1349 } 1482 }
1350 } 1483 }
@@ -1362,6 +1495,41 @@ struct SubdomainsSectionView: View {
1362 let provenance: SectionProvenance? 1495 let provenance: SectionProvenance?
1363 let confidence: ConfidenceLevel? 1496 let confidence: ConfidenceLevel?
1364 let showsExtendedPlaceholder: Bool 1497 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 }
1365 1533
1366 var body: some View { 1534 var body: some View {
1367 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { 1535 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
@@ -1373,10 +1541,17 @@ struct SubdomainsSectionView: View {
1373 } else if rows.isEmpty { 1541 } else if rows.isEmpty {
1374 MessageRowView(text: error ?? "No passive subdomains found", isError: false) 1542 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1375 if showsExtendedPlaceholder { 1543 if showsExtendedPlaceholder {
1376 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) 1544 MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false)
1377 .padding(.top, 4) 1545 .padding(.top, 4)
1378 } 1546 }
1379 } else { 1547 } 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 }
1380 if !groups.isEmpty { 1555 if !groups.isEmpty {
1381 Text("Groups") 1556 Text("Groups")
1382 .font(appDensity.font(.caption2)) 1557 .font(appDensity.font(.caption2))
@@ -1411,8 +1586,18 @@ struct SubdomainsSectionView: View {
1411 } 1586 }
1412 } 1587 }
1413 } 1588 }
1414 if showsExtendedPlaceholder { 1589 if extendedLoading {
1415 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) 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)
1416 .padding(.top, 4) 1601 .padding(.top, 4)
1417 } 1602 }
1418 } 1603 }
@@ -1422,6 +1607,7 @@ struct SubdomainsSectionView: View {
1422} 1607}
1423 1608
1424struct DNSSectionView: View { 1609struct DNSSectionView: View {
1610 @Environment(\.appDensity) private var appDensity
1425 @Binding var isCollapsed: Bool 1611 @Binding var isCollapsed: Bool
1426 let dnssecLabel: String? 1612 let dnssecLabel: String?
1427 let patternSummary: DNSPatternSummary? 1613 let patternSummary: DNSPatternSummary?
@@ -1431,6 +1617,46 @@ struct DNSSectionView: View {
1431 let dnsProvenance: SectionProvenance? 1617 let dnsProvenance: SectionProvenance?
1432 let ptrProvenance: SectionProvenance? 1618 let ptrProvenance: SectionProvenance?
1433 let sectionError: String? 1619 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 }
1434 1660
1435 var body: some View { 1661 var body: some View {
1436 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { 1662 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
@@ -1491,6 +1717,50 @@ struct DNSSectionView: View {
1491 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) 1717 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
1492 } 1718 }
1493 } 1719 }
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 }
1494 } 1764 }
1495 } 1765 }
1496 } 1766 }
@@ -2220,11 +2490,11 @@ struct SettingsView: View {
2220 } 2490 }
2221 } 2491 }
2222 2492
2223 Section("Pro") { 2493 Section("Tier") {
2224 LabeledContent("Status", value: purchaseService.currentTier.title) 2494 LabeledContent("Status", value: purchaseService.currentTier.title)
2225 2495
2226 if purchaseService.currentTier == .free { 2496 if purchaseService.currentTier == .free {
2227 Button("Upgrade to Pro") { 2497 Button("Upgrade") {
2228 viewModel.isPaywallPresented = true 2498 viewModel.isPaywallPresented = true
2229 } 2499 }
2230 } else { 2500 } else {
@@ -2255,6 +2525,23 @@ struct SettingsView: View {
2255 } 2525 }
2256 } 2526 }
2257 2527
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
2258 Section("Features") { 2545 Section("Features") {
2259 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in 2546 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
2260 Text(label) 2547 Text(label)
@@ -2264,7 +2551,7 @@ struct SettingsView: View {
2264 Section("About") { 2551 Section("About") {
2265 LabeledContent("Version", value: appVersion) 2552 LabeledContent("Version", value: appVersion)
2266 LabeledContent("Storage", value: "Local-only") 2553 LabeledContent("Storage", value: "Local-only")
2267 LabeledContent("Report Schema", value: "3.1.0") 2554 LabeledContent("Report Schema", value: "3.2.0")
2268 } 2555 }
2269 } 2556 }
2270 .navigationTitle("Settings") 2557 .navigationTitle("Settings")
@@ -2304,6 +2591,9 @@ struct SettingsView: View {
2304 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 2591 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2305 resolverOption = DNSResolverOption.option(for: currentResolverURL) 2592 resolverOption = DNSResolverOption.option(for: currentResolverURL)
2306 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString 2593 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2594 Task {
2595 await viewModel.refreshUsageCredits()
2596 }
2307 } 2597 }
2308 .onChange(of: resolverOption) { _, newValue in 2598 .onChange(of: resolverOption) { _, newValue in
2309 guard let presetURL = newValue.urlString else { 2599 guard let presetURL = newValue.urlString else {
DomainDig/DataAccessService.swift +1 −1
@@ -10,7 +10,7 @@ enum DataAccessService {
10 case .extendedSubdomains: 10 case .extendedSubdomains:
11 return FeatureAccessService.hasAccess(to: .extendedSubdomains) 11 return FeatureAccessService.hasAccess(to: .extendedSubdomains)
12 case .domainPricing: 12 case .domainPricing:
13 return false 13 return FeatureAccessService.hasAccess(to: .domainPricing)
14 } 14 }
15 } 15 }
16} 16}
DomainDig/DomainViewModel.swift +336 −2
@@ -147,6 +147,9 @@ final class DomainViewModel {
147 var ownershipResult: DomainOwnership? 147 var ownershipResult: DomainOwnership?
148 var ownershipLoading = false 148 var ownershipLoading = false
149 var ownershipError: String? 149 var ownershipError: String?
150 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
151 var ownershipHistoryLoading = false
152 var ownershipHistoryError: String?
150 153
151 var ptrRecord: String? 154 var ptrRecord: String?
152 var ptrLoading = false 155 var ptrLoading = false
@@ -159,6 +162,16 @@ final class DomainViewModel {
159 var subdomains: [DiscoveredSubdomain] = [] 162 var subdomains: [DiscoveredSubdomain] = []
160 var subdomainsLoading = false 163 var subdomainsLoading = false
161 var subdomainsError: String? 164 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()
162 175
163 var portScanResults: [PortScanResult] = [] 176 var portScanResults: [PortScanResult] = []
164 var portScanLoading = false 177 var portScanLoading = false
@@ -263,8 +276,12 @@ final class DomainViewModel {
263 if sslLoading || hstsLoading { labels.append("TLS") } 276 if sslLoading || hstsLoading { labels.append("TLS") }
264 if httpHeadersLoading { labels.append("HTTP") } 277 if httpHeadersLoading { labels.append("HTTP") }
265 if ownershipLoading { labels.append("Ownership") } 278 if ownershipLoading { labels.append("Ownership") }
279 if ownershipHistoryLoading { labels.append("Ownership History") }
266 if emailSecurityLoading { labels.append("Email") } 280 if emailSecurityLoading { labels.append("Email") }
267 if subdomainsLoading { labels.append("Subdomains") } 281 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") }
268 if redirectChainLoading { labels.append("Redirects") } 285 if redirectChainLoading { labels.append("Redirects") }
269 if reachabilityLoading { labels.append("Reachability") } 286 if reachabilityLoading { labels.append("Reachability") }
270 if ipGeolocationLoading { labels.append("Geolocation") } 287 if ipGeolocationLoading { labels.append("Geolocation") }
@@ -438,12 +455,20 @@ final class DomainViewModel {
438 emailSecurityError: emailSecurityError, 455 emailSecurityError: emailSecurityError,
439 ownership: ownershipResult, 456 ownership: ownershipResult,
440 ownershipError: ownershipError, 457 ownershipError: ownershipError,
458 ownershipHistory: ownershipHistory,
459 ownershipHistoryError: ownershipHistoryError,
441 ptrRecord: ptrRecord, 460 ptrRecord: ptrRecord,
442 ptrError: ptrError, 461 ptrError: ptrError,
443 redirectChain: redirectChain, 462 redirectChain: redirectChain,
444 redirectChainError: redirectChainError, 463 redirectChainError: redirectChainError,
445 subdomains: subdomains, 464 subdomains: subdomains,
446 subdomainsError: subdomainsError, 465 subdomainsError: subdomainsError,
466 extendedSubdomains: extendedSubdomains,
467 extendedSubdomainsError: extendedSubdomainsError,
468 dnsHistory: dnsHistory,
469 dnsHistoryError: dnsHistoryError,
470 domainPricing: domainPricing,
471 domainPricingError: domainPricingError,
447 portScanResults: allPortScanResults, 472 portScanResults: allPortScanResults,
448 portScanError: combinedPortScanError, 473 portScanError: combinedPortScanError,
449 changeSummary: currentChangeSummary, 474 changeSummary: currentChangeSummary,
@@ -482,6 +507,23 @@ final class DomainViewModel {
482 currentReport?.web 507 currentReport?.web
483 } 508 }
484 509
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
485 var summaryFields: [SummaryFieldViewData] { 527 var summaryFields: [SummaryFieldViewData] {
486 Self.summaryFields(from: currentSnapshot) 528 Self.summaryFields(from: currentSnapshot)
487 } 529 }
@@ -527,7 +569,7 @@ final class DomainViewModel {
527 } 569 }
528 570
529 var subdomainRows: [SubdomainRowViewData] { 571 var subdomainRows: [SubdomainRowViewData] {
530 Self.subdomainRows(from: currentSnapshot) 572 Self.subdomainRows(from: combinedSubdomains)
531 } 573 }
532 574
533 var reachabilityRows: [ReachabilityRowViewData] { 575 var reachabilityRows: [ReachabilityRowViewData] {
@@ -934,6 +976,151 @@ final class DomainViewModel {
934 return String(data: data, encoding: .utf8) 976 return String(data: data, encoding: .utf8)
935 } 977 }
936 978
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
937 func exportBatchText() -> String { 1124 func exportBatchText() -> String {
938 DomainReportExporter.batchText( 1125 DomainReportExporter.batchText(
939 for: currentBatchReports(), 1126 for: currentBatchReports(),
@@ -1019,11 +1206,18 @@ final class DomainViewModel {
1019 lastLookupDurationMs = snapshot.totalLookupDurationMs 1206 lastLookupDurationMs = snapshot.totalLookupDurationMs
1020 refreshingTrackedDomainID = nil 1207 refreshingTrackedDomainID = nil
1021 1208
1209 if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
1210 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
1211 }
1212
1022 guard snapshot.statusMessage == nil else { 1213 guard snapshot.statusMessage == nil else {
1214 await refreshUsageCredits()
1023 return history.first(where: { $0.id == snapshot.historyEntryID }) 1215 return history.first(where: { $0.id == snapshot.historyEntryID })
1024 } 1216 }
1025 1217
1026 return saveHistoryEntry(replaceLatest: false) 1218 let entry = saveHistoryEntry(replaceLatest: false)
1219 await refreshUsageCredits()
1220 return entry
1027 } 1221 }
1028 1222
1029 private func applySnapshot(_ snapshot: LookupSnapshot) { 1223 private func applySnapshot(_ snapshot: LookupSnapshot) {
@@ -1066,12 +1260,20 @@ final class DomainViewModel {
1066 emailSecurityError = snapshot.emailSecurityError 1260 emailSecurityError = snapshot.emailSecurityError
1067 ownershipResult = snapshot.ownership 1261 ownershipResult = snapshot.ownership
1068 ownershipError = snapshot.ownershipError 1262 ownershipError = snapshot.ownershipError
1263 ownershipHistory = snapshot.ownershipHistory
1264 ownershipHistoryError = snapshot.ownershipHistoryError
1069 ptrRecord = snapshot.ptrRecord 1265 ptrRecord = snapshot.ptrRecord
1070 ptrError = snapshot.ptrError 1266 ptrError = snapshot.ptrError
1071 redirectChain = snapshot.redirectChain 1267 redirectChain = snapshot.redirectChain
1072 redirectChainError = snapshot.redirectChainError 1268 redirectChainError = snapshot.redirectChainError
1073 subdomains = snapshot.subdomains 1269 subdomains = snapshot.subdomains
1074 subdomainsError = snapshot.subdomainsError 1270 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
1075 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } 1277 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
1076 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } 1278 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
1077 portScanError = snapshot.portScanError 1279 portScanError = snapshot.portScanError
@@ -1087,9 +1289,13 @@ final class DomainViewModel {
1087 ipGeolocationLoading = false 1289 ipGeolocationLoading = false
1088 emailSecurityLoading = false 1290 emailSecurityLoading = false
1089 ownershipLoading = false 1291 ownershipLoading = false
1292 ownershipHistoryLoading = false
1090 ptrLoading = false 1293 ptrLoading = false
1091 redirectChainLoading = false 1294 redirectChainLoading = false
1092 subdomainsLoading = false 1295 subdomainsLoading = false
1296 extendedSubdomainsLoading = false
1297 dnsHistoryLoading = false
1298 domainPricingLoading = false
1093 portScanLoading = false 1299 portScanLoading = false
1094 customPortScanLoading = false 1300 customPortScanLoading = false
1095 } 1301 }
@@ -1144,12 +1350,20 @@ final class DomainViewModel {
1144 emailSecurityError: previousSnapshot.emailSecurityError, 1350 emailSecurityError: previousSnapshot.emailSecurityError,
1145 ownership: previousSnapshot.ownership, 1351 ownership: previousSnapshot.ownership,
1146 ownershipError: previousSnapshot.ownershipError, 1352 ownershipError: previousSnapshot.ownershipError,
1353 ownershipHistory: previousSnapshot.ownershipHistory,
1354 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
1147 ptrRecord: previousSnapshot.ptrRecord, 1355 ptrRecord: previousSnapshot.ptrRecord,
1148 ptrError: previousSnapshot.ptrError, 1356 ptrError: previousSnapshot.ptrError,
1149 redirectChain: previousSnapshot.redirectChain, 1357 redirectChain: previousSnapshot.redirectChain,
1150 redirectChainError: previousSnapshot.redirectChainError, 1358 redirectChainError: previousSnapshot.redirectChainError,
1151 subdomains: previousSnapshot.subdomains, 1359 subdomains: previousSnapshot.subdomains,
1152 subdomainsError: previousSnapshot.subdomainsError, 1360 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,
1153 portScanResults: previousSnapshot.portScanResults, 1367 portScanResults: previousSnapshot.portScanResults,
1154 portScanError: previousSnapshot.portScanError, 1368 portScanError: previousSnapshot.portScanError,
1155 changeSummary: previousSnapshot.changeSummary, 1369 changeSummary: previousSnapshot.changeSummary,
@@ -1515,9 +1729,13 @@ final class DomainViewModel {
1515 emailSecurity: snapshot.emailSecurity, 1729 emailSecurity: snapshot.emailSecurity,
1516 mtaSts: snapshot.emailSecurity?.mtaSts, 1730 mtaSts: snapshot.emailSecurity?.mtaSts,
1517 ownership: snapshot.ownership, 1731 ownership: snapshot.ownership,
1732 ownershipHistory: snapshot.ownershipHistory,
1518 ptrRecord: snapshot.ptrRecord, 1733 ptrRecord: snapshot.ptrRecord,
1519 redirectChain: snapshot.redirectChain, 1734 redirectChain: snapshot.redirectChain,
1520 subdomains: snapshot.subdomains, 1735 subdomains: snapshot.subdomains,
1736 extendedSubdomains: snapshot.extendedSubdomains,
1737 dnsHistory: snapshot.dnsHistory,
1738 domainPricing: snapshot.domainPricing,
1521 portScanResults: snapshot.portScanResults, 1739 portScanResults: snapshot.portScanResults,
1522 hstsPreloaded: snapshot.hstsPreloaded, 1740 hstsPreloaded: snapshot.hstsPreloaded,
1523 availabilityResult: snapshot.availabilityResult, 1741 availabilityResult: snapshot.availabilityResult,
@@ -1549,9 +1767,13 @@ final class DomainViewModel {
1549 ipGeolocationError: snapshot.ipGeolocationError, 1767 ipGeolocationError: snapshot.ipGeolocationError,
1550 emailSecurityError: snapshot.emailSecurityError, 1768 emailSecurityError: snapshot.emailSecurityError,
1551 ownershipError: snapshot.ownershipError, 1769 ownershipError: snapshot.ownershipError,
1770 ownershipHistoryError: snapshot.ownershipHistoryError,
1552 ptrError: snapshot.ptrError, 1771 ptrError: snapshot.ptrError,
1553 redirectChainError: snapshot.redirectChainError, 1772 redirectChainError: snapshot.redirectChainError,
1554 subdomainsError: snapshot.subdomainsError, 1773 subdomainsError: snapshot.subdomainsError,
1774 extendedSubdomainsError: snapshot.extendedSubdomainsError,
1775 dnsHistoryError: snapshot.dnsHistoryError,
1776 domainPricingError: snapshot.domainPricingError,
1555 portScanError: snapshot.portScanError 1777 portScanError: snapshot.portScanError
1556 ) 1778 )
1557 1779
@@ -2038,6 +2260,9 @@ final class DomainViewModel {
2038 ownershipResult = nil 2260 ownershipResult = nil
2039 ownershipError = nil 2261 ownershipError = nil
2040 ownershipLoading = false 2262 ownershipLoading = false
2263 ownershipHistory = []
2264 ownershipHistoryError = nil
2265 ownershipHistoryLoading = false
2041 ptrRecord = nil 2266 ptrRecord = nil
2042 ptrError = nil 2267 ptrError = nil
2043 ptrLoading = false 2268 ptrLoading = false
@@ -2047,6 +2272,15 @@ final class DomainViewModel {
2047 subdomains = [] 2272 subdomains = []
2048 subdomainsError = nil 2273 subdomainsError = nil
2049 subdomainsLoading = false 2274 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
2050 portScanResults = [] 2284 portScanResults = []
2051 portScanError = nil 2285 portScanError = nil
2052 portScanLoading = false 2286 portScanLoading = false
@@ -2072,9 +2306,13 @@ final class DomainViewModel {
2072 ipGeolocationLoading = loading 2306 ipGeolocationLoading = loading
2073 emailSecurityLoading = loading 2307 emailSecurityLoading = loading
2074 ownershipLoading = loading 2308 ownershipLoading = loading
2309 ownershipHistoryLoading = false
2075 ptrLoading = loading 2310 ptrLoading = loading
2076 redirectChainLoading = loading 2311 redirectChainLoading = loading
2077 subdomainsLoading = loading 2312 subdomainsLoading = loading
2313 extendedSubdomainsLoading = false
2314 dnsHistoryLoading = false
2315 domainPricingLoading = false
2078 portScanLoading = loading 2316 portScanLoading = loading
2079 } 2317 }
2080 2318
@@ -2248,12 +2486,20 @@ final class DomainViewModel {
2248 emailSecurityError: nil, 2486 emailSecurityError: nil,
2249 ownership: nil, 2487 ownership: nil,
2250 ownershipError: nil, 2488 ownershipError: nil,
2489 ownershipHistory: [],
2490 ownershipHistoryError: nil,
2251 ptrRecord: nil, 2491 ptrRecord: nil,
2252 ptrError: nil, 2492 ptrError: nil,
2253 redirectChain: [], 2493 redirectChain: [],
2254 redirectChainError: nil, 2494 redirectChainError: nil,
2255 subdomains: [], 2495 subdomains: [],
2256 subdomainsError: nil, 2496 subdomainsError: nil,
2497 extendedSubdomains: [],
2498 extendedSubdomainsError: nil,
2499 dnsHistory: [],
2500 dnsHistoryError: nil,
2501 domainPricing: nil,
2502 domainPricingError: nil,
2257 portScanResults: [], 2503 portScanResults: [],
2258 portScanError: nil, 2504 portScanError: nil,
2259 changeSummary: trackedDomain.lastChangeSummary, 2505 changeSummary: trackedDomain.lastChangeSummary,
@@ -2435,6 +2681,30 @@ final class DomainViewModel {
2435 at: 3 2681 at: 3
2436 ) 2682 )
2437 } 2683 }
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 }
2438 if let certificateStatus = certificateBadgeLabel(from: snapshot) { 2708 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
2439 rows.insert( 2709 rows.insert(
2440 InfoRowViewData( 2710 InfoRowViewData(
@@ -2460,6 +2730,15 @@ final class DomainViewModel {
2460 } 2730 }
2461 } 2731 }
2462 2732
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
2463 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { 2742 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
2464 snapshot.dnsSections.map { section in 2743 snapshot.dnsSections.map { section in
2465 DNSRecordSectionViewData( 2744 DNSRecordSectionViewData(
@@ -3098,6 +3377,61 @@ final class DomainViewModel {
3098 return formatter 3377 return formatter
3099 }() 3378 }()
3100 3379
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
3101 private static func csvEscaped(_ value: String) -> String { 3435 private static func csvEscaped(_ value: String) -> String {
3102 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") 3436 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
3103 return "\"\(escaped)\"" 3437 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 {
29 case ownershipHistory 29 case ownershipHistory
30 case dnsHistory 30 case dnsHistory
31 case extendedSubdomains 31 case extendedSubdomains
32 case domainPricing
32 33
33 var id: String { rawValue } 34 var id: String { rawValue }
34 35
@@ -52,6 +53,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
52 return "DNS history" 53 return "DNS history"
53 case .extendedSubdomains: 54 case .extendedSubdomains:
54 return "Extended subdomains" 55 return "Extended subdomains"
56 case .domainPricing:
57 return "Domain pricing"
55 } 58 }
56 } 59 }
57} 60}
@@ -139,7 +142,7 @@ enum FeatureAccessService {
139 switch capability { 142 switch capability {
140 case .workflows, .batchOperations, .advancedExports: 143 case .workflows, .batchOperations, .advancedExports:
141 return "Available in Pro" 144 return "Available in Pro"
142 case .ownershipHistory, .dnsHistory, .extendedSubdomains: 145 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
143 return "Available in Data+" 146 return "Available in Data+"
144 case .limitedTracking: 147 case .limitedTracking:
145 return "Tracking is limited on Free" 148 return "Tracking is limited on Free"
@@ -199,8 +202,15 @@ enum FeatureAccessService {
199 } 202 }
200 203
201 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { 204 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
202 UpgradePromptContext( 205 let title: String
203 title: "Available in Pro", 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,
204 message: upgradeMessage(for: capability), 214 message: upgradeMessage(for: capability),
205 capability: capability 215 capability: capability
206 ) 216 )
DomainDig/HistoryView.swift +3
@@ -198,6 +198,9 @@ struct HistoryDetailView: View {
198 trackedDomain: trackedDomain, 198 trackedDomain: trackedDomain,
199 workflows: viewModel.workflowsContaining(domain: entry.domain), 199 workflows: viewModel.workflowsContaining(domain: entry.domain),
200 trackingLimitMessage: nil, 200 trackingLimitMessage: nil,
201 pricingLoading: false,
202 pricingError: snapshot.domainPricingError,
203 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
201 onTrack: { 204 onTrack: {
202 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) 205 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
203 }, 206 },
DomainDig/Models.swift +155 −3
@@ -370,6 +370,117 @@ enum DataCapability: String, Codable {
370 case domainPricing 370 case domainPricing
371} 371}
372 372
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
373enum HistoryDateFilter: String, CaseIterable, Identifiable { 484enum HistoryDateFilter: String, CaseIterable, Identifiable {
374 case today 485 case today
375 case last7Days 486 case last7Days
@@ -691,6 +802,7 @@ struct IPGeolocation: Codable {
691 802
692struct DomainOwnership: Codable, Equatable { 803struct DomainOwnership: Codable, Equatable {
693 let registrar: String? 804 let registrar: String?
805 let registrant: String?
694 let createdDate: Date? 806 let createdDate: Date?
695 let expirationDate: Date? 807 let expirationDate: Date?
696 let status: [String] 808 let status: [String]
@@ -699,6 +811,7 @@ struct DomainOwnership: Codable, Equatable {
699 811
700 init( 812 init(
701 registrar: String? = nil, 813 registrar: String? = nil,
814 registrant: String? = nil,
702 createdDate: Date? = nil, 815 createdDate: Date? = nil,
703 expirationDate: Date? = nil, 816 expirationDate: Date? = nil,
704 status: [String] = [], 817 status: [String] = [],
@@ -706,6 +819,7 @@ struct DomainOwnership: Codable, Equatable {
706 abuseEmail: String? = nil 819 abuseEmail: String? = nil
707 ) { 820 ) {
708 self.registrar = registrar 821 self.registrar = registrar
822 self.registrant = registrant
709 self.createdDate = createdDate 823 self.createdDate = createdDate
710 self.expirationDate = expirationDate 824 self.expirationDate = expirationDate
711 self.status = status 825 self.status = status
@@ -716,6 +830,7 @@ struct DomainOwnership: Codable, Equatable {
716 init(from decoder: Decoder) throws { 830 init(from decoder: Decoder) throws {
717 let container = try decoder.container(keyedBy: CodingKeys.self) 831 let container = try decoder.container(keyedBy: CodingKeys.self)
718 registrar = try container.decodeIfPresent(String.self, forKey: .registrar) 832 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
833 registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
719 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) 834 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
720 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) 835 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
721 status = try container.decodeIfPresent([String].self, forKey: .status) ?? [] 836 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
@@ -724,9 +839,17 @@ struct DomainOwnership: Codable, Equatable {
724 } 839 }
725} 840}
726 841
727struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable { 842struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
728 var id: String { hostname } 843 var id: String { hostname }
729 let hostname: String 844 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 }
730} 853}
731 854
732// MARK: - Email Security Models 855// MARK: - Email Security Models
@@ -857,9 +980,13 @@ struct HistoryEntry: Identifiable, Codable {
857 var emailSecurity: EmailSecurityResult? 980 var emailSecurity: EmailSecurityResult?
858 var mtaSts: MTASTSResult? 981 var mtaSts: MTASTSResult?
859 var ownership: DomainOwnership? 982 var ownership: DomainOwnership?
983 var ownershipHistory: [DomainOwnershipHistoryEvent]
860 var ptrRecord: String? 984 var ptrRecord: String?
861 var redirectChain: [RedirectHop] 985 var redirectChain: [RedirectHop]
862 var subdomains: [DiscoveredSubdomain] 986 var subdomains: [DiscoveredSubdomain]
987 var extendedSubdomains: [DiscoveredSubdomain]
988 var dnsHistory: [DNSHistoryEvent]
989 var domainPricing: DomainPricingInsight?
863 var portScanResults: [PortScanResult] 990 var portScanResults: [PortScanResult]
864 var hstsPreloaded: Bool? 991 var hstsPreloaded: Bool?
865 var availabilityResult: DomainAvailabilityResult? 992 var availabilityResult: DomainAvailabilityResult?
@@ -891,16 +1018,23 @@ struct HistoryEntry: Identifiable, Codable {
891 var ipGeolocationError: String? 1018 var ipGeolocationError: String?
892 var emailSecurityError: String? 1019 var emailSecurityError: String?
893 var ownershipError: String? 1020 var ownershipError: String?
1021 var ownershipHistoryError: String?
894 var ptrError: String? 1022 var ptrError: String?
895 var redirectChainError: String? 1023 var redirectChainError: String?
896 var subdomainsError: String? 1024 var subdomainsError: String?
1025 var extendedSubdomainsError: String?
1026 var dnsHistoryError: String?
1027 var domainPricingError: String?
897 var portScanError: String? 1028 var portScanError: String?
898 1029
899 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], 1030 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
900 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], 1031 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
901 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, 1032 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
902 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, 1033 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
1034 ownershipHistory: [DomainOwnershipHistoryEvent] = [],
903 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], 1035 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
1036 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
1037 domainPricing: DomainPricingInsight? = nil,
904 portScanResults: [PortScanResult] = [], 1038 portScanResults: [PortScanResult] = [],
905 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, 1039 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
906 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", 1040 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
@@ -915,8 +1049,10 @@ struct HistoryEntry: Identifiable, Codable {
915 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, 1049 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
916 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, 1050 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
917 reachabilityError: String? = nil, ipGeolocationError: String? = nil, 1051 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
918 emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil, 1052 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
919 redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: 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) {
920 self.domain = domain 1056 self.domain = domain
921 self.timestamp = timestamp 1057 self.timestamp = timestamp
922 self.trackedDomainID = trackedDomainID 1058 self.trackedDomainID = trackedDomainID
@@ -929,9 +1065,13 @@ struct HistoryEntry: Identifiable, Codable {
929 self.emailSecurity = emailSecurity 1065 self.emailSecurity = emailSecurity
930 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts 1066 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
931 self.ownership = ownership 1067 self.ownership = ownership
1068 self.ownershipHistory = ownershipHistory
932 self.ptrRecord = ptrRecord 1069 self.ptrRecord = ptrRecord
933 self.redirectChain = redirectChain 1070 self.redirectChain = redirectChain
934 self.subdomains = subdomains 1071 self.subdomains = subdomains
1072 self.extendedSubdomains = extendedSubdomains
1073 self.dnsHistory = dnsHistory
1074 self.domainPricing = domainPricing
935 self.portScanResults = portScanResults 1075 self.portScanResults = portScanResults
936 self.hstsPreloaded = hstsPreloaded 1076 self.hstsPreloaded = hstsPreloaded
937 self.availabilityResult = availabilityResult 1077 self.availabilityResult = availabilityResult
@@ -963,9 +1103,13 @@ struct HistoryEntry: Identifiable, Codable {
963 self.ipGeolocationError = ipGeolocationError 1103 self.ipGeolocationError = ipGeolocationError
964 self.emailSecurityError = emailSecurityError 1104 self.emailSecurityError = emailSecurityError
965 self.ownershipError = ownershipError 1105 self.ownershipError = ownershipError
1106 self.ownershipHistoryError = ownershipHistoryError
966 self.ptrError = ptrError 1107 self.ptrError = ptrError
967 self.redirectChainError = redirectChainError 1108 self.redirectChainError = redirectChainError
968 self.subdomainsError = subdomainsError 1109 self.subdomainsError = subdomainsError
1110 self.extendedSubdomainsError = extendedSubdomainsError
1111 self.dnsHistoryError = dnsHistoryError
1112 self.domainPricingError = domainPricingError
969 self.portScanError = portScanError 1113 self.portScanError = portScanError
970 } 1114 }
971 1115
@@ -984,9 +1128,13 @@ struct HistoryEntry: Identifiable, Codable {
984 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) 1128 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
985 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts 1129 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
986 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) 1130 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
1131 ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
987 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) 1132 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
988 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] 1133 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
989 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] 1134 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)
990 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] 1138 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
991 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) 1139 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
992 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) 1140 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -1018,9 +1166,13 @@ struct HistoryEntry: Identifiable, Codable {
1018 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) 1166 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
1019 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) 1167 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
1020 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError) 1168 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
1169 ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
1021 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) 1170 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
1022 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) 1171 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
1023 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) 1172 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)
1024 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) 1176 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
1025 } 1177 }
1026 1178
DomainDig/PaywallView.swift +12 −1
@@ -10,7 +10,7 @@ struct PaywallView: View {
10 NavigationStack { 10 NavigationStack {
11 List { 11 List {
12 Section { 12 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.")
14 .font(appDensity.font(.body, design: .default)) 14 .font(appDensity.font(.body, design: .default))
15 .foregroundStyle(.secondary) 15 .foregroundStyle(.secondary)
16 .fixedSize(horizontal: false, vertical: true) 16 .fixedSize(horizontal: false, vertical: true)
@@ -23,6 +23,13 @@ struct PaywallView: View {
23 featureRow("Advanced exports") 23 featureRow("Advanced exports")
24 } 24 }
25 25
26 Section("What Data+ Unlocks") {
27 featureRow("Ownership history")
28 featureRow("DNS history")
29 featureRow("Extended subdomains")
30 featureRow("External pricing signals")
31 }
32
26 Section("Subscription") { 33 Section("Subscription") {
27 if purchaseService.isLoadingProducts { 34 if purchaseService.isLoadingProducts {
28 ProgressView("Loading pricing…") 35 ProgressView("Loading pricing…")
@@ -116,6 +123,10 @@ struct PaywallView: View {
116 return "Pro Monthly" 123 return "Pro Monthly"
117 case PurchaseService.yearlyProductID: 124 case PurchaseService.yearlyProductID:
118 return "Pro Yearly" 125 return "Pro Yearly"
126 case PurchaseService.dataPlusMonthlyProductID:
127 return "Data+ Monthly"
128 case PurchaseService.dataPlusYearlyProductID:
129 return "Data+ Yearly"
119 default: 130 default:
120 return product.displayName 131 return product.displayName
121 } 132 }
DomainDig/PurchaseService.swift +31 −5
@@ -17,7 +17,14 @@ final class PurchaseService {
17 static let shared = PurchaseService() 17 static let shared = PurchaseService()
18 static let monthlyProductID = "domaindig.pro.monthly" 18 static let monthlyProductID = "domaindig.pro.monthly"
19 static let yearlyProductID = "domaindig.pro.yearly" 19 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 ]
21 28
22 private static let entitlementCacheKey = "purchase.cachedEntitlement" 29 private static let entitlementCacheKey = "purchase.cachedEntitlement"
23 30
@@ -52,7 +59,11 @@ final class PurchaseService {
52 } 59 }
53 60
54 var hasProAccess: Bool { 61 var hasProAccess: Bool {
55 currentTier == .pro 62 currentTier != .free
63 }
64
65 var hasDataPlusAccess: Bool {
66 currentTier == .dataPlus
56 } 67 }
57 68
58 func refreshProducts() async { 69 func refreshProducts() async {
@@ -91,7 +102,7 @@ final class PurchaseService {
91 .productID 102 .productID
92 103
93 self.activeProductID = activeProductID 104 self.activeProductID = activeProductID
94 currentTier = activeProductID == nil ? .free : .pro 105 currentTier = tier(for: activeProductID)
95 persistCurrentEntitlement() 106 persistCurrentEntitlement()
96 } 107 }
97 108
@@ -109,7 +120,7 @@ final class PurchaseService {
109 apply(transaction: transaction) 120 apply(transaction: transaction)
110 await transaction.finish() 121 await transaction.finish()
111 await refreshEntitlements() 122 await refreshEntitlements()
112 statusMessage = "Pro is active." 123 statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active."
113 case .userCancelled: 124 case .userCancelled:
114 break 125 break
115 case .pending: 126 case .pending:
@@ -177,7 +188,7 @@ final class PurchaseService {
177 } 188 }
178 189
179 activeProductID = transaction.productID 190 activeProductID = transaction.productID
180 currentTier = .pro 191 currentTier = tier(for: transaction.productID)
181 persistCurrentEntitlement() 192 persistCurrentEntitlement()
182 } 193 }
183 194
@@ -224,11 +235,26 @@ final class PurchaseService {
224 return 0 235 return 0
225 case Self.yearlyProductID: 236 case Self.yearlyProductID:
226 return 1 237 return 1
238 case Self.dataPlusMonthlyProductID:
239 return 2
240 case Self.dataPlusYearlyProductID:
241 return 3
227 default: 242 default:
228 return Int.max 243 return Int.max
229 } 244 }
230 } 245 }
231 246
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
232 private func storeMessage(for error: Error, fallback: String) -> String { 258 private func storeMessage(for error: Error, fallback: String) -> String {
233 if let storeKitError = error as? StoreKitError { 259 if let storeKitError = error as? StoreKitError {
234 switch storeKitError { 260 switch storeKitError {
DomainDig/RDAPService.swift +5
@@ -25,6 +25,7 @@ enum RDAPService {
25 case let .success(response): 25 case let .success(response):
26 let ownership = DomainOwnership( 26 let ownership = DomainOwnership(
27 registrar: response.registrarName, 27 registrar: response.registrarName,
28 registrant: response.registrantName,
28 createdDate: response.createdDate, 29 createdDate: response.createdDate,
29 expirationDate: response.expirationDate, 30 expirationDate: response.expirationDate,
30 status: response.status, 31 status: response.status,
@@ -113,6 +114,10 @@ private struct RDAPDomainResponse: Decodable, Sendable {
113 entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName 114 entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
114 } 115 }
115 116
117 var registrantName: String? {
118 entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName
119 }
120
116 var createdDate: Date? { 121 var createdDate: Date? {
117 eventDate(for: ["registration", "registered"]) 122 eventDate(for: ["registration", "registered"])
118 } 123 }
DomainDig/SubdomainDiscoveryService.swift +1 −1
@@ -59,7 +59,7 @@ enum SubdomainDiscoveryService {
59 guard seen.insert(sanitized).inserted else { 59 guard seen.insert(sanitized).inserted else {
60 continue 60 continue
61 } 61 }
62 results.append(DiscoveredSubdomain(hostname: sanitized)) 62 results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh"))
63 if results.count == limit { 63 if results.count == limit {
64 return results 64 return results
65 } 65 }
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 {
6 let arguments = Array(CommandLine.arguments.dropFirst()) 6 let arguments = Array(CommandLine.arguments.dropFirst())
7 7
8 guard let command = CommandLine.arguments.first else { 8 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)
10 Foundation.exit(1) 10 Foundation.exit(1)
11 } 11 }
12 _ = command 12 _ = command
13 13
14 let wantsJSON = arguments.contains("--json") || arguments.contains("-j") 14 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")
15 let domains = arguments.filter { !$0.hasPrefix("-") } 20 let domains = arguments.filter { !$0.hasPrefix("-") }
16 21
17 let requestedDomains = domains 22 let requestedDomains = domains
@@ -19,18 +24,29 @@ struct DomainDigCLI {
19 .filter { !$0.isEmpty } 24 .filter { !$0.isEmpty }
20 25
21 guard !requestedDomains.isEmpty else { 26 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)
23 Foundation.exit(1) 28 Foundation.exit(1)
24 } 29 }
25 30
26 let inspectionService = DomainInspectionService() 31 let inspectionService = DomainInspectionService()
32 let reportBuilder = DomainReportBuilder()
27 var reports: [DomainReport] = [] 33 var reports: [DomainReport] = []
28 var seen = Set<String>() 34 var seen = Set<String>()
35 var usageImpact: [String] = []
29 36
30 for domain in requestedDomains { 37 for domain in requestedDomains {
31 let normalizedDomain = domain.lowercased() 38 let normalizedDomain = domain.lowercased()
32 guard seen.insert(normalizedDomain).inserted else { continue } 39 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))
34 } 50 }
35 51
36 do { 52 do {
@@ -47,6 +63,9 @@ struct DomainDigCLI {
47 title: "DomainDig Batch Report" 63 title: "DomainDig Batch Report"
48 ) 64 )
49 } 65 }
66 if wantsUsage, !usageImpact.isEmpty {
67 FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8))
68 }
50 FileHandle.standardOutput.write(data) 69 FileHandle.standardOutput.write(data)
51 if data.last != 0x0A { 70 if data.last != 0x0A {
52 FileHandle.standardOutput.write(Data([0x0A])) 71 FileHandle.standardOutput.write(Data([0x0A]))
@@ -56,4 +75,187 @@ struct DomainDigCLI {
56 Foundation.exit(1) 75 Foundation.exit(1)
57 } 76 }
58 } 77 }
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 }
59} 261}
DomainInspectionService.swift +8
@@ -313,12 +313,20 @@ struct DomainInspectionService {
313 emailSecurityError: emailSecurity.message, 313 emailSecurityError: emailSecurity.message,
314 ownership: ownership.value, 314 ownership: ownership.value,
315 ownershipError: ownership.message, 315 ownershipError: ownership.message,
316 ownershipHistory: [],
317 ownershipHistoryError: nil,
316 ptrRecord: ptrRecord.value, 318 ptrRecord: ptrRecord.value,
317 ptrError: ptrRecord.message, 319 ptrError: ptrRecord.message,
318 redirectChain: redirectChain.value, 320 redirectChain: redirectChain.value,
319 redirectChainError: redirectChain.message, 321 redirectChainError: redirectChain.message,
320 subdomains: subdomains.value, 322 subdomains: subdomains.value,
321 subdomainsError: subdomains.message, 323 subdomainsError: subdomains.message,
324 extendedSubdomains: [],
325 extendedSubdomainsError: nil,
326 dnsHistory: [],
327 dnsHistoryError: nil,
328 domainPricing: nil,
329 domainPricingError: nil,
322 portScanResults: portScanResults.value, 330 portScanResults: portScanResults.value,
323 portScanError: portScanResults.message, 331 portScanError: portScanResults.message,
324 changeSummary: nil, 332 changeSummary: nil,
DomainReportBuilder.swift +9 −1
@@ -21,11 +21,15 @@ struct DomainReport: Codable {
21 let emailConfidence: ConfidenceLevel? 21 let emailConfidence: ConfidenceLevel?
22 let geolocationConfidence: ConfidenceLevel? 22 let geolocationConfidence: ConfidenceLevel?
23 let ownership: DomainOwnership? 23 let ownership: DomainOwnership?
24 let ownershipHistory: [DomainOwnershipHistoryEvent]
24 let dns: DNSResultSummary 25 let dns: DNSResultSummary
25 let web: WebResultSummary 26 let web: WebResultSummary
26 let email: EmailSecuritySummary 27 let email: EmailSecuritySummary
27 let network: NetworkSummary 28 let network: NetworkSummary
28 let subdomains: [String] 29 let subdomains: [String]
30 let extendedSubdomains: [String]
31 let dnsHistory: [DNSHistoryEvent]
32 let domainPricing: DomainPricingInsight?
29 let subdomainGroups: [SubdomainGroup] 33 let subdomainGroups: [SubdomainGroup]
30 let riskAssessment: DomainRiskAssessment 34 let riskAssessment: DomainRiskAssessment
31 let insights: [String] 35 let insights: [String]
@@ -164,6 +168,7 @@ struct DomainReportBuilder {
164 emailConfidence: snapshot.emailSecurityConfidence, 168 emailConfidence: snapshot.emailSecurityConfidence,
165 geolocationConfidence: snapshot.geolocationConfidence, 169 geolocationConfidence: snapshot.geolocationConfidence,
166 ownership: snapshot.ownership, 170 ownership: snapshot.ownership,
171 ownershipHistory: snapshot.ownershipHistory,
167 dns: DNSResultSummary( 172 dns: DNSResultSummary(
168 resolverDisplayName: snapshot.resolverDisplayName, 173 resolverDisplayName: snapshot.resolverDisplayName,
169 resolverURLString: snapshot.resolverURLString, 174 resolverURLString: snapshot.resolverURLString,
@@ -216,13 +221,16 @@ struct DomainReportBuilder {
216 portScanError: snapshot.portScanError 221 portScanError: snapshot.portScanError
217 ), 222 ),
218 subdomains: snapshot.subdomains.map(\.hostname), 223 subdomains: snapshot.subdomains.map(\.hostname),
224 extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
225 dnsHistory: snapshot.dnsHistory,
226 domainPricing: snapshot.domainPricing,
219 subdomainGroups: analysis.subdomainGroups, 227 subdomainGroups: analysis.subdomainGroups,
220 riskAssessment: analysis.riskAssessment, 228 riskAssessment: analysis.riskAssessment,
221 insights: analysis.insights, 229 insights: analysis.insights,
222 changeSummary: changeSummary, 230 changeSummary: changeSummary,
223 workflowContext: workflowContext, 231 workflowContext: workflowContext,
224 metadata: DomainReportMetadata( 232 metadata: DomainReportMetadata(
225 schemaVersion: "3.0.0", 233 schemaVersion: "3.2.0",
226 resolverDisplayName: snapshot.resolverDisplayName, 234 resolverDisplayName: snapshot.resolverDisplayName,
227 resolverURLString: snapshot.resolverURLString, 235 resolverURLString: snapshot.resolverURLString,
228 appVersion: snapshot.appVersion, 236 appVersion: snapshot.appVersion,
DomainReportExporter.swift +74 −1
@@ -67,7 +67,9 @@ enum DomainReportExporter {
67 "TLS Status: \(report.web.tlsStatus)", 67 "TLS Status: \(report.web.tlsStatus)",
68 "HTTP: \(httpSummary(for: report))", 68 "HTTP: \(httpSummary(for: report))",
69 "Email: \(report.email.summary)", 69 "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")"
71 ] 73 ]
72 } 74 }
73 75
@@ -97,6 +99,7 @@ enum DomainReportExporter {
97 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", 99 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
98 "Created: \(ownershipDateLabel(report.ownership?.createdDate))", 100 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
99 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", 101 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
102 "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
100 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", 103 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
101 "Status: \(joined(report.ownership?.status) ?? "Unavailable")", 104 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
102 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" 105 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
@@ -112,6 +115,20 @@ enum DomainReportExporter {
112 return ownershipLines 115 return ownershipLines
113 } 116 }
114 117
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
115 appendSection("DNS", to: &lines) { 132 appendSection("DNS", to: &lines) {
116 var dnsLines = [ 133 var dnsLines = [
117 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", 134 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
@@ -139,6 +156,22 @@ enum DomainReportExporter {
139 return dnsLines 156 return dnsLines
140 } 157 }
141 158
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
142 appendSection("Web", to: &lines) { 175 appendSection("Web", to: &lines) {
143 var webLines = [ 176 var webLines = [
144 "TLS Status: \(report.web.tlsStatus)", 177 "TLS Status: \(report.web.tlsStatus)",
@@ -254,9 +287,28 @@ enum DomainReportExporter {
254 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))") 287 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
255 } 288 }
256 values.append(contentsOf: report.subdomains.map { "- \($0)" }) 289 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 }
257 return values 294 return values
258 } 295 }
259 296
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
260 appendSection("Changes", to: &lines) { 312 appendSection("Changes", to: &lines) {
261 guard let changeSummary = report.changeSummary else { 313 guard let changeSummary = report.changeSummary else {
262 return ["No comparison available"] 314 return ["No comparison available"]
@@ -342,9 +394,18 @@ enum DomainReportExporter {
342 "email_grade", 394 "email_grade",
343 "email_confidence", 395 "email_confidence",
344 "subdomain_count", 396 "subdomain_count",
397 "extended_subdomain_count",
345 "subdomain_groups", 398 "subdomain_groups",
346 "subdomain_confidence", 399 "subdomain_confidence",
347 "subdomains", 400 "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",
348 "open_ports", 409 "open_ports",
349 "reachability_summary", 410 "reachability_summary",
350 "geolocation_summary", 411 "geolocation_summary",
@@ -368,12 +429,15 @@ enum DomainReportExporter {
368 let httpStatus = report.web.statusCode.map(String.init) ?? "" 429 let httpStatus = report.web.statusCode.map(String.init) ?? ""
369 let subdomainCount = String(report.subdomains.count) 430 let subdomainCount = String(report.subdomains.count)
370 let subdomains = report.subdomains.joined(separator: " | ") 431 let subdomains = report.subdomains.joined(separator: " | ")
432 let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
371 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") 433 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
372 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ") 434 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
373 let insights = report.insights.joined(separator: " | ") 435 let insights = report.insights.joined(separator: " | ")
374 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ") 436 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
375 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ") 437 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
376 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") 438 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: " | ")
377 441
378 return [ 442 return [
379 report.domain, 443 report.domain,
@@ -407,9 +471,18 @@ enum DomainReportExporter {
407 report.email.grade?.rawValue ?? "", 471 report.email.grade?.rawValue ?? "",
408 report.emailConfidence?.rawValue ?? "", 472 report.emailConfidence?.rawValue ?? "",
409 subdomainCount, 473 subdomainCount,
474 String(report.extendedSubdomains.count),
410 subdomainGroups, 475 subdomainGroups,
411 report.subdomainConfidence?.rawValue ?? "", 476 report.subdomainConfidence?.rawValue ?? "",
412 subdomains, 477 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 ?? "",
413 openPorts, 486 openPorts,
414 report.network.reachabilitySummary, 487 report.network.reachabilitySummary,
415 report.network.geolocationSummary, 488 report.network.geolocationSummary,
LookupSnapshot.swift +16
@@ -42,12 +42,20 @@ struct LookupSnapshot {
42 let emailSecurityError: String? 42 let emailSecurityError: String?
43 let ownership: DomainOwnership? 43 let ownership: DomainOwnership?
44 let ownershipError: String? 44 let ownershipError: String?
45 let ownershipHistory: [DomainOwnershipHistoryEvent]
46 let ownershipHistoryError: String?
45 let ptrRecord: String? 47 let ptrRecord: String?
46 let ptrError: String? 48 let ptrError: String?
47 let redirectChain: [RedirectHop] 49 let redirectChain: [RedirectHop]
48 let redirectChainError: String? 50 let redirectChainError: String?
49 let subdomains: [DiscoveredSubdomain] 51 let subdomains: [DiscoveredSubdomain]
50 let subdomainsError: String? 52 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?
51 let portScanResults: [PortScanResult] 59 let portScanResults: [PortScanResult]
52 let portScanError: String? 60 let portScanError: String?
53 let changeSummary: DomainChangeSummary? 61 let changeSummary: DomainChangeSummary?
@@ -104,12 +112,20 @@ extension HistoryEntry {
104 emailSecurityError: emailSecurityError, 112 emailSecurityError: emailSecurityError,
105 ownership: ownership, 113 ownership: ownership,
106 ownershipError: ownershipError, 114 ownershipError: ownershipError,
115 ownershipHistory: ownershipHistory,
116 ownershipHistoryError: ownershipHistoryError,
107 ptrRecord: ptrRecord, 117 ptrRecord: ptrRecord,
108 ptrError: ptrError, 118 ptrError: ptrError,
109 redirectChain: redirectChain, 119 redirectChain: redirectChain,
110 redirectChainError: redirectChainError, 120 redirectChainError: redirectChainError,
111 subdomains: subdomains, 121 subdomains: subdomains,
112 subdomainsError: subdomainsError, 122 subdomainsError: subdomainsError,
123 extendedSubdomains: extendedSubdomains,
124 extendedSubdomainsError: extendedSubdomainsError,
125 dnsHistory: dnsHistory,
126 dnsHistoryError: dnsHistoryError,
127 domainPricing: domainPricing,
128 domainPricingError: domainPricingError,
113 portScanResults: portScanResults, 129 portScanResults: portScanResults,
114 portScanError: portScanError, 130 portScanError: portScanError,
115 changeSummary: changeSummary, 131 changeSummary: changeSummary,