Commit 6c23daaaba
Verified · cmc
Layout: unified · split
DomainDig/AppVersion.swift +2 −2
| @@ -1,7 +1,7 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | enum AppVersion { | 3 | enum AppVersion { |
| 4 | static var current: String { | 4 | nonisolated static var current: String { |
| 5 | "3.1.0" | 5 | "3.2.0" |
| 6 | } | 6 | } |
| 7 | } | 7 | } |
DomainDig/ContentView.swift +364 −74
| @@ -71,62 +71,11 @@ struct ContentView: View { | |||
| 71 | .padding(.top, appDensity.metrics.cardSpacing) | 71 | .padding(.top, appDensity.metrics.cardSpacing) |
| 72 | } | 72 | } |
| 73 | } | 73 | } |
| 74 | DomainSectionView( | 74 | domainOverviewSection |
| 75 | isCollapsed: sectionCollapsedBinding(.domain), | ||
| 76 | rows: viewModel.domainRows, | ||
| 77 | suggestions: viewModel.suggestionRows, | ||
| 78 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, | ||
| 79 | availabilityLoading: viewModel.availabilityLoading, | ||
| 80 | suggestionsLoading: viewModel.suggestionsLoading, | ||
| 81 | provenance: viewModel.currentSnapshot.provenanceBySection[.availability], | ||
| 82 | confidence: viewModel.currentSnapshot.availabilityConfidence, | ||
| 83 | snapshotNote: viewModel.currentSnapshot.note, | ||
| 84 | trackedDomain: viewModel.currentTrackedDomain, | ||
| 85 | workflows: viewModel.currentDomainWorkflows, | ||
| 86 | trackingLimitMessage: viewModel.trackingLimitMessage, | ||
| 87 | onTrack: { | ||
| 88 | _ = viewModel.trackCurrentDomain() | ||
| 89 | }, | ||
| 90 | onTogglePinned: { | ||
| 91 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | ||
| 92 | viewModel.togglePinned(for: trackedDomain) | ||
| 93 | }, | ||
| 94 | onEditNote: { | ||
| 95 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | ||
| 96 | trackingNoteDraft = trackedDomain.note ?? "" | ||
| 97 | editingTrackedDomain = trackedDomain | ||
| 98 | }, | ||
| 99 | onAddToWorkflow: { | ||
| 100 | showingCurrentDomainWorkflowSheet = true | ||
| 101 | }, | ||
| 102 | onOpenWorkflow: { workflow in | ||
| 103 | navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id)) | ||
| 104 | }, | ||
| 105 | onRunWorkflow: { workflow in | ||
| 106 | viewModel.rerunCurrentDomain(in: workflow) | ||
| 107 | } | ||
| 108 | ) | ||
| 109 | .padding(.top, appDensity.metrics.sectionSpacing) | 75 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 110 | OwnershipSectionView( | 76 | ownershipSection |
| 111 | isCollapsed: sectionCollapsedBinding(.ownership), | ||
| 112 | rows: viewModel.ownershipRows, | ||
| 113 | loading: viewModel.ownershipLoading, | ||
| 114 | error: viewModel.ownershipError, | ||
| 115 | provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], | ||
| 116 | confidence: viewModel.currentSnapshot.ownershipConfidence, | ||
| 117 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) | ||
| 118 | ) | ||
| 119 | .padding(.top, appDensity.metrics.sectionSpacing) | 77 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 120 | SubdomainsSectionView( | 78 | subdomainsSection |
| 121 | isCollapsed: sectionCollapsedBinding(.subdomains), | ||
| 122 | rows: viewModel.subdomainRows, | ||
| 123 | groups: viewModel.currentSubdomainGroups, | ||
| 124 | loading: viewModel.subdomainsLoading, | ||
| 125 | error: viewModel.subdomainsError, | ||
| 126 | provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], | ||
| 127 | confidence: viewModel.currentSnapshot.subdomainConfidence, | ||
| 128 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) | ||
| 129 | ) | ||
| 130 | .padding(.top, appDensity.metrics.sectionSpacing) | 79 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 131 | if !viewModel.currentDiffSections.isEmpty { | 80 | if !viewModel.currentDiffSections.isEmpty { |
| 132 | DomainDiffView( | 81 | DomainDiffView( |
| @@ -137,17 +86,7 @@ struct ContentView: View { | |||
| 137 | ) | 86 | ) |
| 138 | .padding(.top, appDensity.metrics.sectionSpacing) | 87 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 139 | } | 88 | } |
| 140 | DNSSectionView( | 89 | dnsSection |
| 141 | isCollapsed: sectionCollapsedBinding(.dns), | ||
| 142 | dnssecLabel: viewModel.dnssecLabel, | ||
| 143 | patternSummary: viewModel.currentDNSPatterns, | ||
| 144 | sections: viewModel.dnsRows, | ||
| 145 | ptrMessage: viewModel.ptrMessage, | ||
| 146 | loading: viewModel.dnsLoading || viewModel.ptrLoading, | ||
| 147 | dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], | ||
| 148 | ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], | ||
| 149 | sectionError: viewModel.dnsError | ||
| 150 | ) | ||
| 151 | .padding(.top, appDensity.metrics.sectionSpacing) | 90 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 152 | WebSectionView( | 91 | WebSectionView( |
| 153 | isCollapsed: sectionCollapsedBinding(.web), | 92 | isCollapsed: sectionCollapsedBinding(.web), |
| @@ -240,6 +179,9 @@ struct ContentView: View { | |||
| 240 | .onAppear { | 179 | .onAppear { |
| 241 | domainFieldFocused = true | 180 | domainFieldFocused = true |
| 242 | } | 181 | } |
| 182 | .task { | ||
| 183 | await viewModel.refreshUsageCredits() | ||
| 184 | } | ||
| 243 | .onChange(of: viewModel.searchedDomain) { _, _ in | 185 | .onChange(of: viewModel.searchedDomain) { _, _ in |
| 244 | collapsedSections = defaultCollapsedSections | 186 | collapsedSections = defaultCollapsedSections |
| 245 | } | 187 | } |
| @@ -372,6 +314,117 @@ struct ContentView: View { | |||
| 372 | .padding(.vertical, appDensity.metrics.sectionSpacing) | 314 | .padding(.vertical, appDensity.metrics.sectionSpacing) |
| 373 | } | 315 | } |
| 374 | 316 | ||
| 317 | private var domainOverviewSection: some View { | ||
| 318 | let trackedDomain = viewModel.currentTrackedDomain | ||
| 319 | let workflows = viewModel.currentDomainWorkflows | ||
| 320 | |||
| 321 | return DomainSectionView( | ||
| 322 | isCollapsed: sectionCollapsedBinding(.domain), | ||
| 323 | rows: viewModel.domainRows, | ||
| 324 | suggestions: viewModel.suggestionRows, | ||
| 325 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, | ||
| 326 | availabilityLoading: viewModel.availabilityLoading, | ||
| 327 | suggestionsLoading: viewModel.suggestionsLoading, | ||
| 328 | provenance: viewModel.currentSnapshot.provenanceBySection[.availability], | ||
| 329 | confidence: viewModel.currentSnapshot.availabilityConfidence, | ||
| 330 | snapshotNote: viewModel.currentSnapshot.note, | ||
| 331 | trackedDomain: trackedDomain, | ||
| 332 | workflows: workflows, | ||
| 333 | trackingLimitMessage: viewModel.trackingLimitMessage, | ||
| 334 | pricingLoading: viewModel.domainPricingLoading, | ||
| 335 | pricingError: viewModel.domainPricingError, | ||
| 336 | showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing), | ||
| 337 | onTrack: { | ||
| 338 | _ = viewModel.trackCurrentDomain() | ||
| 339 | }, | ||
| 340 | onTogglePinned: { | ||
| 341 | guard let trackedDomain else { return } | ||
| 342 | viewModel.togglePinned(for: trackedDomain) | ||
| 343 | }, | ||
| 344 | onEditNote: { | ||
| 345 | guard let trackedDomain else { return } | ||
| 346 | trackingNoteDraft = trackedDomain.note ?? "" | ||
| 347 | editingTrackedDomain = trackedDomain | ||
| 348 | }, | ||
| 349 | onAddToWorkflow: { | ||
| 350 | showingCurrentDomainWorkflowSheet = true | ||
| 351 | }, | ||
| 352 | onOpenWorkflow: { workflow in | ||
| 353 | navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id)) | ||
| 354 | }, | ||
| 355 | onRunWorkflow: { workflow in | ||
| 356 | viewModel.rerunCurrentDomain(in: workflow) | ||
| 357 | } | ||
| 358 | ) | ||
| 359 | } | ||
| 360 | |||
| 361 | private var ownershipSection: some View { | ||
| 362 | OwnershipSectionView( | ||
| 363 | isCollapsed: sectionCollapsedBinding(.ownership), | ||
| 364 | rows: viewModel.ownershipRows, | ||
| 365 | loading: viewModel.ownershipLoading, | ||
| 366 | error: viewModel.ownershipError, | ||
| 367 | provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], | ||
| 368 | confidence: viewModel.currentSnapshot.ownershipConfidence, | ||
| 369 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory), | ||
| 370 | history: viewModel.ownershipHistory, | ||
| 371 | historyLoading: viewModel.ownershipHistoryLoading, | ||
| 372 | historyError: viewModel.ownershipHistoryError, | ||
| 373 | historyCreditStatus: viewModel.ownershipHistoryCreditStatus, | ||
| 374 | onLoadHistory: { | ||
| 375 | Task { | ||
| 376 | await viewModel.loadOwnershipHistory() | ||
| 377 | } | ||
| 378 | } | ||
| 379 | ) | ||
| 380 | } | ||
| 381 | |||
| 382 | private var subdomainsSection: some View { | ||
| 383 | SubdomainsSectionView( | ||
| 384 | isCollapsed: sectionCollapsedBinding(.subdomains), | ||
| 385 | rows: viewModel.subdomainRows, | ||
| 386 | groups: viewModel.currentSubdomainGroups, | ||
| 387 | loading: viewModel.subdomainsLoading, | ||
| 388 | error: viewModel.subdomainsError, | ||
| 389 | provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], | ||
| 390 | confidence: viewModel.currentSnapshot.subdomainConfidence, | ||
| 391 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains), | ||
| 392 | extendedCount: viewModel.extendedSubdomains.count, | ||
| 393 | extendedLoading: viewModel.extendedSubdomainsLoading, | ||
| 394 | extendedError: viewModel.extendedSubdomainsError, | ||
| 395 | extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus, | ||
| 396 | onLoadExtended: { | ||
| 397 | Task { | ||
| 398 | await viewModel.loadExtendedSubdomains() | ||
| 399 | } | ||
| 400 | } | ||
| 401 | ) | ||
| 402 | } | ||
| 403 | |||
| 404 | private var dnsSection: some View { | ||
| 405 | DNSSectionView( | ||
| 406 | isCollapsed: sectionCollapsedBinding(.dns), | ||
| 407 | dnssecLabel: viewModel.dnssecLabel, | ||
| 408 | patternSummary: viewModel.currentDNSPatterns, | ||
| 409 | sections: viewModel.dnsRows, | ||
| 410 | ptrMessage: viewModel.ptrMessage, | ||
| 411 | loading: viewModel.dnsLoading || viewModel.ptrLoading, | ||
| 412 | dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], | ||
| 413 | ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], | ||
| 414 | sectionError: viewModel.dnsError, | ||
| 415 | history: viewModel.dnsHistory, | ||
| 416 | historyLoading: viewModel.dnsHistoryLoading, | ||
| 417 | historyError: viewModel.dnsHistoryError, | ||
| 418 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory), | ||
| 419 | historyCreditStatus: viewModel.dnsHistoryCreditStatus, | ||
| 420 | onLoadHistory: { | ||
| 421 | Task { | ||
| 422 | await viewModel.loadDNSHistory() | ||
| 423 | } | ||
| 424 | } | ||
| 425 | ) | ||
| 426 | } | ||
| 427 | |||
| 375 | private var actionButtons: some View { | 428 | private var actionButtons: some View { |
| 376 | HStack { | 429 | HStack { |
| 377 | Spacer() | 430 | Spacer() |
| @@ -1192,6 +1245,9 @@ struct DomainSectionView: View { | |||
| 1192 | let trackedDomain: TrackedDomain? | 1245 | let trackedDomain: TrackedDomain? |
| 1193 | let workflows: [DomainWorkflow] | 1246 | let workflows: [DomainWorkflow] |
| 1194 | let trackingLimitMessage: String? | 1247 | let trackingLimitMessage: String? |
| 1248 | let pricingLoading: Bool | ||
| 1249 | let pricingError: String? | ||
| 1250 | let showsPricingPlaceholder: Bool | ||
| 1195 | let onTrack: () -> Void | 1251 | let onTrack: () -> Void |
| 1196 | let onTogglePinned: () -> Void | 1252 | let onTogglePinned: () -> Void |
| 1197 | let onEditNote: (() -> Void)? | 1253 | let onEditNote: (() -> Void)? |
| @@ -1313,12 +1369,24 @@ struct DomainSectionView: View { | |||
| 1313 | } | 1369 | } |
| 1314 | } | 1370 | } |
| 1315 | } | 1371 | } |
| 1372 | if pricingLoading { | ||
| 1373 | ProgressView("Loading external pricing…") | ||
| 1374 | .appLoadingStyle() | ||
| 1375 | .padding(.top, 4) | ||
| 1376 | } else if let pricingError { | ||
| 1377 | MessageRowView(text: pricingError, isError: false) | ||
| 1378 | .padding(.top, 4) | ||
| 1379 | } else if showsPricingPlaceholder { | ||
| 1380 | MessageRowView(text: "Pricing signals available in Data+", isError: false) | ||
| 1381 | .padding(.top, 4) | ||
| 1382 | } | ||
| 1316 | } | 1383 | } |
| 1317 | } | 1384 | } |
| 1318 | } | 1385 | } |
| 1319 | } | 1386 | } |
| 1320 | 1387 | ||
| 1321 | struct OwnershipSectionView: View { | 1388 | struct OwnershipSectionView: View { |
| 1389 | @Environment(\.appDensity) private var appDensity | ||
| 1322 | @Binding var isCollapsed: Bool | 1390 | @Binding var isCollapsed: Bool |
| 1323 | let rows: [InfoRowViewData] | 1391 | let rows: [InfoRowViewData] |
| 1324 | let loading: Bool | 1392 | let loading: Bool |
| @@ -1326,6 +1394,39 @@ struct OwnershipSectionView: View { | |||
| 1326 | let provenance: SectionProvenance? | 1394 | let provenance: SectionProvenance? |
| 1327 | let confidence: ConfidenceLevel? | 1395 | let confidence: ConfidenceLevel? |
| 1328 | let showsHistoryPlaceholder: Bool | 1396 | let showsHistoryPlaceholder: Bool |
| 1397 | let history: [DomainOwnershipHistoryEvent] | ||
| 1398 | let historyLoading: Bool | ||
| 1399 | let historyError: String? | ||
| 1400 | let historyCreditStatus: UsageCreditStatus? | ||
| 1401 | let onLoadHistory: (() -> Void)? | ||
| 1402 | |||
| 1403 | init( | ||
| 1404 | isCollapsed: Binding<Bool>, | ||
| 1405 | rows: [InfoRowViewData], | ||
| 1406 | loading: Bool, | ||
| 1407 | error: String?, | ||
| 1408 | provenance: SectionProvenance?, | ||
| 1409 | confidence: ConfidenceLevel?, | ||
| 1410 | showsHistoryPlaceholder: Bool, | ||
| 1411 | history: [DomainOwnershipHistoryEvent] = [], | ||
| 1412 | historyLoading: Bool = false, | ||
| 1413 | historyError: String? = nil, | ||
| 1414 | historyCreditStatus: UsageCreditStatus? = nil, | ||
| 1415 | onLoadHistory: (() -> Void)? = nil | ||
| 1416 | ) { | ||
| 1417 | _isCollapsed = isCollapsed | ||
| 1418 | self.rows = rows | ||
| 1419 | self.loading = loading | ||
| 1420 | self.error = error | ||
| 1421 | self.provenance = provenance | ||
| 1422 | self.confidence = confidence | ||
| 1423 | self.showsHistoryPlaceholder = showsHistoryPlaceholder | ||
| 1424 | self.history = history | ||
| 1425 | self.historyLoading = historyLoading | ||
| 1426 | self.historyError = historyError | ||
| 1427 | self.historyCreditStatus = historyCreditStatus | ||
| 1428 | self.onLoadHistory = onLoadHistory | ||
| 1429 | } | ||
| 1329 | 1430 | ||
| 1330 | var body: some View { | 1431 | var body: some View { |
| 1331 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { | 1432 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { |
| @@ -1342,9 +1443,41 @@ struct OwnershipSectionView: View { | |||
| 1342 | MessageRowView(text: error, isError: error != "Unavailable") | 1443 | MessageRowView(text: error, isError: error != "Unavailable") |
| 1343 | .padding(.top, 4) | 1444 | .padding(.top, 4) |
| 1344 | } | 1445 | } |
| 1345 | if showsHistoryPlaceholder { | 1446 | VStack(alignment: .leading, spacing: 8) { |
| 1346 | MessageRowView(text: "Ownership history (coming soon)", isError: false) | 1447 | HStack { |
| 1347 | .padding(.top, 4) | 1448 | Text("History") |
| 1449 | .font(appDensity.font(.caption)) | ||
| 1450 | .foregroundStyle(.secondary) | ||
| 1451 | Spacer() | ||
| 1452 | if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | ||
| 1453 | Button("Load (\(historyCreditStatus.remaining) left)") { | ||
| 1454 | onLoadHistory() | ||
| 1455 | } | ||
| 1456 | .buttonStyle(.bordered) | ||
| 1457 | .font(appDensity.font(.caption2)) | ||
| 1458 | } | ||
| 1459 | } | ||
| 1460 | if historyLoading { | ||
| 1461 | ProgressView("Loading history…") | ||
| 1462 | .appLoadingStyle() | ||
| 1463 | } else if !history.isEmpty { | ||
| 1464 | ForEach(history) { event in | ||
| 1465 | VStack(alignment: .leading, spacing: 3) { | ||
| 1466 | Text(event.date.formatted(date: .abbreviated, time: .omitted)) | ||
| 1467 | .font(appDensity.font(.caption2)) | ||
| 1468 | .foregroundStyle(.secondary) | ||
| 1469 | Text(event.summary) | ||
| 1470 | .font(appDensity.font(.caption)) | ||
| 1471 | Text(event.source) | ||
| 1472 | .font(appDensity.font(.caption2)) | ||
| 1473 | .foregroundStyle(.secondary) | ||
| 1474 | } | ||
| 1475 | } | ||
| 1476 | } else if let historyError { | ||
| 1477 | MessageRowView(text: historyError, isError: false) | ||
| 1478 | } else if showsHistoryPlaceholder { | ||
| 1479 | MessageRowView(text: "Ownership history available in Data+", isError: false) | ||
| 1480 | } | ||
| 1348 | } | 1481 | } |
| 1349 | } | 1482 | } |
| 1350 | } | 1483 | } |
| @@ -1362,6 +1495,41 @@ struct SubdomainsSectionView: View { | |||
| 1362 | let provenance: SectionProvenance? | 1495 | let provenance: SectionProvenance? |
| 1363 | let confidence: ConfidenceLevel? | 1496 | let confidence: ConfidenceLevel? |
| 1364 | let showsExtendedPlaceholder: Bool | 1497 | let showsExtendedPlaceholder: Bool |
| 1498 | let extendedCount: Int | ||
| 1499 | let extendedLoading: Bool | ||
| 1500 | let extendedError: String? | ||
| 1501 | let extendedCreditStatus: UsageCreditStatus? | ||
| 1502 | let onLoadExtended: (() -> Void)? | ||
| 1503 | |||
| 1504 | init( | ||
| 1505 | isCollapsed: Binding<Bool>, | ||
| 1506 | rows: [SubdomainRowViewData], | ||
| 1507 | groups: [SubdomainGroup], | ||
| 1508 | loading: Bool, | ||
| 1509 | error: String?, | ||
| 1510 | provenance: SectionProvenance?, | ||
| 1511 | confidence: ConfidenceLevel?, | ||
| 1512 | showsExtendedPlaceholder: Bool, | ||
| 1513 | extendedCount: Int = 0, | ||
| 1514 | extendedLoading: Bool = false, | ||
| 1515 | extendedError: String? = nil, | ||
| 1516 | extendedCreditStatus: UsageCreditStatus? = nil, | ||
| 1517 | onLoadExtended: (() -> Void)? = nil | ||
| 1518 | ) { | ||
| 1519 | _isCollapsed = isCollapsed | ||
| 1520 | self.rows = rows | ||
| 1521 | self.groups = groups | ||
| 1522 | self.loading = loading | ||
| 1523 | self.error = error | ||
| 1524 | self.provenance = provenance | ||
| 1525 | self.confidence = confidence | ||
| 1526 | self.showsExtendedPlaceholder = showsExtendedPlaceholder | ||
| 1527 | self.extendedCount = extendedCount | ||
| 1528 | self.extendedLoading = extendedLoading | ||
| 1529 | self.extendedError = extendedError | ||
| 1530 | self.extendedCreditStatus = extendedCreditStatus | ||
| 1531 | self.onLoadExtended = onLoadExtended | ||
| 1532 | } | ||
| 1365 | 1533 | ||
| 1366 | var body: some View { | 1534 | var body: some View { |
| 1367 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { | 1535 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { |
| @@ -1373,10 +1541,17 @@ struct SubdomainsSectionView: View { | |||
| 1373 | } else if rows.isEmpty { | 1541 | } else if rows.isEmpty { |
| 1374 | MessageRowView(text: error ?? "No passive subdomains found", isError: false) | 1542 | MessageRowView(text: error ?? "No passive subdomains found", isError: false) |
| 1375 | if showsExtendedPlaceholder { | 1543 | if showsExtendedPlaceholder { |
| 1376 | MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) | 1544 | MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) |
| 1377 | .padding(.top, 4) | 1545 | .padding(.top, 4) |
| 1378 | } | 1546 | } |
| 1379 | } else { | 1547 | } else { |
| 1548 | if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { | ||
| 1549 | Button("Load extended results (\(extendedCreditStatus.remaining) left)") { | ||
| 1550 | onLoadExtended() | ||
| 1551 | } | ||
| 1552 | .buttonStyle(.bordered) | ||
| 1553 | .font(appDensity.font(.caption2)) | ||
| 1554 | } | ||
| 1380 | if !groups.isEmpty { | 1555 | if !groups.isEmpty { |
| 1381 | Text("Groups") | 1556 | Text("Groups") |
| 1382 | .font(appDensity.font(.caption2)) | 1557 | .font(appDensity.font(.caption2)) |
| @@ -1411,8 +1586,18 @@ struct SubdomainsSectionView: View { | |||
| 1411 | } | 1586 | } |
| 1412 | } | 1587 | } |
| 1413 | } | 1588 | } |
| 1414 | if showsExtendedPlaceholder { | 1589 | if extendedLoading { |
| 1415 | MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) | 1590 | ProgressView("Loading extended subdomains…") |
| 1591 | .appLoadingStyle() | ||
| 1592 | .padding(.top, 4) | ||
| 1593 | } else if extendedCount > 0 { | ||
| 1594 | MessageRowView(text: "\(extendedCount) extended results included", isError: false) | ||
| 1595 | .padding(.top, 4) | ||
| 1596 | } else if let extendedError { | ||
| 1597 | MessageRowView(text: extendedError, isError: false) | ||
| 1598 | .padding(.top, 4) | ||
| 1599 | } else if showsExtendedPlaceholder { | ||
| 1600 | MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) | ||
| 1416 | .padding(.top, 4) | 1601 | .padding(.top, 4) |
| 1417 | } | 1602 | } |
| 1418 | } | 1603 | } |
| @@ -1422,6 +1607,7 @@ struct SubdomainsSectionView: View { | |||
| 1422 | } | 1607 | } |
| 1423 | 1608 | ||
| 1424 | struct DNSSectionView: View { | 1609 | struct DNSSectionView: View { |
| 1610 | @Environment(\.appDensity) private var appDensity | ||
| 1425 | @Binding var isCollapsed: Bool | 1611 | @Binding var isCollapsed: Bool |
| 1426 | let dnssecLabel: String? | 1612 | let dnssecLabel: String? |
| 1427 | let patternSummary: DNSPatternSummary? | 1613 | let patternSummary: DNSPatternSummary? |
| @@ -1431,6 +1617,46 @@ struct DNSSectionView: View { | |||
| 1431 | let dnsProvenance: SectionProvenance? | 1617 | let dnsProvenance: SectionProvenance? |
| 1432 | let ptrProvenance: SectionProvenance? | 1618 | let ptrProvenance: SectionProvenance? |
| 1433 | let sectionError: String? | 1619 | let sectionError: String? |
| 1620 | let history: [DNSHistoryEvent] | ||
| 1621 | let historyLoading: Bool | ||
| 1622 | let historyError: String? | ||
| 1623 | let showsHistoryPlaceholder: Bool | ||
| 1624 | let historyCreditStatus: UsageCreditStatus? | ||
| 1625 | let onLoadHistory: (() -> Void)? | ||
| 1626 | |||
| 1627 | init( | ||
| 1628 | isCollapsed: Binding<Bool>, | ||
| 1629 | dnssecLabel: String?, | ||
| 1630 | patternSummary: DNSPatternSummary?, | ||
| 1631 | sections: [DNSRecordSectionViewData], | ||
| 1632 | ptrMessage: SectionMessageViewData?, | ||
| 1633 | loading: Bool, | ||
| 1634 | dnsProvenance: SectionProvenance?, | ||
| 1635 | ptrProvenance: SectionProvenance?, | ||
| 1636 | sectionError: String?, | ||
| 1637 | history: [DNSHistoryEvent] = [], | ||
| 1638 | historyLoading: Bool = false, | ||
| 1639 | historyError: String? = nil, | ||
| 1640 | showsHistoryPlaceholder: Bool = false, | ||
| 1641 | historyCreditStatus: UsageCreditStatus? = nil, | ||
| 1642 | onLoadHistory: (() -> Void)? = nil | ||
| 1643 | ) { | ||
| 1644 | _isCollapsed = isCollapsed | ||
| 1645 | self.dnssecLabel = dnssecLabel | ||
| 1646 | self.patternSummary = patternSummary | ||
| 1647 | self.sections = sections | ||
| 1648 | self.ptrMessage = ptrMessage | ||
| 1649 | self.loading = loading | ||
| 1650 | self.dnsProvenance = dnsProvenance | ||
| 1651 | self.ptrProvenance = ptrProvenance | ||
| 1652 | self.sectionError = sectionError | ||
| 1653 | self.history = history | ||
| 1654 | self.historyLoading = historyLoading | ||
| 1655 | self.historyError = historyError | ||
| 1656 | self.showsHistoryPlaceholder = showsHistoryPlaceholder | ||
| 1657 | self.historyCreditStatus = historyCreditStatus | ||
| 1658 | self.onLoadHistory = onLoadHistory | ||
| 1659 | } | ||
| 1434 | 1660 | ||
| 1435 | var body: some View { | 1661 | var body: some View { |
| 1436 | CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { | 1662 | CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { |
| @@ -1491,6 +1717,50 @@ struct DNSSectionView: View { | |||
| 1491 | MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) | 1717 | MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) |
| 1492 | } | 1718 | } |
| 1493 | } | 1719 | } |
| 1720 | |||
| 1721 | CardView(allowsHorizontalScroll: false) { | ||
| 1722 | HStack { | ||
| 1723 | Text("History") | ||
| 1724 | .font(appDensity.font(.subheadline, weight: .semibold)) | ||
| 1725 | .foregroundStyle(.cyan) | ||
| 1726 | Spacer() | ||
| 1727 | if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { | ||
| 1728 | Button("Load (\(historyCreditStatus.remaining) left)") { | ||
| 1729 | onLoadHistory() | ||
| 1730 | } | ||
| 1731 | .buttonStyle(.bordered) | ||
| 1732 | .font(appDensity.font(.caption2)) | ||
| 1733 | } | ||
| 1734 | } | ||
| 1735 | if historyLoading { | ||
| 1736 | ProgressView("Loading DNS history…") | ||
| 1737 | .appLoadingStyle() | ||
| 1738 | } else if !history.isEmpty { | ||
| 1739 | ForEach(history) { event in | ||
| 1740 | VStack(alignment: .leading, spacing: 3) { | ||
| 1741 | Text(event.date.formatted(date: .abbreviated, time: .omitted)) | ||
| 1742 | .font(appDensity.font(.caption2)) | ||
| 1743 | .foregroundStyle(.secondary) | ||
| 1744 | Text(event.summary) | ||
| 1745 | .font(appDensity.font(.caption)) | ||
| 1746 | if !event.aRecords.isEmpty { | ||
| 1747 | Text("A: \(event.aRecords.joined(separator: ", "))") | ||
| 1748 | .font(appDensity.font(.caption2)) | ||
| 1749 | .foregroundStyle(.secondary) | ||
| 1750 | } | ||
| 1751 | if !event.nameservers.isEmpty { | ||
| 1752 | Text("NS: \(event.nameservers.joined(separator: ", "))") | ||
| 1753 | .font(appDensity.font(.caption2)) | ||
| 1754 | .foregroundStyle(.secondary) | ||
| 1755 | } | ||
| 1756 | } | ||
| 1757 | } | ||
| 1758 | } else if let historyError { | ||
| 1759 | MessageRowView(text: historyError, isError: false) | ||
| 1760 | } else if showsHistoryPlaceholder { | ||
| 1761 | MessageRowView(text: "DNS history available in Data+", isError: false) | ||
| 1762 | } | ||
| 1763 | } | ||
| 1494 | } | 1764 | } |
| 1495 | } | 1765 | } |
| 1496 | } | 1766 | } |
| @@ -2220,11 +2490,11 @@ struct SettingsView: View { | |||
| 2220 | } | 2490 | } |
| 2221 | } | 2491 | } |
| 2222 | 2492 | ||
| 2223 | Section("Pro") { | 2493 | Section("Tier") { |
| 2224 | LabeledContent("Status", value: purchaseService.currentTier.title) | 2494 | LabeledContent("Status", value: purchaseService.currentTier.title) |
| 2225 | 2495 | ||
| 2226 | if purchaseService.currentTier == .free { | 2496 | if purchaseService.currentTier == .free { |
| 2227 | Button("Upgrade to Pro") { | 2497 | Button("Upgrade") { |
| 2228 | viewModel.isPaywallPresented = true | 2498 | viewModel.isPaywallPresented = true |
| 2229 | } | 2499 | } |
| 2230 | } else { | 2500 | } else { |
| @@ -2255,6 +2525,23 @@ struct SettingsView: View { | |||
| 2255 | } | 2525 | } |
| 2256 | } | 2526 | } |
| 2257 | 2527 | ||
| 2528 | Section("Data+ Usage") { | ||
| 2529 | ForEach(UsageCreditFeature.allCases) { feature in | ||
| 2530 | let status = viewModel.usageCredits[feature] ?? UsageCreditStatus( | ||
| 2531 | feature: feature, | ||
| 2532 | remaining: feature.defaultAllowance, | ||
| 2533 | total: feature.defaultAllowance, | ||
| 2534 | resetContext: "Resets with app version \(AppVersion.current)" | ||
| 2535 | ) | ||
| 2536 | VStack(alignment: .leading, spacing: 2) { | ||
| 2537 | LabeledContent(feature.title, value: status.summary) | ||
| 2538 | Text(status.resetContext) | ||
| 2539 | .font(appDensity.font(.caption, design: .default)) | ||
| 2540 | .foregroundStyle(.secondary) | ||
| 2541 | } | ||
| 2542 | } | ||
| 2543 | } | ||
| 2544 | |||
| 2258 | Section("Features") { | 2545 | Section("Features") { |
| 2259 | ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in | 2546 | ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in |
| 2260 | Text(label) | 2547 | Text(label) |
| @@ -2264,7 +2551,7 @@ struct SettingsView: View { | |||
| 2264 | Section("About") { | 2551 | Section("About") { |
| 2265 | LabeledContent("Version", value: appVersion) | 2552 | LabeledContent("Version", value: appVersion) |
| 2266 | LabeledContent("Storage", value: "Local-only") | 2553 | LabeledContent("Storage", value: "Local-only") |
| 2267 | LabeledContent("Report Schema", value: "3.1.0") | 2554 | LabeledContent("Report Schema", value: "3.2.0") |
| 2268 | } | 2555 | } |
| 2269 | } | 2556 | } |
| 2270 | .navigationTitle("Settings") | 2557 | .navigationTitle("Settings") |
| @@ -2304,6 +2591,9 @@ struct SettingsView: View { | |||
| 2304 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) | 2591 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 2305 | resolverOption = DNSResolverOption.option(for: currentResolverURL) | 2592 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
| 2306 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | 2593 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString |
| 2594 | Task { | ||
| 2595 | await viewModel.refreshUsageCredits() | ||
| 2596 | } | ||
| 2307 | } | 2597 | } |
| 2308 | .onChange(of: resolverOption) { _, newValue in | 2598 | .onChange(of: resolverOption) { _, newValue in |
| 2309 | guard let presetURL = newValue.urlString else { | 2599 | guard let presetURL = newValue.urlString else { |
DomainDig/DataAccessService.swift +1 −1
| @@ -10,7 +10,7 @@ enum DataAccessService { | |||
| 10 | case .extendedSubdomains: | 10 | case .extendedSubdomains: |
| 11 | return FeatureAccessService.hasAccess(to: .extendedSubdomains) | 11 | return FeatureAccessService.hasAccess(to: .extendedSubdomains) |
| 12 | case .domainPricing: | 12 | case .domainPricing: |
| 13 | return false | 13 | return FeatureAccessService.hasAccess(to: .domainPricing) |
| 14 | } | 14 | } |
| 15 | } | 15 | } |
| 16 | } | 16 | } |
DomainDig/DomainViewModel.swift +336 −2
| @@ -147,6 +147,9 @@ final class DomainViewModel { | |||
| 147 | var ownershipResult: DomainOwnership? | 147 | var ownershipResult: DomainOwnership? |
| 148 | var ownershipLoading = false | 148 | var ownershipLoading = false |
| 149 | var ownershipError: String? | 149 | var ownershipError: String? |
| 150 | var ownershipHistory: [DomainOwnershipHistoryEvent] = [] | ||
| 151 | var ownershipHistoryLoading = false | ||
| 152 | var ownershipHistoryError: String? | ||
| 150 | 153 | ||
| 151 | var ptrRecord: String? | 154 | var ptrRecord: String? |
| 152 | var ptrLoading = false | 155 | var ptrLoading = false |
| @@ -159,6 +162,16 @@ final class DomainViewModel { | |||
| 159 | var subdomains: [DiscoveredSubdomain] = [] | 162 | var subdomains: [DiscoveredSubdomain] = [] |
| 160 | var subdomainsLoading = false | 163 | var subdomainsLoading = false |
| 161 | var subdomainsError: String? | 164 | var subdomainsError: String? |
| 165 | var extendedSubdomains: [DiscoveredSubdomain] = [] | ||
| 166 | var extendedSubdomainsLoading = false | ||
| 167 | var extendedSubdomainsError: String? | ||
| 168 | var dnsHistory: [DNSHistoryEvent] = [] | ||
| 169 | var dnsHistoryLoading = false | ||
| 170 | var dnsHistoryError: String? | ||
| 171 | var domainPricing: DomainPricingInsight? | ||
| 172 | var domainPricingLoading = false | ||
| 173 | var domainPricingError: String? | ||
| 174 | var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits() | ||
| 162 | 175 | ||
| 163 | var portScanResults: [PortScanResult] = [] | 176 | var portScanResults: [PortScanResult] = [] |
| 164 | var portScanLoading = false | 177 | var portScanLoading = false |
| @@ -263,8 +276,12 @@ final class DomainViewModel { | |||
| 263 | if sslLoading || hstsLoading { labels.append("TLS") } | 276 | if sslLoading || hstsLoading { labels.append("TLS") } |
| 264 | if httpHeadersLoading { labels.append("HTTP") } | 277 | if httpHeadersLoading { labels.append("HTTP") } |
| 265 | if ownershipLoading { labels.append("Ownership") } | 278 | if ownershipLoading { labels.append("Ownership") } |
| 279 | if ownershipHistoryLoading { labels.append("Ownership History") } | ||
| 266 | if emailSecurityLoading { labels.append("Email") } | 280 | if emailSecurityLoading { labels.append("Email") } |
| 267 | if subdomainsLoading { labels.append("Subdomains") } | 281 | if subdomainsLoading { labels.append("Subdomains") } |
| 282 | if extendedSubdomainsLoading { labels.append("Extended Subdomains") } | ||
| 283 | if dnsHistoryLoading { labels.append("DNS History") } | ||
| 284 | if domainPricingLoading { labels.append("Pricing") } | ||
| 268 | if redirectChainLoading { labels.append("Redirects") } | 285 | if redirectChainLoading { labels.append("Redirects") } |
| 269 | if reachabilityLoading { labels.append("Reachability") } | 286 | if reachabilityLoading { labels.append("Reachability") } |
| 270 | if ipGeolocationLoading { labels.append("Geolocation") } | 287 | if ipGeolocationLoading { labels.append("Geolocation") } |
| @@ -438,12 +455,20 @@ final class DomainViewModel { | |||
| 438 | emailSecurityError: emailSecurityError, | 455 | emailSecurityError: emailSecurityError, |
| 439 | ownership: ownershipResult, | 456 | ownership: ownershipResult, |
| 440 | ownershipError: ownershipError, | 457 | ownershipError: ownershipError, |
| 458 | ownershipHistory: ownershipHistory, | ||
| 459 | ownershipHistoryError: ownershipHistoryError, | ||
| 441 | ptrRecord: ptrRecord, | 460 | ptrRecord: ptrRecord, |
| 442 | ptrError: ptrError, | 461 | ptrError: ptrError, |
| 443 | redirectChain: redirectChain, | 462 | redirectChain: redirectChain, |
| 444 | redirectChainError: redirectChainError, | 463 | redirectChainError: redirectChainError, |
| 445 | subdomains: subdomains, | 464 | subdomains: subdomains, |
| 446 | subdomainsError: subdomainsError, | 465 | subdomainsError: subdomainsError, |
| 466 | extendedSubdomains: extendedSubdomains, | ||
| 467 | extendedSubdomainsError: extendedSubdomainsError, | ||
| 468 | dnsHistory: dnsHistory, | ||
| 469 | dnsHistoryError: dnsHistoryError, | ||
| 470 | domainPricing: domainPricing, | ||
| 471 | domainPricingError: domainPricingError, | ||
| 447 | portScanResults: allPortScanResults, | 472 | portScanResults: allPortScanResults, |
| 448 | portScanError: combinedPortScanError, | 473 | portScanError: combinedPortScanError, |
| 449 | changeSummary: currentChangeSummary, | 474 | changeSummary: currentChangeSummary, |
| @@ -482,6 +507,23 @@ final class DomainViewModel { | |||
| 482 | currentReport?.web | 507 | currentReport?.web |
| 483 | } | 508 | } |
| 484 | 509 | ||
| 510 | var ownershipHistoryCreditStatus: UsageCreditStatus { | ||
| 511 | usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory) | ||
| 512 | } | ||
| 513 | |||
| 514 | var dnsHistoryCreditStatus: UsageCreditStatus { | ||
| 515 | usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory) | ||
| 516 | } | ||
| 517 | |||
| 518 | var extendedSubdomainsCreditStatus: UsageCreditStatus { | ||
| 519 | usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains) | ||
| 520 | } | ||
| 521 | |||
| 522 | var combinedSubdomains: [DiscoveredSubdomain] { | ||
| 523 | let existingHosts = Set(subdomains.map { $0.hostname.lowercased() }) | ||
| 524 | return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) } | ||
| 525 | } | ||
| 526 | |||
| 485 | var summaryFields: [SummaryFieldViewData] { | 527 | var summaryFields: [SummaryFieldViewData] { |
| 486 | Self.summaryFields(from: currentSnapshot) | 528 | Self.summaryFields(from: currentSnapshot) |
| 487 | } | 529 | } |
| @@ -527,7 +569,7 @@ final class DomainViewModel { | |||
| 527 | } | 569 | } |
| 528 | 570 | ||
| 529 | var subdomainRows: [SubdomainRowViewData] { | 571 | var subdomainRows: [SubdomainRowViewData] { |
| 530 | Self.subdomainRows(from: currentSnapshot) | 572 | Self.subdomainRows(from: combinedSubdomains) |
| 531 | } | 573 | } |
| 532 | 574 | ||
| 533 | var reachabilityRows: [ReachabilityRowViewData] { | 575 | var reachabilityRows: [ReachabilityRowViewData] { |
| @@ -934,6 +976,151 @@ final class DomainViewModel { | |||
| 934 | return String(data: data, encoding: .utf8) | 976 | return String(data: data, encoding: .utf8) |
| 935 | } | 977 | } |
| 936 | 978 | ||
| 979 | func loadOwnershipHistory() async { | ||
| 980 | guard !searchedDomain.isEmpty else { return } | ||
| 981 | guard DataAccessService.hasAccess(to: .ownershipHistory) else { | ||
| 982 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory) | ||
| 983 | return | ||
| 984 | } | ||
| 985 | guard ownershipHistory.isEmpty else { return } | ||
| 986 | |||
| 987 | let creditStatus = await UsageCreditService.shared.status(for: .ownershipHistory) | ||
| 988 | guard !creditStatus.isExhausted else { | ||
| 989 | ownershipHistoryError = "No ownership history credits remaining" | ||
| 990 | await refreshUsageCredits() | ||
| 991 | return | ||
| 992 | } | ||
| 993 | |||
| 994 | ownershipHistoryLoading = true | ||
| 995 | ownershipHistoryError = nil | ||
| 996 | |||
| 997 | let outcome = await ExternalDataService.shared.ownershipHistory( | ||
| 998 | domain: searchedDomain, | ||
| 999 | currentOwnership: ownershipResult, | ||
| 1000 | historyEntries: history | ||
| 1001 | ) | ||
| 1002 | |||
| 1003 | switch outcome.value { | ||
| 1004 | case let .success(events): | ||
| 1005 | ownershipHistory = events | ||
| 1006 | ownershipHistoryError = nil | ||
| 1007 | if outcome.source != .cached { | ||
| 1008 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | ||
| 1009 | } | ||
| 1010 | case let .empty(message): | ||
| 1011 | ownershipHistory = [] | ||
| 1012 | ownershipHistoryError = message | ||
| 1013 | if outcome.source != .cached { | ||
| 1014 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | ||
| 1015 | } | ||
| 1016 | case let .error(message): | ||
| 1017 | ownershipHistory = [] | ||
| 1018 | ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable") | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | ownershipHistoryLoading = false | ||
| 1022 | _ = saveHistoryEntry(replaceLatest: true) | ||
| 1023 | await refreshUsageCredits() | ||
| 1024 | } | ||
| 1025 | |||
| 1026 | func loadDNSHistory() async { | ||
| 1027 | guard !searchedDomain.isEmpty else { return } | ||
| 1028 | guard DataAccessService.hasAccess(to: .dnsHistory) else { | ||
| 1029 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory) | ||
| 1030 | return | ||
| 1031 | } | ||
| 1032 | guard dnsHistory.isEmpty else { return } | ||
| 1033 | |||
| 1034 | let creditStatus = await UsageCreditService.shared.status(for: .dnsHistory) | ||
| 1035 | guard !creditStatus.isExhausted else { | ||
| 1036 | dnsHistoryError = "No DNS history credits remaining" | ||
| 1037 | await refreshUsageCredits() | ||
| 1038 | return | ||
| 1039 | } | ||
| 1040 | |||
| 1041 | dnsHistoryLoading = true | ||
| 1042 | dnsHistoryError = nil | ||
| 1043 | |||
| 1044 | let outcome = await ExternalDataService.shared.dnsHistory( | ||
| 1045 | domain: searchedDomain, | ||
| 1046 | dnsSections: dnsSections, | ||
| 1047 | historyEntries: history | ||
| 1048 | ) | ||
| 1049 | |||
| 1050 | switch outcome.value { | ||
| 1051 | case let .success(events): | ||
| 1052 | dnsHistory = events | ||
| 1053 | dnsHistoryError = nil | ||
| 1054 | if outcome.source != .cached { | ||
| 1055 | _ = await UsageCreditService.shared.consume(.dnsHistory) | ||
| 1056 | } | ||
| 1057 | case let .empty(message): | ||
| 1058 | dnsHistory = [] | ||
| 1059 | dnsHistoryError = message | ||
| 1060 | if outcome.source != .cached { | ||
| 1061 | _ = await UsageCreditService.shared.consume(.dnsHistory) | ||
| 1062 | } | ||
| 1063 | case let .error(message): | ||
| 1064 | dnsHistory = [] | ||
| 1065 | dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable") | ||
| 1066 | } | ||
| 1067 | |||
| 1068 | dnsHistoryLoading = false | ||
| 1069 | _ = saveHistoryEntry(replaceLatest: true) | ||
| 1070 | await refreshUsageCredits() | ||
| 1071 | } | ||
| 1072 | |||
| 1073 | func loadExtendedSubdomains() async { | ||
| 1074 | guard !searchedDomain.isEmpty else { return } | ||
| 1075 | guard DataAccessService.hasAccess(to: .extendedSubdomains) else { | ||
| 1076 | upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains) | ||
| 1077 | return | ||
| 1078 | } | ||
| 1079 | guard extendedSubdomains.isEmpty else { return } | ||
| 1080 | |||
| 1081 | let creditStatus = await UsageCreditService.shared.status(for: .extendedSubdomains) | ||
| 1082 | guard !creditStatus.isExhausted else { | ||
| 1083 | extendedSubdomainsError = "No extended subdomain credits remaining" | ||
| 1084 | await refreshUsageCredits() | ||
| 1085 | return | ||
| 1086 | } | ||
| 1087 | |||
| 1088 | extendedSubdomainsLoading = true | ||
| 1089 | extendedSubdomainsError = nil | ||
| 1090 | |||
| 1091 | let outcome = await ExternalDataService.shared.extendedSubdomains( | ||
| 1092 | domain: searchedDomain, | ||
| 1093 | existing: subdomains | ||
| 1094 | ) | ||
| 1095 | |||
| 1096 | switch outcome.value { | ||
| 1097 | case let .success(results): | ||
| 1098 | extendedSubdomains = results | ||
| 1099 | extendedSubdomainsError = nil | ||
| 1100 | if outcome.source != .cached { | ||
| 1101 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | ||
| 1102 | } | ||
| 1103 | case let .empty(message): | ||
| 1104 | extendedSubdomains = [] | ||
| 1105 | extendedSubdomainsError = message | ||
| 1106 | if outcome.source != .cached { | ||
| 1107 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | ||
| 1108 | } | ||
| 1109 | case let .error(message): | ||
| 1110 | extendedSubdomains = [] | ||
| 1111 | extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable") | ||
| 1112 | } | ||
| 1113 | |||
| 1114 | extendedSubdomainsLoading = false | ||
| 1115 | _ = saveHistoryEntry(replaceLatest: true) | ||
| 1116 | await refreshUsageCredits() | ||
| 1117 | } | ||
| 1118 | |||
| 1119 | func refreshUsageCredits() async { | ||
| 1120 | let statuses = await UsageCreditService.shared.allStatuses() | ||
| 1121 | usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) | ||
| 1122 | } | ||
| 1123 | |||
| 937 | func exportBatchText() -> String { | 1124 | func exportBatchText() -> String { |
| 938 | DomainReportExporter.batchText( | 1125 | DomainReportExporter.batchText( |
| 939 | for: currentBatchReports(), | 1126 | for: currentBatchReports(), |
| @@ -1019,11 +1206,18 @@ final class DomainViewModel { | |||
| 1019 | lastLookupDurationMs = snapshot.totalLookupDurationMs | 1206 | lastLookupDurationMs = snapshot.totalLookupDurationMs |
| 1020 | refreshingTrackedDomainID = nil | 1207 | refreshingTrackedDomainID = nil |
| 1021 | 1208 | ||
| 1209 | if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil { | ||
| 1210 | await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) | ||
| 1211 | } | ||
| 1212 | |||
| 1022 | guard snapshot.statusMessage == nil else { | 1213 | guard snapshot.statusMessage == nil else { |
| 1214 | await refreshUsageCredits() | ||
| 1023 | return history.first(where: { $0.id == snapshot.historyEntryID }) | 1215 | return history.first(where: { $0.id == snapshot.historyEntryID }) |
| 1024 | } | 1216 | } |
| 1025 | 1217 | ||
| 1026 | return saveHistoryEntry(replaceLatest: false) | 1218 | let entry = saveHistoryEntry(replaceLatest: false) |
| 1219 | await refreshUsageCredits() | ||
| 1220 | return entry | ||
| 1027 | } | 1221 | } |
| 1028 | 1222 | ||
| 1029 | private func applySnapshot(_ snapshot: LookupSnapshot) { | 1223 | private func applySnapshot(_ snapshot: LookupSnapshot) { |
| @@ -1066,12 +1260,20 @@ final class DomainViewModel { | |||
| 1066 | emailSecurityError = snapshot.emailSecurityError | 1260 | emailSecurityError = snapshot.emailSecurityError |
| 1067 | ownershipResult = snapshot.ownership | 1261 | ownershipResult = snapshot.ownership |
| 1068 | ownershipError = snapshot.ownershipError | 1262 | ownershipError = snapshot.ownershipError |
| 1263 | ownershipHistory = snapshot.ownershipHistory | ||
| 1264 | ownershipHistoryError = snapshot.ownershipHistoryError | ||
| 1069 | ptrRecord = snapshot.ptrRecord | 1265 | ptrRecord = snapshot.ptrRecord |
| 1070 | ptrError = snapshot.ptrError | 1266 | ptrError = snapshot.ptrError |
| 1071 | redirectChain = snapshot.redirectChain | 1267 | redirectChain = snapshot.redirectChain |
| 1072 | redirectChainError = snapshot.redirectChainError | 1268 | redirectChainError = snapshot.redirectChainError |
| 1073 | subdomains = snapshot.subdomains | 1269 | subdomains = snapshot.subdomains |
| 1074 | subdomainsError = snapshot.subdomainsError | 1270 | subdomainsError = snapshot.subdomainsError |
| 1271 | extendedSubdomains = snapshot.extendedSubdomains | ||
| 1272 | extendedSubdomainsError = snapshot.extendedSubdomainsError | ||
| 1273 | dnsHistory = snapshot.dnsHistory | ||
| 1274 | dnsHistoryError = snapshot.dnsHistoryError | ||
| 1275 | domainPricing = snapshot.domainPricing | ||
| 1276 | domainPricingError = snapshot.domainPricingError | ||
| 1075 | portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } | 1277 | portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } |
| 1076 | customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } | 1278 | customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } |
| 1077 | portScanError = snapshot.portScanError | 1279 | portScanError = snapshot.portScanError |
| @@ -1087,9 +1289,13 @@ final class DomainViewModel { | |||
| 1087 | ipGeolocationLoading = false | 1289 | ipGeolocationLoading = false |
| 1088 | emailSecurityLoading = false | 1290 | emailSecurityLoading = false |
| 1089 | ownershipLoading = false | 1291 | ownershipLoading = false |
| 1292 | ownershipHistoryLoading = false | ||
| 1090 | ptrLoading = false | 1293 | ptrLoading = false |
| 1091 | redirectChainLoading = false | 1294 | redirectChainLoading = false |
| 1092 | subdomainsLoading = false | 1295 | subdomainsLoading = false |
| 1296 | extendedSubdomainsLoading = false | ||
| 1297 | dnsHistoryLoading = false | ||
| 1298 | domainPricingLoading = false | ||
| 1093 | portScanLoading = false | 1299 | portScanLoading = false |
| 1094 | customPortScanLoading = false | 1300 | customPortScanLoading = false |
| 1095 | } | 1301 | } |
| @@ -1144,12 +1350,20 @@ final class DomainViewModel { | |||
| 1144 | emailSecurityError: previousSnapshot.emailSecurityError, | 1350 | emailSecurityError: previousSnapshot.emailSecurityError, |
| 1145 | ownership: previousSnapshot.ownership, | 1351 | ownership: previousSnapshot.ownership, |
| 1146 | ownershipError: previousSnapshot.ownershipError, | 1352 | ownershipError: previousSnapshot.ownershipError, |
| 1353 | ownershipHistory: previousSnapshot.ownershipHistory, | ||
| 1354 | ownershipHistoryError: previousSnapshot.ownershipHistoryError, | ||
| 1147 | ptrRecord: previousSnapshot.ptrRecord, | 1355 | ptrRecord: previousSnapshot.ptrRecord, |
| 1148 | ptrError: previousSnapshot.ptrError, | 1356 | ptrError: previousSnapshot.ptrError, |
| 1149 | redirectChain: previousSnapshot.redirectChain, | 1357 | redirectChain: previousSnapshot.redirectChain, |
| 1150 | redirectChainError: previousSnapshot.redirectChainError, | 1358 | redirectChainError: previousSnapshot.redirectChainError, |
| 1151 | subdomains: previousSnapshot.subdomains, | 1359 | subdomains: previousSnapshot.subdomains, |
| 1152 | subdomainsError: previousSnapshot.subdomainsError, | 1360 | subdomainsError: previousSnapshot.subdomainsError, |
| 1361 | extendedSubdomains: previousSnapshot.extendedSubdomains, | ||
| 1362 | extendedSubdomainsError: previousSnapshot.extendedSubdomainsError, | ||
| 1363 | dnsHistory: previousSnapshot.dnsHistory, | ||
| 1364 | dnsHistoryError: previousSnapshot.dnsHistoryError, | ||
| 1365 | domainPricing: previousSnapshot.domainPricing, | ||
| 1366 | domainPricingError: previousSnapshot.domainPricingError, | ||
| 1153 | portScanResults: previousSnapshot.portScanResults, | 1367 | portScanResults: previousSnapshot.portScanResults, |
| 1154 | portScanError: previousSnapshot.portScanError, | 1368 | portScanError: previousSnapshot.portScanError, |
| 1155 | changeSummary: previousSnapshot.changeSummary, | 1369 | changeSummary: previousSnapshot.changeSummary, |
| @@ -1515,9 +1729,13 @@ final class DomainViewModel { | |||
| 1515 | emailSecurity: snapshot.emailSecurity, | 1729 | emailSecurity: snapshot.emailSecurity, |
| 1516 | mtaSts: snapshot.emailSecurity?.mtaSts, | 1730 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 1517 | ownership: snapshot.ownership, | 1731 | ownership: snapshot.ownership, |
| 1732 | ownershipHistory: snapshot.ownershipHistory, | ||
| 1518 | ptrRecord: snapshot.ptrRecord, | 1733 | ptrRecord: snapshot.ptrRecord, |
| 1519 | redirectChain: snapshot.redirectChain, | 1734 | redirectChain: snapshot.redirectChain, |
| 1520 | subdomains: snapshot.subdomains, | 1735 | subdomains: snapshot.subdomains, |
| 1736 | extendedSubdomains: snapshot.extendedSubdomains, | ||
| 1737 | dnsHistory: snapshot.dnsHistory, | ||
| 1738 | domainPricing: snapshot.domainPricing, | ||
| 1521 | portScanResults: snapshot.portScanResults, | 1739 | portScanResults: snapshot.portScanResults, |
| 1522 | hstsPreloaded: snapshot.hstsPreloaded, | 1740 | hstsPreloaded: snapshot.hstsPreloaded, |
| 1523 | availabilityResult: snapshot.availabilityResult, | 1741 | availabilityResult: snapshot.availabilityResult, |
| @@ -1549,9 +1767,13 @@ final class DomainViewModel { | |||
| 1549 | ipGeolocationError: snapshot.ipGeolocationError, | 1767 | ipGeolocationError: snapshot.ipGeolocationError, |
| 1550 | emailSecurityError: snapshot.emailSecurityError, | 1768 | emailSecurityError: snapshot.emailSecurityError, |
| 1551 | ownershipError: snapshot.ownershipError, | 1769 | ownershipError: snapshot.ownershipError, |
| 1770 | ownershipHistoryError: snapshot.ownershipHistoryError, | ||
| 1552 | ptrError: snapshot.ptrError, | 1771 | ptrError: snapshot.ptrError, |
| 1553 | redirectChainError: snapshot.redirectChainError, | 1772 | redirectChainError: snapshot.redirectChainError, |
| 1554 | subdomainsError: snapshot.subdomainsError, | 1773 | subdomainsError: snapshot.subdomainsError, |
| 1774 | extendedSubdomainsError: snapshot.extendedSubdomainsError, | ||
| 1775 | dnsHistoryError: snapshot.dnsHistoryError, | ||
| 1776 | domainPricingError: snapshot.domainPricingError, | ||
| 1555 | portScanError: snapshot.portScanError | 1777 | portScanError: snapshot.portScanError |
| 1556 | ) | 1778 | ) |
| 1557 | 1779 | ||
| @@ -2038,6 +2260,9 @@ final class DomainViewModel { | |||
| 2038 | ownershipResult = nil | 2260 | ownershipResult = nil |
| 2039 | ownershipError = nil | 2261 | ownershipError = nil |
| 2040 | ownershipLoading = false | 2262 | ownershipLoading = false |
| 2263 | ownershipHistory = [] | ||
| 2264 | ownershipHistoryError = nil | ||
| 2265 | ownershipHistoryLoading = false | ||
| 2041 | ptrRecord = nil | 2266 | ptrRecord = nil |
| 2042 | ptrError = nil | 2267 | ptrError = nil |
| 2043 | ptrLoading = false | 2268 | ptrLoading = false |
| @@ -2047,6 +2272,15 @@ final class DomainViewModel { | |||
| 2047 | subdomains = [] | 2272 | subdomains = [] |
| 2048 | subdomainsError = nil | 2273 | subdomainsError = nil |
| 2049 | subdomainsLoading = false | 2274 | subdomainsLoading = false |
| 2275 | extendedSubdomains = [] | ||
| 2276 | extendedSubdomainsError = nil | ||
| 2277 | extendedSubdomainsLoading = false | ||
| 2278 | dnsHistory = [] | ||
| 2279 | dnsHistoryError = nil | ||
| 2280 | dnsHistoryLoading = false | ||
| 2281 | domainPricing = nil | ||
| 2282 | domainPricingError = nil | ||
| 2283 | domainPricingLoading = false | ||
| 2050 | portScanResults = [] | 2284 | portScanResults = [] |
| 2051 | portScanError = nil | 2285 | portScanError = nil |
| 2052 | portScanLoading = false | 2286 | portScanLoading = false |
| @@ -2072,9 +2306,13 @@ final class DomainViewModel { | |||
| 2072 | ipGeolocationLoading = loading | 2306 | ipGeolocationLoading = loading |
| 2073 | emailSecurityLoading = loading | 2307 | emailSecurityLoading = loading |
| 2074 | ownershipLoading = loading | 2308 | ownershipLoading = loading |
| 2309 | ownershipHistoryLoading = false | ||
| 2075 | ptrLoading = loading | 2310 | ptrLoading = loading |
| 2076 | redirectChainLoading = loading | 2311 | redirectChainLoading = loading |
| 2077 | subdomainsLoading = loading | 2312 | subdomainsLoading = loading |
| 2313 | extendedSubdomainsLoading = false | ||
| 2314 | dnsHistoryLoading = false | ||
| 2315 | domainPricingLoading = false | ||
| 2078 | portScanLoading = loading | 2316 | portScanLoading = loading |
| 2079 | } | 2317 | } |
| 2080 | 2318 | ||
| @@ -2248,12 +2486,20 @@ final class DomainViewModel { | |||
| 2248 | emailSecurityError: nil, | 2486 | emailSecurityError: nil, |
| 2249 | ownership: nil, | 2487 | ownership: nil, |
| 2250 | ownershipError: nil, | 2488 | ownershipError: nil, |
| 2489 | ownershipHistory: [], | ||
| 2490 | ownershipHistoryError: nil, | ||
| 2251 | ptrRecord: nil, | 2491 | ptrRecord: nil, |
| 2252 | ptrError: nil, | 2492 | ptrError: nil, |
| 2253 | redirectChain: [], | 2493 | redirectChain: [], |
| 2254 | redirectChainError: nil, | 2494 | redirectChainError: nil, |
| 2255 | subdomains: [], | 2495 | subdomains: [], |
| 2256 | subdomainsError: nil, | 2496 | subdomainsError: nil, |
| 2497 | extendedSubdomains: [], | ||
| 2498 | extendedSubdomainsError: nil, | ||
| 2499 | dnsHistory: [], | ||
| 2500 | dnsHistoryError: nil, | ||
| 2501 | domainPricing: nil, | ||
| 2502 | domainPricingError: nil, | ||
| 2257 | portScanResults: [], | 2503 | portScanResults: [], |
| 2258 | portScanError: nil, | 2504 | portScanError: nil, |
| 2259 | changeSummary: trackedDomain.lastChangeSummary, | 2505 | changeSummary: trackedDomain.lastChangeSummary, |
| @@ -2435,6 +2681,30 @@ final class DomainViewModel { | |||
| 2435 | at: 3 | 2681 | at: 3 |
| 2436 | ) | 2682 | ) |
| 2437 | } | 2683 | } |
| 2684 | if let pricing = snapshot.domainPricing { | ||
| 2685 | rows.append( | ||
| 2686 | InfoRowViewData( | ||
| 2687 | label: "External Price", | ||
| 2688 | value: pricing.estimatedPrice ?? "Unavailable", | ||
| 2689 | tone: .secondary | ||
| 2690 | ) | ||
| 2691 | ) | ||
| 2692 | if let premiumIndicator = pricing.premiumIndicator { | ||
| 2693 | rows.append( | ||
| 2694 | InfoRowViewData( | ||
| 2695 | label: "Premium", | ||
| 2696 | value: premiumIndicator ? "Yes" : "No", | ||
| 2697 | tone: premiumIndicator ? .warning : .secondary | ||
| 2698 | ) | ||
| 2699 | ) | ||
| 2700 | } | ||
| 2701 | if let resaleSignal = pricing.resaleSignal { | ||
| 2702 | rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary)) | ||
| 2703 | } | ||
| 2704 | if let auctionSignal = pricing.auctionSignal { | ||
| 2705 | rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary)) | ||
| 2706 | } | ||
| 2707 | } | ||
| 2438 | if let certificateStatus = certificateBadgeLabel(from: snapshot) { | 2708 | if let certificateStatus = certificateBadgeLabel(from: snapshot) { |
| 2439 | rows.insert( | 2709 | rows.insert( |
| 2440 | InfoRowViewData( | 2710 | InfoRowViewData( |
| @@ -2460,6 +2730,15 @@ final class DomainViewModel { | |||
| 2460 | } | 2730 | } |
| 2461 | } | 2731 | } |
| 2462 | 2732 | ||
| 2733 | static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] { | ||
| 2734 | subdomains.map { subdomain in | ||
| 2735 | SubdomainRowViewData( | ||
| 2736 | hostname: subdomain.hostname, | ||
| 2737 | isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname) | ||
| 2738 | ) | ||
| 2739 | } | ||
| 2740 | } | ||
| 2741 | |||
| 2463 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { | 2742 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { |
| 2464 | snapshot.dnsSections.map { section in | 2743 | snapshot.dnsSections.map { section in |
| 2465 | DNSRecordSectionViewData( | 2744 | DNSRecordSectionViewData( |
| @@ -3098,6 +3377,61 @@ final class DomainViewModel { | |||
| 3098 | return formatter | 3377 | return formatter |
| 3099 | }() | 3378 | }() |
| 3100 | 3379 | ||
| 3380 | private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async { | ||
| 3381 | domainPricingLoading = true | ||
| 3382 | let outcome = await ExternalDataService.shared.pricing(domain: domain) | ||
| 3383 | |||
| 3384 | switch outcome.value { | ||
| 3385 | case let .success(pricing): | ||
| 3386 | domainPricing = pricing | ||
| 3387 | domainPricingError = nil | ||
| 3388 | case let .empty(message): | ||
| 3389 | domainPricing = nil | ||
| 3390 | domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable") | ||
| 3391 | case let .error(message): | ||
| 3392 | domainPricing = nil | ||
| 3393 | domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable") | ||
| 3394 | } | ||
| 3395 | |||
| 3396 | domainPricingLoading = false | ||
| 3397 | |||
| 3398 | if persistAfterFetch { | ||
| 3399 | _ = saveHistoryEntry(replaceLatest: true) | ||
| 3400 | } | ||
| 3401 | } | ||
| 3402 | |||
| 3403 | private func conciseExternalMessage(_ message: String, fallback: String) -> String { | ||
| 3404 | let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 3405 | if trimmed.isEmpty { | ||
| 3406 | return fallback | ||
| 3407 | } | ||
| 3408 | if trimmed.localizedCaseInsensitiveContains("rate") { | ||
| 3409 | return "Rate limited. Try again later." | ||
| 3410 | } | ||
| 3411 | if trimmed.localizedCaseInsensitiveContains("invalid") { | ||
| 3412 | return "External data was invalid." | ||
| 3413 | } | ||
| 3414 | if trimmed.localizedCaseInsensitiveContains("network") { | ||
| 3415 | return "External data is offline." | ||
| 3416 | } | ||
| 3417 | return trimmed | ||
| 3418 | } | ||
| 3419 | |||
| 3420 | private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] { | ||
| 3421 | Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in | ||
| 3422 | (feature, fallbackCreditStatus(for: feature)) | ||
| 3423 | }) | ||
| 3424 | } | ||
| 3425 | |||
| 3426 | private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus { | ||
| 3427 | UsageCreditStatus( | ||
| 3428 | feature: feature, | ||
| 3429 | remaining: feature.defaultAllowance, | ||
| 3430 | total: feature.defaultAllowance, | ||
| 3431 | resetContext: "Resets with app version \(AppVersion.current)" | ||
| 3432 | ) | ||
| 3433 | } | ||
| 3434 | |||
| 3101 | private static func csvEscaped(_ value: String) -> String { | 3435 | private static func csvEscaped(_ value: String) -> String { |
| 3102 | let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") | 3436 | let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") |
| 3103 | return "\"\(escaped)\"" | 3437 | return "\"\(escaped)\"" |
DomainDig/ExternalDataService.swift added +627
| @@ -0,0 +1,627 @@ | |||
| 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 | case ownershipHistory | 29 | case ownershipHistory |
| 30 | case dnsHistory | 30 | case dnsHistory |
| 31 | case extendedSubdomains | 31 | case extendedSubdomains |
| 32 | case domainPricing | ||
| 32 | 33 | ||
| 33 | var id: String { rawValue } | 34 | var id: String { rawValue } |
| 34 | 35 | ||
| @@ -52,6 +53,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable { | |||
| 52 | return "DNS history" | 53 | return "DNS history" |
| 53 | case .extendedSubdomains: | 54 | case .extendedSubdomains: |
| 54 | return "Extended subdomains" | 55 | return "Extended subdomains" |
| 56 | case .domainPricing: | ||
| 57 | return "Domain pricing" | ||
| 55 | } | 58 | } |
| 56 | } | 59 | } |
| 57 | } | 60 | } |
| @@ -139,7 +142,7 @@ enum FeatureAccessService { | |||
| 139 | switch capability { | 142 | switch capability { |
| 140 | case .workflows, .batchOperations, .advancedExports: | 143 | case .workflows, .batchOperations, .advancedExports: |
| 141 | return "Available in Pro" | 144 | return "Available in Pro" |
| 142 | case .ownershipHistory, .dnsHistory, .extendedSubdomains: | 145 | case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: |
| 143 | return "Available in Data+" | 146 | return "Available in Data+" |
| 144 | case .limitedTracking: | 147 | case .limitedTracking: |
| 145 | return "Tracking is limited on Free" | 148 | return "Tracking is limited on Free" |
| @@ -199,8 +202,15 @@ enum FeatureAccessService { | |||
| 199 | } | 202 | } |
| 200 | 203 | ||
| 201 | static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { | 204 | static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { |
| 202 | UpgradePromptContext( | 205 | let title: String |
| 203 | title: "Available in Pro", | 206 | switch capability { |
| 207 | case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: | ||
| 208 | title = "Available in Data+" | ||
| 209 | default: | ||
| 210 | title = "Available in Pro" | ||
| 211 | } | ||
| 212 | return UpgradePromptContext( | ||
| 213 | title: title, | ||
| 204 | message: upgradeMessage(for: capability), | 214 | message: upgradeMessage(for: capability), |
| 205 | capability: capability | 215 | capability: capability |
| 206 | ) | 216 | ) |
DomainDig/HistoryView.swift +3
| @@ -198,6 +198,9 @@ struct HistoryDetailView: View { | |||
| 198 | trackedDomain: trackedDomain, | 198 | trackedDomain: trackedDomain, |
| 199 | workflows: viewModel.workflowsContaining(domain: entry.domain), | 199 | workflows: viewModel.workflowsContaining(domain: entry.domain), |
| 200 | trackingLimitMessage: nil, | 200 | trackingLimitMessage: nil, |
| 201 | pricingLoading: false, | ||
| 202 | pricingError: snapshot.domainPricingError, | ||
| 203 | showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing), | ||
| 201 | onTrack: { | 204 | onTrack: { |
| 202 | _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | 205 | _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) |
| 203 | }, | 206 | }, |
DomainDig/Models.swift +155 −3
| @@ -370,6 +370,117 @@ enum DataCapability: String, Codable { | |||
| 370 | case domainPricing | 370 | case domainPricing |
| 371 | } | 371 | } |
| 372 | 372 | ||
| 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 | enum HistoryDateFilter: String, CaseIterable, Identifiable { | 484 | enum HistoryDateFilter: String, CaseIterable, Identifiable { |
| 374 | case today | 485 | case today |
| 375 | case last7Days | 486 | case last7Days |
| @@ -691,6 +802,7 @@ struct IPGeolocation: Codable { | |||
| 691 | 802 | ||
| 692 | struct DomainOwnership: Codable, Equatable { | 803 | struct DomainOwnership: Codable, Equatable { |
| 693 | let registrar: String? | 804 | let registrar: String? |
| 805 | let registrant: String? | ||
| 694 | let createdDate: Date? | 806 | let createdDate: Date? |
| 695 | let expirationDate: Date? | 807 | let expirationDate: Date? |
| 696 | let status: [String] | 808 | let status: [String] |
| @@ -699,6 +811,7 @@ struct DomainOwnership: Codable, Equatable { | |||
| 699 | 811 | ||
| 700 | init( | 812 | init( |
| 701 | registrar: String? = nil, | 813 | registrar: String? = nil, |
| 814 | registrant: String? = nil, | ||
| 702 | createdDate: Date? = nil, | 815 | createdDate: Date? = nil, |
| 703 | expirationDate: Date? = nil, | 816 | expirationDate: Date? = nil, |
| 704 | status: [String] = [], | 817 | status: [String] = [], |
| @@ -706,6 +819,7 @@ struct DomainOwnership: Codable, Equatable { | |||
| 706 | abuseEmail: String? = nil | 819 | abuseEmail: String? = nil |
| 707 | ) { | 820 | ) { |
| 708 | self.registrar = registrar | 821 | self.registrar = registrar |
| 822 | self.registrant = registrant | ||
| 709 | self.createdDate = createdDate | 823 | self.createdDate = createdDate |
| 710 | self.expirationDate = expirationDate | 824 | self.expirationDate = expirationDate |
| 711 | self.status = status | 825 | self.status = status |
| @@ -716,6 +830,7 @@ struct DomainOwnership: Codable, Equatable { | |||
| 716 | init(from decoder: Decoder) throws { | 830 | init(from decoder: Decoder) throws { |
| 717 | let container = try decoder.container(keyedBy: CodingKeys.self) | 831 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| 718 | registrar = try container.decodeIfPresent(String.self, forKey: .registrar) | 832 | registrar = try container.decodeIfPresent(String.self, forKey: .registrar) |
| 833 | registrant = try container.decodeIfPresent(String.self, forKey: .registrant) | ||
| 719 | createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) | 834 | createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) |
| 720 | expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) | 835 | expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) |
| 721 | status = try container.decodeIfPresent([String].self, forKey: .status) ?? [] | 836 | status = try container.decodeIfPresent([String].self, forKey: .status) ?? [] |
| @@ -724,9 +839,17 @@ struct DomainOwnership: Codable, Equatable { | |||
| 724 | } | 839 | } |
| 725 | } | 840 | } |
| 726 | 841 | ||
| 727 | struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable { | 842 | struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable { |
| 728 | var id: String { hostname } | 843 | var id: String { hostname } |
| 729 | let hostname: String | 844 | let hostname: String |
| 845 | let source: String? | ||
| 846 | let isExtended: Bool | ||
| 847 | |||
| 848 | nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) { | ||
| 849 | self.hostname = hostname | ||
| 850 | self.source = source | ||
| 851 | self.isExtended = isExtended | ||
| 852 | } | ||
| 730 | } | 853 | } |
| 731 | 854 | ||
| 732 | // MARK: - Email Security Models | 855 | // MARK: - Email Security Models |
| @@ -857,9 +980,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 857 | var emailSecurity: EmailSecurityResult? | 980 | var emailSecurity: EmailSecurityResult? |
| 858 | var mtaSts: MTASTSResult? | 981 | var mtaSts: MTASTSResult? |
| 859 | var ownership: DomainOwnership? | 982 | var ownership: DomainOwnership? |
| 983 | var ownershipHistory: [DomainOwnershipHistoryEvent] | ||
| 860 | var ptrRecord: String? | 984 | var ptrRecord: String? |
| 861 | var redirectChain: [RedirectHop] | 985 | var redirectChain: [RedirectHop] |
| 862 | var subdomains: [DiscoveredSubdomain] | 986 | var subdomains: [DiscoveredSubdomain] |
| 987 | var extendedSubdomains: [DiscoveredSubdomain] | ||
| 988 | var dnsHistory: [DNSHistoryEvent] | ||
| 989 | var domainPricing: DomainPricingInsight? | ||
| 863 | var portScanResults: [PortScanResult] | 990 | var portScanResults: [PortScanResult] |
| 864 | var hstsPreloaded: Bool? | 991 | var hstsPreloaded: Bool? |
| 865 | var availabilityResult: DomainAvailabilityResult? | 992 | var availabilityResult: DomainAvailabilityResult? |
| @@ -891,16 +1018,23 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 891 | var ipGeolocationError: String? | 1018 | var ipGeolocationError: String? |
| 892 | var emailSecurityError: String? | 1019 | var emailSecurityError: String? |
| 893 | var ownershipError: String? | 1020 | var ownershipError: String? |
| 1021 | var ownershipHistoryError: String? | ||
| 894 | var ptrError: String? | 1022 | var ptrError: String? |
| 895 | var redirectChainError: String? | 1023 | var redirectChainError: String? |
| 896 | var subdomainsError: String? | 1024 | var subdomainsError: String? |
| 1025 | var extendedSubdomainsError: String? | ||
| 1026 | var dnsHistoryError: String? | ||
| 1027 | var domainPricingError: String? | ||
| 897 | var portScanError: String? | 1028 | var portScanError: String? |
| 898 | 1029 | ||
| 899 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], | 1030 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], |
| 900 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], | 1031 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 901 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 1032 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 902 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, | 1033 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, |
| 1034 | ownershipHistory: [DomainOwnershipHistoryEvent] = [], | ||
| 903 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], | 1035 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], |
| 1036 | extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], | ||
| 1037 | domainPricing: DomainPricingInsight? = nil, | ||
| 904 | portScanResults: [PortScanResult] = [], | 1038 | portScanResults: [PortScanResult] = [], |
| 905 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, | 1039 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, |
| 906 | suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", | 1040 | suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", |
| @@ -915,8 +1049,10 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 915 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, | 1049 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, |
| 916 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | 1050 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, |
| 917 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | 1051 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 918 | emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil, | 1052 | emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, |
| 919 | redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) { | 1053 | ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, |
| 1054 | extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, | ||
| 1055 | domainPricingError: String? = nil, portScanError: String? = nil) { | ||
| 920 | self.domain = domain | 1056 | self.domain = domain |
| 921 | self.timestamp = timestamp | 1057 | self.timestamp = timestamp |
| 922 | self.trackedDomainID = trackedDomainID | 1058 | self.trackedDomainID = trackedDomainID |
| @@ -929,9 +1065,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 929 | self.emailSecurity = emailSecurity | 1065 | self.emailSecurity = emailSecurity |
| 930 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts | 1066 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts |
| 931 | self.ownership = ownership | 1067 | self.ownership = ownership |
| 1068 | self.ownershipHistory = ownershipHistory | ||
| 932 | self.ptrRecord = ptrRecord | 1069 | self.ptrRecord = ptrRecord |
| 933 | self.redirectChain = redirectChain | 1070 | self.redirectChain = redirectChain |
| 934 | self.subdomains = subdomains | 1071 | self.subdomains = subdomains |
| 1072 | self.extendedSubdomains = extendedSubdomains | ||
| 1073 | self.dnsHistory = dnsHistory | ||
| 1074 | self.domainPricing = domainPricing | ||
| 935 | self.portScanResults = portScanResults | 1075 | self.portScanResults = portScanResults |
| 936 | self.hstsPreloaded = hstsPreloaded | 1076 | self.hstsPreloaded = hstsPreloaded |
| 937 | self.availabilityResult = availabilityResult | 1077 | self.availabilityResult = availabilityResult |
| @@ -963,9 +1103,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 963 | self.ipGeolocationError = ipGeolocationError | 1103 | self.ipGeolocationError = ipGeolocationError |
| 964 | self.emailSecurityError = emailSecurityError | 1104 | self.emailSecurityError = emailSecurityError |
| 965 | self.ownershipError = ownershipError | 1105 | self.ownershipError = ownershipError |
| 1106 | self.ownershipHistoryError = ownershipHistoryError | ||
| 966 | self.ptrError = ptrError | 1107 | self.ptrError = ptrError |
| 967 | self.redirectChainError = redirectChainError | 1108 | self.redirectChainError = redirectChainError |
| 968 | self.subdomainsError = subdomainsError | 1109 | self.subdomainsError = subdomainsError |
| 1110 | self.extendedSubdomainsError = extendedSubdomainsError | ||
| 1111 | self.dnsHistoryError = dnsHistoryError | ||
| 1112 | self.domainPricingError = domainPricingError | ||
| 969 | self.portScanError = portScanError | 1113 | self.portScanError = portScanError |
| 970 | } | 1114 | } |
| 971 | 1115 | ||
| @@ -984,9 +1128,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 984 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) | 1128 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) |
| 985 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts | 1129 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts |
| 986 | ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) | 1130 | ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) |
| 1131 | ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] | ||
| 987 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) | 1132 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) |
| 988 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] | 1133 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 989 | subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] | 1134 | subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] |
| 1135 | extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? [] | ||
| 1136 | dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? [] | ||
| 1137 | domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing) | ||
| 990 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] | 1138 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |
| 991 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) | 1139 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) |
| 992 | availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) | 1140 | availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) |
| @@ -1018,9 +1166,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 1018 | ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) | 1166 | ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) |
| 1019 | emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) | 1167 | emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) |
| 1020 | ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError) | 1168 | ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError) |
| 1169 | ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError) | ||
| 1021 | ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) | 1170 | ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) |
| 1022 | redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) | 1171 | redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) |
| 1023 | subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) | 1172 | subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) |
| 1173 | extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError) | ||
| 1174 | dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError) | ||
| 1175 | domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError) | ||
| 1024 | portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) | 1176 | portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) |
| 1025 | } | 1177 | } |
| 1026 | 1178 | ||
DomainDig/PaywallView.swift +12 −1
| @@ -10,7 +10,7 @@ struct PaywallView: View { | |||
| 10 | NavigationStack { | 10 | NavigationStack { |
| 11 | List { | 11 | List { |
| 12 | Section { | 12 | Section { |
| 13 | Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.") | 13 | Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") |
| 14 | .font(appDensity.font(.body, design: .default)) | 14 | .font(appDensity.font(.body, design: .default)) |
| 15 | .foregroundStyle(.secondary) | 15 | .foregroundStyle(.secondary) |
| 16 | .fixedSize(horizontal: false, vertical: true) | 16 | .fixedSize(horizontal: false, vertical: true) |
| @@ -23,6 +23,13 @@ struct PaywallView: View { | |||
| 23 | featureRow("Advanced exports") | 23 | featureRow("Advanced exports") |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | Section("What Data+ Unlocks") { | ||
| 27 | featureRow("Ownership history") | ||
| 28 | featureRow("DNS history") | ||
| 29 | featureRow("Extended subdomains") | ||
| 30 | featureRow("External pricing signals") | ||
| 31 | } | ||
| 32 | |||
| 26 | Section("Subscription") { | 33 | Section("Subscription") { |
| 27 | if purchaseService.isLoadingProducts { | 34 | if purchaseService.isLoadingProducts { |
| 28 | ProgressView("Loading pricing…") | 35 | ProgressView("Loading pricing…") |
| @@ -116,6 +123,10 @@ struct PaywallView: View { | |||
| 116 | return "Pro Monthly" | 123 | return "Pro Monthly" |
| 117 | case PurchaseService.yearlyProductID: | 124 | case PurchaseService.yearlyProductID: |
| 118 | return "Pro Yearly" | 125 | return "Pro Yearly" |
| 126 | case PurchaseService.dataPlusMonthlyProductID: | ||
| 127 | return "Data+ Monthly" | ||
| 128 | case PurchaseService.dataPlusYearlyProductID: | ||
| 129 | return "Data+ Yearly" | ||
| 119 | default: | 130 | default: |
| 120 | return product.displayName | 131 | return product.displayName |
| 121 | } | 132 | } |
DomainDig/PurchaseService.swift +31 −5
| @@ -17,7 +17,14 @@ final class PurchaseService { | |||
| 17 | static let shared = PurchaseService() | 17 | static let shared = PurchaseService() |
| 18 | static let monthlyProductID = "domaindig.pro.monthly" | 18 | static let monthlyProductID = "domaindig.pro.monthly" |
| 19 | static let yearlyProductID = "domaindig.pro.yearly" | 19 | static let yearlyProductID = "domaindig.pro.yearly" |
| 20 | static let productIDs = [monthlyProductID, yearlyProductID] | 20 | static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly" |
| 21 | static let dataPlusYearlyProductID = "domaindig.dataplus.yearly" | ||
| 22 | static let productIDs = [ | ||
| 23 | monthlyProductID, | ||
| 24 | yearlyProductID, | ||
| 25 | dataPlusMonthlyProductID, | ||
| 26 | dataPlusYearlyProductID | ||
| 27 | ] | ||
| 21 | 28 | ||
| 22 | private static let entitlementCacheKey = "purchase.cachedEntitlement" | 29 | private static let entitlementCacheKey = "purchase.cachedEntitlement" |
| 23 | 30 | ||
| @@ -52,7 +59,11 @@ final class PurchaseService { | |||
| 52 | } | 59 | } |
| 53 | 60 | ||
| 54 | var hasProAccess: Bool { | 61 | var hasProAccess: Bool { |
| 55 | currentTier == .pro | 62 | currentTier != .free |
| 63 | } | ||
| 64 | |||
| 65 | var hasDataPlusAccess: Bool { | ||
| 66 | currentTier == .dataPlus | ||
| 56 | } | 67 | } |
| 57 | 68 | ||
| 58 | func refreshProducts() async { | 69 | func refreshProducts() async { |
| @@ -91,7 +102,7 @@ final class PurchaseService { | |||
| 91 | .productID | 102 | .productID |
| 92 | 103 | ||
| 93 | self.activeProductID = activeProductID | 104 | self.activeProductID = activeProductID |
| 94 | currentTier = activeProductID == nil ? .free : .pro | 105 | currentTier = tier(for: activeProductID) |
| 95 | persistCurrentEntitlement() | 106 | persistCurrentEntitlement() |
| 96 | } | 107 | } |
| 97 | 108 | ||
| @@ -109,7 +120,7 @@ final class PurchaseService { | |||
| 109 | apply(transaction: transaction) | 120 | apply(transaction: transaction) |
| 110 | await transaction.finish() | 121 | await transaction.finish() |
| 111 | await refreshEntitlements() | 122 | await refreshEntitlements() |
| 112 | statusMessage = "Pro is active." | 123 | statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active." |
| 113 | case .userCancelled: | 124 | case .userCancelled: |
| 114 | break | 125 | break |
| 115 | case .pending: | 126 | case .pending: |
| @@ -177,7 +188,7 @@ final class PurchaseService { | |||
| 177 | } | 188 | } |
| 178 | 189 | ||
| 179 | activeProductID = transaction.productID | 190 | activeProductID = transaction.productID |
| 180 | currentTier = .pro | 191 | currentTier = tier(for: transaction.productID) |
| 181 | persistCurrentEntitlement() | 192 | persistCurrentEntitlement() |
| 182 | } | 193 | } |
| 183 | 194 | ||
| @@ -224,11 +235,26 @@ final class PurchaseService { | |||
| 224 | return 0 | 235 | return 0 |
| 225 | case Self.yearlyProductID: | 236 | case Self.yearlyProductID: |
| 226 | return 1 | 237 | return 1 |
| 238 | case Self.dataPlusMonthlyProductID: | ||
| 239 | return 2 | ||
| 240 | case Self.dataPlusYearlyProductID: | ||
| 241 | return 3 | ||
| 227 | default: | 242 | default: |
| 228 | return Int.max | 243 | return Int.max |
| 229 | } | 244 | } |
| 230 | } | 245 | } |
| 231 | 246 | ||
| 247 | private func tier(for productID: String?) -> FeatureTier { | ||
| 248 | switch productID { | ||
| 249 | case Self.monthlyProductID, Self.yearlyProductID: | ||
| 250 | return .pro | ||
| 251 | case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID: | ||
| 252 | return .dataPlus | ||
| 253 | default: | ||
| 254 | return .free | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 232 | private func storeMessage(for error: Error, fallback: String) -> String { | 258 | private func storeMessage(for error: Error, fallback: String) -> String { |
| 233 | if let storeKitError = error as? StoreKitError { | 259 | if let storeKitError = error as? StoreKitError { |
| 234 | switch storeKitError { | 260 | switch storeKitError { |
DomainDig/RDAPService.swift +5
| @@ -25,6 +25,7 @@ enum RDAPService { | |||
| 25 | case let .success(response): | 25 | case let .success(response): |
| 26 | let ownership = DomainOwnership( | 26 | let ownership = DomainOwnership( |
| 27 | registrar: response.registrarName, | 27 | registrar: response.registrarName, |
| 28 | registrant: response.registrantName, | ||
| 28 | createdDate: response.createdDate, | 29 | createdDate: response.createdDate, |
| 29 | expirationDate: response.expirationDate, | 30 | expirationDate: response.expirationDate, |
| 30 | status: response.status, | 31 | status: response.status, |
| @@ -113,6 +114,10 @@ private struct RDAPDomainResponse: Decodable, Sendable { | |||
| 113 | entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName | 114 | entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName |
| 114 | } | 115 | } |
| 115 | 116 | ||
| 117 | var registrantName: String? { | ||
| 118 | entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName | ||
| 119 | } | ||
| 120 | |||
| 116 | var createdDate: Date? { | 121 | var createdDate: Date? { |
| 117 | eventDate(for: ["registration", "registered"]) | 122 | eventDate(for: ["registration", "registered"]) |
| 118 | } | 123 | } |
DomainDig/SubdomainDiscoveryService.swift +1 −1
| @@ -59,7 +59,7 @@ enum SubdomainDiscoveryService { | |||
| 59 | guard seen.insert(sanitized).inserted else { | 59 | guard seen.insert(sanitized).inserted else { |
| 60 | continue | 60 | continue |
| 61 | } | 61 | } |
| 62 | results.append(DiscoveredSubdomain(hostname: sanitized)) | 62 | results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh")) |
| 63 | if results.count == limit { | 63 | if results.count == limit { |
| 64 | return results | 64 | return results |
| 65 | } | 65 | } |
DomainDig/UsageCreditService.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 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 | let arguments = Array(CommandLine.arguments.dropFirst()) | 6 | let arguments = Array(CommandLine.arguments.dropFirst()) |
| 7 | 7 | ||
| 8 | guard let command = CommandLine.arguments.first else { | 8 | guard let command = CommandLine.arguments.first else { |
| 9 | fputs("usage: domaindig <domain> [--json]\n", stderr) | 9 | fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) |
| 10 | Foundation.exit(1) | 10 | Foundation.exit(1) |
| 11 | } | 11 | } |
| 12 | _ = command | 12 | _ = command |
| 13 | 13 | ||
| 14 | let wantsJSON = arguments.contains("--json") || arguments.contains("-j") | 14 | let wantsJSON = arguments.contains("--json") || arguments.contains("-j") |
| 15 | let wantsOwnershipHistory = arguments.contains("--ownership-history") | ||
| 16 | let wantsDNSHistory = arguments.contains("--dns-history") | ||
| 17 | let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") | ||
| 18 | let wantsPricing = arguments.contains("--pricing") | ||
| 19 | let wantsUsage = arguments.contains("--show-usage") | ||
| 15 | let domains = arguments.filter { !$0.hasPrefix("-") } | 20 | let domains = arguments.filter { !$0.hasPrefix("-") } |
| 16 | 21 | ||
| 17 | let requestedDomains = domains | 22 | let requestedDomains = domains |
| @@ -19,18 +24,29 @@ struct DomainDigCLI { | |||
| 19 | .filter { !$0.isEmpty } | 24 | .filter { !$0.isEmpty } |
| 20 | 25 | ||
| 21 | guard !requestedDomains.isEmpty else { | 26 | guard !requestedDomains.isEmpty else { |
| 22 | fputs("usage: domaindig <domain> [--json]\n", stderr) | 27 | fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) |
| 23 | Foundation.exit(1) | 28 | Foundation.exit(1) |
| 24 | } | 29 | } |
| 25 | 30 | ||
| 26 | let inspectionService = DomainInspectionService() | 31 | let inspectionService = DomainInspectionService() |
| 32 | let reportBuilder = DomainReportBuilder() | ||
| 27 | var reports: [DomainReport] = [] | 33 | var reports: [DomainReport] = [] |
| 28 | var seen = Set<String>() | 34 | var seen = Set<String>() |
| 35 | var usageImpact: [String] = [] | ||
| 29 | 36 | ||
| 30 | for domain in requestedDomains { | 37 | for domain in requestedDomains { |
| 31 | let normalizedDomain = domain.lowercased() | 38 | let normalizedDomain = domain.lowercased() |
| 32 | guard seen.insert(normalizedDomain).inserted else { continue } | 39 | guard seen.insert(normalizedDomain).inserted else { continue } |
| 33 | reports.append(await inspectionService.inspect(domain: domain)) | 40 | let snapshot = await inspectionService.inspectSnapshot(domain: domain) |
| 41 | let enrichedSnapshot = await enrichSnapshot( | ||
| 42 | snapshot, | ||
| 43 | wantsOwnershipHistory: wantsOwnershipHistory, | ||
| 44 | wantsDNSHistory: wantsDNSHistory, | ||
| 45 | wantsExtendedSubdomains: wantsExtendedSubdomains, | ||
| 46 | wantsPricing: wantsPricing, | ||
| 47 | usageImpact: &usageImpact | ||
| 48 | ) | ||
| 49 | reports.append(reportBuilder.build(from: enrichedSnapshot)) | ||
| 34 | } | 50 | } |
| 35 | 51 | ||
| 36 | do { | 52 | do { |
| @@ -47,6 +63,9 @@ struct DomainDigCLI { | |||
| 47 | title: "DomainDig Batch Report" | 63 | title: "DomainDig Batch Report" |
| 48 | ) | 64 | ) |
| 49 | } | 65 | } |
| 66 | if wantsUsage, !usageImpact.isEmpty { | ||
| 67 | FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8)) | ||
| 68 | } | ||
| 50 | FileHandle.standardOutput.write(data) | 69 | FileHandle.standardOutput.write(data) |
| 51 | if data.last != 0x0A { | 70 | if data.last != 0x0A { |
| 52 | FileHandle.standardOutput.write(Data([0x0A])) | 71 | FileHandle.standardOutput.write(Data([0x0A])) |
| @@ -56,4 +75,187 @@ struct DomainDigCLI { | |||
| 56 | Foundation.exit(1) | 75 | Foundation.exit(1) |
| 57 | } | 76 | } |
| 58 | } | 77 | } |
| 78 | |||
| 79 | private static func enrichSnapshot( | ||
| 80 | _ snapshot: LookupSnapshot, | ||
| 81 | wantsOwnershipHistory: Bool, | ||
| 82 | wantsDNSHistory: Bool, | ||
| 83 | wantsExtendedSubdomains: Bool, | ||
| 84 | wantsPricing: Bool, | ||
| 85 | usageImpact: inout [String] | ||
| 86 | ) async -> LookupSnapshot { | ||
| 87 | guard FeatureAccessService.currentTier == .dataPlus else { | ||
| 88 | return snapshot | ||
| 89 | } | ||
| 90 | |||
| 91 | let historyEntries = loadHistoryEntries() | ||
| 92 | var ownershipHistory = snapshot.ownershipHistory | ||
| 93 | var ownershipHistoryError = snapshot.ownershipHistoryError | ||
| 94 | var dnsHistory = snapshot.dnsHistory | ||
| 95 | var dnsHistoryError = snapshot.dnsHistoryError | ||
| 96 | var extendedSubdomains = snapshot.extendedSubdomains | ||
| 97 | var extendedSubdomainsError = snapshot.extendedSubdomainsError | ||
| 98 | var domainPricing = snapshot.domainPricing | ||
| 99 | var domainPricingError = snapshot.domainPricingError | ||
| 100 | |||
| 101 | if wantsOwnershipHistory, | ||
| 102 | await UsageCreditService.shared.canUse(.ownershipHistory) { | ||
| 103 | let outcome = await ExternalDataService.shared.ownershipHistory( | ||
| 104 | domain: snapshot.domain, | ||
| 105 | currentOwnership: snapshot.ownership, | ||
| 106 | historyEntries: historyEntries | ||
| 107 | ) | ||
| 108 | switch outcome.value { | ||
| 109 | case let .success(events): | ||
| 110 | ownershipHistory = events | ||
| 111 | ownershipHistoryError = nil | ||
| 112 | if outcome.source != .cached { | ||
| 113 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | ||
| 114 | usageImpact.append("ownership history -1") | ||
| 115 | } | ||
| 116 | case let .empty(message): | ||
| 117 | ownershipHistoryError = message | ||
| 118 | if outcome.source != .cached { | ||
| 119 | _ = await UsageCreditService.shared.consume(.ownershipHistory) | ||
| 120 | usageImpact.append("ownership history -1") | ||
| 121 | } | ||
| 122 | case let .error(message): | ||
| 123 | ownershipHistoryError = message | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | if wantsDNSHistory, | ||
| 128 | await UsageCreditService.shared.canUse(.dnsHistory) { | ||
| 129 | let outcome = await ExternalDataService.shared.dnsHistory( | ||
| 130 | domain: snapshot.domain, | ||
| 131 | dnsSections: snapshot.dnsSections, | ||
| 132 | historyEntries: historyEntries | ||
| 133 | ) | ||
| 134 | switch outcome.value { | ||
| 135 | case let .success(events): | ||
| 136 | dnsHistory = events | ||
| 137 | dnsHistoryError = nil | ||
| 138 | if outcome.source != .cached { | ||
| 139 | _ = await UsageCreditService.shared.consume(.dnsHistory) | ||
| 140 | usageImpact.append("dns history -1") | ||
| 141 | } | ||
| 142 | case let .empty(message): | ||
| 143 | dnsHistoryError = message | ||
| 144 | if outcome.source != .cached { | ||
| 145 | _ = await UsageCreditService.shared.consume(.dnsHistory) | ||
| 146 | usageImpact.append("dns history -1") | ||
| 147 | } | ||
| 148 | case let .error(message): | ||
| 149 | dnsHistoryError = message | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | if wantsExtendedSubdomains, | ||
| 154 | await UsageCreditService.shared.canUse(.extendedSubdomains) { | ||
| 155 | let outcome = await ExternalDataService.shared.extendedSubdomains( | ||
| 156 | domain: snapshot.domain, | ||
| 157 | existing: snapshot.subdomains | ||
| 158 | ) | ||
| 159 | switch outcome.value { | ||
| 160 | case let .success(results): | ||
| 161 | extendedSubdomains = results | ||
| 162 | extendedSubdomainsError = nil | ||
| 163 | if outcome.source != .cached { | ||
| 164 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | ||
| 165 | usageImpact.append("extended subdomains -1") | ||
| 166 | } | ||
| 167 | case let .empty(message): | ||
| 168 | extendedSubdomainsError = message | ||
| 169 | if outcome.source != .cached { | ||
| 170 | _ = await UsageCreditService.shared.consume(.extendedSubdomains) | ||
| 171 | usageImpact.append("extended subdomains -1") | ||
| 172 | } | ||
| 173 | case let .error(message): | ||
| 174 | extendedSubdomainsError = message | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | if wantsPricing { | ||
| 179 | let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain) | ||
| 180 | switch outcome.value { | ||
| 181 | case let .success(pricing): | ||
| 182 | domainPricing = pricing | ||
| 183 | domainPricingError = nil | ||
| 184 | case let .empty(message), let .error(message): | ||
| 185 | domainPricingError = message | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | return LookupSnapshot( | ||
| 190 | historyEntryID: snapshot.historyEntryID, | ||
| 191 | domain: snapshot.domain, | ||
| 192 | timestamp: snapshot.timestamp, | ||
| 193 | trackedDomainID: snapshot.trackedDomainID, | ||
| 194 | note: snapshot.note, | ||
| 195 | appVersion: snapshot.appVersion, | ||
| 196 | resolverDisplayName: snapshot.resolverDisplayName, | ||
| 197 | resolverURLString: snapshot.resolverURLString, | ||
| 198 | dataSources: snapshot.dataSources, | ||
| 199 | provenanceBySection: snapshot.provenanceBySection, | ||
| 200 | availabilityConfidence: snapshot.availabilityConfidence, | ||
| 201 | ownershipConfidence: snapshot.ownershipConfidence, | ||
| 202 | subdomainConfidence: snapshot.subdomainConfidence, | ||
| 203 | emailSecurityConfidence: snapshot.emailSecurityConfidence, | ||
| 204 | geolocationConfidence: snapshot.geolocationConfidence, | ||
| 205 | errorDetails: snapshot.errorDetails, | ||
| 206 | isPartialSnapshot: snapshot.isPartialSnapshot, | ||
| 207 | validationIssues: snapshot.validationIssues, | ||
| 208 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | ||
| 209 | dnsSections: snapshot.dnsSections, | ||
| 210 | dnsError: snapshot.dnsError, | ||
| 211 | availabilityResult: snapshot.availabilityResult, | ||
| 212 | suggestions: snapshot.suggestions, | ||
| 213 | sslInfo: snapshot.sslInfo, | ||
| 214 | sslError: snapshot.sslError, | ||
| 215 | hstsPreloaded: snapshot.hstsPreloaded, | ||
| 216 | httpHeaders: snapshot.httpHeaders, | ||
| 217 | httpSecurityGrade: snapshot.httpSecurityGrade, | ||
| 218 | httpStatusCode: snapshot.httpStatusCode, | ||
| 219 | httpResponseTimeMs: snapshot.httpResponseTimeMs, | ||
| 220 | httpProtocol: snapshot.httpProtocol, | ||
| 221 | http3Advertised: snapshot.http3Advertised, | ||
| 222 | httpHeadersError: snapshot.httpHeadersError, | ||
| 223 | reachabilityResults: snapshot.reachabilityResults, | ||
| 224 | reachabilityError: snapshot.reachabilityError, | ||
| 225 | ipGeolocation: snapshot.ipGeolocation, | ||
| 226 | ipGeolocationError: snapshot.ipGeolocationError, | ||
| 227 | emailSecurity: snapshot.emailSecurity, | ||
| 228 | emailSecurityError: snapshot.emailSecurityError, | ||
| 229 | ownership: snapshot.ownership, | ||
| 230 | ownershipError: snapshot.ownershipError, | ||
| 231 | ownershipHistory: ownershipHistory, | ||
| 232 | ownershipHistoryError: ownershipHistoryError, | ||
| 233 | ptrRecord: snapshot.ptrRecord, | ||
| 234 | ptrError: snapshot.ptrError, | ||
| 235 | redirectChain: snapshot.redirectChain, | ||
| 236 | redirectChainError: snapshot.redirectChainError, | ||
| 237 | subdomains: snapshot.subdomains, | ||
| 238 | subdomainsError: snapshot.subdomainsError, | ||
| 239 | extendedSubdomains: extendedSubdomains, | ||
| 240 | extendedSubdomainsError: extendedSubdomainsError, | ||
| 241 | dnsHistory: dnsHistory, | ||
| 242 | dnsHistoryError: dnsHistoryError, | ||
| 243 | domainPricing: domainPricing, | ||
| 244 | domainPricingError: domainPricingError, | ||
| 245 | portScanResults: snapshot.portScanResults, | ||
| 246 | portScanError: snapshot.portScanError, | ||
| 247 | changeSummary: snapshot.changeSummary, | ||
| 248 | resultSource: snapshot.resultSource, | ||
| 249 | cachedSections: snapshot.cachedSections, | ||
| 250 | statusMessage: snapshot.statusMessage | ||
| 251 | ) | ||
| 252 | } | ||
| 253 | |||
| 254 | private static func loadHistoryEntries() -> [HistoryEntry] { | ||
| 255 | guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), | ||
| 256 | let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { | ||
| 257 | return [] | ||
| 258 | } | ||
| 259 | return entries | ||
| 260 | } | ||
| 59 | } | 261 | } |
DomainInspectionService.swift +8
| @@ -313,12 +313,20 @@ struct DomainInspectionService { | |||
| 313 | emailSecurityError: emailSecurity.message, | 313 | emailSecurityError: emailSecurity.message, |
| 314 | ownership: ownership.value, | 314 | ownership: ownership.value, |
| 315 | ownershipError: ownership.message, | 315 | ownershipError: ownership.message, |
| 316 | ownershipHistory: [], | ||
| 317 | ownershipHistoryError: nil, | ||
| 316 | ptrRecord: ptrRecord.value, | 318 | ptrRecord: ptrRecord.value, |
| 317 | ptrError: ptrRecord.message, | 319 | ptrError: ptrRecord.message, |
| 318 | redirectChain: redirectChain.value, | 320 | redirectChain: redirectChain.value, |
| 319 | redirectChainError: redirectChain.message, | 321 | redirectChainError: redirectChain.message, |
| 320 | subdomains: subdomains.value, | 322 | subdomains: subdomains.value, |
| 321 | subdomainsError: subdomains.message, | 323 | subdomainsError: subdomains.message, |
| 324 | extendedSubdomains: [], | ||
| 325 | extendedSubdomainsError: nil, | ||
| 326 | dnsHistory: [], | ||
| 327 | dnsHistoryError: nil, | ||
| 328 | domainPricing: nil, | ||
| 329 | domainPricingError: nil, | ||
| 322 | portScanResults: portScanResults.value, | 330 | portScanResults: portScanResults.value, |
| 323 | portScanError: portScanResults.message, | 331 | portScanError: portScanResults.message, |
| 324 | changeSummary: nil, | 332 | changeSummary: nil, |
DomainReportBuilder.swift +9 −1
| @@ -21,11 +21,15 @@ struct DomainReport: Codable { | |||
| 21 | let emailConfidence: ConfidenceLevel? | 21 | let emailConfidence: ConfidenceLevel? |
| 22 | let geolocationConfidence: ConfidenceLevel? | 22 | let geolocationConfidence: ConfidenceLevel? |
| 23 | let ownership: DomainOwnership? | 23 | let ownership: DomainOwnership? |
| 24 | let ownershipHistory: [DomainOwnershipHistoryEvent] | ||
| 24 | let dns: DNSResultSummary | 25 | let dns: DNSResultSummary |
| 25 | let web: WebResultSummary | 26 | let web: WebResultSummary |
| 26 | let email: EmailSecuritySummary | 27 | let email: EmailSecuritySummary |
| 27 | let network: NetworkSummary | 28 | let network: NetworkSummary |
| 28 | let subdomains: [String] | 29 | let subdomains: [String] |
| 30 | let extendedSubdomains: [String] | ||
| 31 | let dnsHistory: [DNSHistoryEvent] | ||
| 32 | let domainPricing: DomainPricingInsight? | ||
| 29 | let subdomainGroups: [SubdomainGroup] | 33 | let subdomainGroups: [SubdomainGroup] |
| 30 | let riskAssessment: DomainRiskAssessment | 34 | let riskAssessment: DomainRiskAssessment |
| 31 | let insights: [String] | 35 | let insights: [String] |
| @@ -164,6 +168,7 @@ struct DomainReportBuilder { | |||
| 164 | emailConfidence: snapshot.emailSecurityConfidence, | 168 | emailConfidence: snapshot.emailSecurityConfidence, |
| 165 | geolocationConfidence: snapshot.geolocationConfidence, | 169 | geolocationConfidence: snapshot.geolocationConfidence, |
| 166 | ownership: snapshot.ownership, | 170 | ownership: snapshot.ownership, |
| 171 | ownershipHistory: snapshot.ownershipHistory, | ||
| 167 | dns: DNSResultSummary( | 172 | dns: DNSResultSummary( |
| 168 | resolverDisplayName: snapshot.resolverDisplayName, | 173 | resolverDisplayName: snapshot.resolverDisplayName, |
| 169 | resolverURLString: snapshot.resolverURLString, | 174 | resolverURLString: snapshot.resolverURLString, |
| @@ -216,13 +221,16 @@ struct DomainReportBuilder { | |||
| 216 | portScanError: snapshot.portScanError | 221 | portScanError: snapshot.portScanError |
| 217 | ), | 222 | ), |
| 218 | subdomains: snapshot.subdomains.map(\.hostname), | 223 | subdomains: snapshot.subdomains.map(\.hostname), |
| 224 | extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname), | ||
| 225 | dnsHistory: snapshot.dnsHistory, | ||
| 226 | domainPricing: snapshot.domainPricing, | ||
| 219 | subdomainGroups: analysis.subdomainGroups, | 227 | subdomainGroups: analysis.subdomainGroups, |
| 220 | riskAssessment: analysis.riskAssessment, | 228 | riskAssessment: analysis.riskAssessment, |
| 221 | insights: analysis.insights, | 229 | insights: analysis.insights, |
| 222 | changeSummary: changeSummary, | 230 | changeSummary: changeSummary, |
| 223 | workflowContext: workflowContext, | 231 | workflowContext: workflowContext, |
| 224 | metadata: DomainReportMetadata( | 232 | metadata: DomainReportMetadata( |
| 225 | schemaVersion: "3.0.0", | 233 | schemaVersion: "3.2.0", |
| 226 | resolverDisplayName: snapshot.resolverDisplayName, | 234 | resolverDisplayName: snapshot.resolverDisplayName, |
| 227 | resolverURLString: snapshot.resolverURLString, | 235 | resolverURLString: snapshot.resolverURLString, |
| 228 | appVersion: snapshot.appVersion, | 236 | appVersion: snapshot.appVersion, |
DomainReportExporter.swift +74 −1
| @@ -67,7 +67,9 @@ enum DomainReportExporter { | |||
| 67 | "TLS Status: \(report.web.tlsStatus)", | 67 | "TLS Status: \(report.web.tlsStatus)", |
| 68 | "HTTP: \(httpSummary(for: report))", | 68 | "HTTP: \(httpSummary(for: report))", |
| 69 | "Email: \(report.email.summary)", | 69 | "Email: \(report.email.summary)", |
| 70 | "Subdomains: \(report.subdomains.count)" | 70 | "Subdomains: \(report.subdomains.count)", |
| 71 | "Extended Subdomains: \(report.extendedSubdomains.count)", | ||
| 72 | "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")" | ||
| 71 | ] | 73 | ] |
| 72 | } | 74 | } |
| 73 | 75 | ||
| @@ -97,6 +99,7 @@ enum DomainReportExporter { | |||
| 97 | "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", | 99 | "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", |
| 98 | "Created: \(ownershipDateLabel(report.ownership?.createdDate))", | 100 | "Created: \(ownershipDateLabel(report.ownership?.createdDate))", |
| 99 | "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", | 101 | "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", |
| 102 | "Registrant: \(report.ownership?.registrant ?? "Unavailable")", | ||
| 100 | "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", | 103 | "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", |
| 101 | "Status: \(joined(report.ownership?.status) ?? "Unavailable")", | 104 | "Status: \(joined(report.ownership?.status) ?? "Unavailable")", |
| 102 | "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" | 105 | "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" |
| @@ -112,6 +115,20 @@ enum DomainReportExporter { | |||
| 112 | return ownershipLines | 115 | return ownershipLines |
| 113 | } | 116 | } |
| 114 | 117 | ||
| 118 | appendSection("Ownership History", to: &lines) { | ||
| 119 | guard !report.ownershipHistory.isEmpty else { | ||
| 120 | return ["No ownership history available"] | ||
| 121 | } | ||
| 122 | |||
| 123 | return report.ownershipHistory.map { event in | ||
| 124 | [ | ||
| 125 | textDateFormatter.string(from: event.date), | ||
| 126 | event.summary, | ||
| 127 | "source=\(event.source)" | ||
| 128 | ].joined(separator: " | ") | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 115 | appendSection("DNS", to: &lines) { | 132 | appendSection("DNS", to: &lines) { |
| 116 | var dnsLines = [ | 133 | var dnsLines = [ |
| 117 | "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", | 134 | "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", |
| @@ -139,6 +156,22 @@ enum DomainReportExporter { | |||
| 139 | return dnsLines | 156 | return dnsLines |
| 140 | } | 157 | } |
| 141 | 158 | ||
| 159 | appendSection("DNS History", to: &lines) { | ||
| 160 | guard !report.dnsHistory.isEmpty else { | ||
| 161 | return ["No DNS history available"] | ||
| 162 | } | ||
| 163 | |||
| 164 | return report.dnsHistory.map { event in | ||
| 165 | [ | ||
| 166 | textDateFormatter.string(from: event.date), | ||
| 167 | event.summary, | ||
| 168 | "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")", | ||
| 169 | "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")", | ||
| 170 | "source=\(event.source)" | ||
| 171 | ].joined(separator: " | ") | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 142 | appendSection("Web", to: &lines) { | 175 | appendSection("Web", to: &lines) { |
| 143 | var webLines = [ | 176 | var webLines = [ |
| 144 | "TLS Status: \(report.web.tlsStatus)", | 177 | "TLS Status: \(report.web.tlsStatus)", |
| @@ -254,9 +287,28 @@ enum DomainReportExporter { | |||
| 254 | values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))") | 287 | values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))") |
| 255 | } | 288 | } |
| 256 | values.append(contentsOf: report.subdomains.map { "- \($0)" }) | 289 | values.append(contentsOf: report.subdomains.map { "- \($0)" }) |
| 290 | if !report.extendedSubdomains.isEmpty { | ||
| 291 | values.append("Extended:") | ||
| 292 | values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" }) | ||
| 293 | } | ||
| 257 | return values | 294 | return values |
| 258 | } | 295 | } |
| 259 | 296 | ||
| 297 | appendSection("Pricing", to: &lines) { | ||
| 298 | guard let pricing = report.domainPricing else { | ||
| 299 | return ["External pricing unavailable"] | ||
| 300 | } | ||
| 301 | |||
| 302 | return [ | ||
| 303 | "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")", | ||
| 304 | "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")", | ||
| 305 | "Resale: \(pricing.resaleSignal ?? "Unavailable")", | ||
| 306 | "Auction: \(pricing.auctionSignal ?? "Unavailable")", | ||
| 307 | "Source: \(pricing.source)", | ||
| 308 | "Collected: \(textDateFormatter.string(from: pricing.collectedAt))" | ||
| 309 | ] | ||
| 310 | } | ||
| 311 | |||
| 260 | appendSection("Changes", to: &lines) { | 312 | appendSection("Changes", to: &lines) { |
| 261 | guard let changeSummary = report.changeSummary else { | 313 | guard let changeSummary = report.changeSummary else { |
| 262 | return ["No comparison available"] | 314 | return ["No comparison available"] |
| @@ -342,9 +394,18 @@ enum DomainReportExporter { | |||
| 342 | "email_grade", | 394 | "email_grade", |
| 343 | "email_confidence", | 395 | "email_confidence", |
| 344 | "subdomain_count", | 396 | "subdomain_count", |
| 397 | "extended_subdomain_count", | ||
| 345 | "subdomain_groups", | 398 | "subdomain_groups", |
| 346 | "subdomain_confidence", | 399 | "subdomain_confidence", |
| 347 | "subdomains", | 400 | "subdomains", |
| 401 | "extended_subdomains", | ||
| 402 | "ownership_history", | ||
| 403 | "dns_history", | ||
| 404 | "pricing_estimated", | ||
| 405 | "pricing_premium", | ||
| 406 | "pricing_resale_signal", | ||
| 407 | "pricing_auction_signal", | ||
| 408 | "pricing_source", | ||
| 348 | "open_ports", | 409 | "open_ports", |
| 349 | "reachability_summary", | 410 | "reachability_summary", |
| 350 | "geolocation_summary", | 411 | "geolocation_summary", |
| @@ -368,12 +429,15 @@ enum DomainReportExporter { | |||
| 368 | let httpStatus = report.web.statusCode.map(String.init) ?? "" | 429 | let httpStatus = report.web.statusCode.map(String.init) ?? "" |
| 369 | let subdomainCount = String(report.subdomains.count) | 430 | let subdomainCount = String(report.subdomains.count) |
| 370 | let subdomains = report.subdomains.joined(separator: " | ") | 431 | let subdomains = report.subdomains.joined(separator: " | ") |
| 432 | let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ") | ||
| 371 | let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") | 433 | let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") |
| 372 | let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ") | 434 | let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ") |
| 373 | let insights = report.insights.joined(separator: " | ") | 435 | let insights = report.insights.joined(separator: " | ") |
| 374 | let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ") | 436 | let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ") |
| 375 | let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ") | 437 | let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ") |
| 376 | let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") | 438 | let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") |
| 439 | let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") | ||
| 440 | let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") | ||
| 377 | 441 | ||
| 378 | return [ | 442 | return [ |
| 379 | report.domain, | 443 | report.domain, |
| @@ -407,9 +471,18 @@ enum DomainReportExporter { | |||
| 407 | report.email.grade?.rawValue ?? "", | 471 | report.email.grade?.rawValue ?? "", |
| 408 | report.emailConfidence?.rawValue ?? "", | 472 | report.emailConfidence?.rawValue ?? "", |
| 409 | subdomainCount, | 473 | subdomainCount, |
| 474 | String(report.extendedSubdomains.count), | ||
| 410 | subdomainGroups, | 475 | subdomainGroups, |
| 411 | report.subdomainConfidence?.rawValue ?? "", | 476 | report.subdomainConfidence?.rawValue ?? "", |
| 412 | subdomains, | 477 | subdomains, |
| 478 | extendedSubdomains, | ||
| 479 | ownershipHistory, | ||
| 480 | dnsHistory, | ||
| 481 | report.domainPricing?.estimatedPrice ?? "", | ||
| 482 | report.domainPricing?.premiumIndicator == true ? "true" : "false", | ||
| 483 | report.domainPricing?.resaleSignal ?? "", | ||
| 484 | report.domainPricing?.auctionSignal ?? "", | ||
| 485 | report.domainPricing?.source ?? "", | ||
| 413 | openPorts, | 486 | openPorts, |
| 414 | report.network.reachabilitySummary, | 487 | report.network.reachabilitySummary, |
| 415 | report.network.geolocationSummary, | 488 | report.network.geolocationSummary, |
LookupSnapshot.swift +16
| @@ -42,12 +42,20 @@ struct LookupSnapshot { | |||
| 42 | let emailSecurityError: String? | 42 | let emailSecurityError: String? |
| 43 | let ownership: DomainOwnership? | 43 | let ownership: DomainOwnership? |
| 44 | let ownershipError: String? | 44 | let ownershipError: String? |
| 45 | let ownershipHistory: [DomainOwnershipHistoryEvent] | ||
| 46 | let ownershipHistoryError: String? | ||
| 45 | let ptrRecord: String? | 47 | let ptrRecord: String? |
| 46 | let ptrError: String? | 48 | let ptrError: String? |
| 47 | let redirectChain: [RedirectHop] | 49 | let redirectChain: [RedirectHop] |
| 48 | let redirectChainError: String? | 50 | let redirectChainError: String? |
| 49 | let subdomains: [DiscoveredSubdomain] | 51 | let subdomains: [DiscoveredSubdomain] |
| 50 | let subdomainsError: String? | 52 | let subdomainsError: String? |
| 53 | let extendedSubdomains: [DiscoveredSubdomain] | ||
| 54 | let extendedSubdomainsError: String? | ||
| 55 | let dnsHistory: [DNSHistoryEvent] | ||
| 56 | let dnsHistoryError: String? | ||
| 57 | let domainPricing: DomainPricingInsight? | ||
| 58 | let domainPricingError: String? | ||
| 51 | let portScanResults: [PortScanResult] | 59 | let portScanResults: [PortScanResult] |
| 52 | let portScanError: String? | 60 | let portScanError: String? |
| 53 | let changeSummary: DomainChangeSummary? | 61 | let changeSummary: DomainChangeSummary? |
| @@ -104,12 +112,20 @@ extension HistoryEntry { | |||
| 104 | emailSecurityError: emailSecurityError, | 112 | emailSecurityError: emailSecurityError, |
| 105 | ownership: ownership, | 113 | ownership: ownership, |
| 106 | ownershipError: ownershipError, | 114 | ownershipError: ownershipError, |
| 115 | ownershipHistory: ownershipHistory, | ||
| 116 | ownershipHistoryError: ownershipHistoryError, | ||
| 107 | ptrRecord: ptrRecord, | 117 | ptrRecord: ptrRecord, |
| 108 | ptrError: ptrError, | 118 | ptrError: ptrError, |
| 109 | redirectChain: redirectChain, | 119 | redirectChain: redirectChain, |
| 110 | redirectChainError: redirectChainError, | 120 | redirectChainError: redirectChainError, |
| 111 | subdomains: subdomains, | 121 | subdomains: subdomains, |
| 112 | subdomainsError: subdomainsError, | 122 | subdomainsError: subdomainsError, |
| 123 | extendedSubdomains: extendedSubdomains, | ||
| 124 | extendedSubdomainsError: extendedSubdomainsError, | ||
| 125 | dnsHistory: dnsHistory, | ||
| 126 | dnsHistoryError: dnsHistoryError, | ||
| 127 | domainPricing: domainPricing, | ||
| 128 | domainPricingError: domainPricingError, | ||
| 113 | portScanResults: portScanResults, | 129 | portScanResults: portScanResults, |
| 114 | portScanError: portScanError, | 130 | portScanError: portScanError, |
| 115 | changeSummary: changeSummary, | 131 | changeSummary: changeSummary, |