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