Commit 0571e751f8

0571e751f8d6f2975b7ea9fdbd30f5b77c05e836

parent: 48999737a3

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 05:22 UTC

refactor: extract result section views into ResultSectionViews (v5 step 4, 7/n)

Seventh slice of the god-file decomposition; second ContentView.swift slice.

- Moves the nine result detail section views into ResultSectionViews.swift:
  DomainSectionView, OwnershipSectionView, IntelligenceSectionView,
  SubdomainsSectionView, DNSSectionView, WebSectionView, EmailSectionView,
  NetworkSectionView, and PortRowsView. Pure move.
- Promotes the one-line appLoadingStyle() View helper private -> internal: the
  moved section views and the staying LoadingCardView both use it. That is the
  only non-deletion edit to ContentView.swift.

The shared primitives (CardView, SectionTitleView, LabeledValueRow,
ResultColors) stay in ContentView.swift and are reached cross-file.

ContentView.swift: 2738 -> 1626 lines (3881 at the start of the ContentView
pivot; -2255 total). App builds clean; unit suite 58/58. No project.pbxproj
change.

Layout: unified · split

DomainDig/ContentView.swift +1 −1114
@@ -1324,1119 +1324,6 @@ struct TrackedDomainDetailHeaderView: View {
13241324 }
13251325}
13261326
1327struct DomainSectionView: View {
1328 @Environment(\.appDensity) private var appDensity
1329 @Binding var isCollapsed: Bool
1330 let rows: [InfoRowViewData]
1331 let suggestions: [DomainSuggestionViewData]
1332 let showSuggestions: Bool
1333 let availabilityLoading: Bool
1334 let suggestionsLoading: Bool
1335 let provenance: SectionProvenance?
1336 let confidence: ConfidenceLevel?
1337 let snapshotNote: String?
1338 let trackedDomain: TrackedDomain?
1339 let workflows: [DomainWorkflow]
1340 let trackingLimitMessage: String?
1341 let pricingLoading: Bool
1342 let pricingError: String?
1343 let showsPricingPlaceholder: Bool
1344 let onTrack: () -> Void
1345 let onTogglePinned: () -> Void
1346 let onEditNote: (() -> Void)?
1347 let onAddToWorkflow: (() -> Void)?
1348 let onOpenWorkflow: ((DomainWorkflow) -> Void)?
1349 let onRunWorkflow: ((DomainWorkflow) -> Void)?
1350
1351 var body: some View {
1352 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
1353 if let trackedDomain {
1354 HStack(spacing: 8) {
1355 // Icon-only: the header also carries Pin and Note, and the
1356 // full "Tracked" pill compresses at larger text sizes.
1357 // VoiceOver still hears the word via the label.
1358 Image(systemName: "eye.fill")
1359 .font(appDensity.font(.caption))
1360 .foregroundStyle(Color(.statusPositive))
1361 .padding(6)
1362 .background(Color(.statusPositiveSurface), in: Circle())
1363 .fixedSize()
1364 .accessibilityLabel("Tracked")
1365 Button {
1366 onTogglePinned()
1367 } label: {
1368 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
1369 }
1370 .buttonStyle(.bordered)
1371 .font(appDensity.font(.caption))
1372 .accessibilityLabel("Pin domain")
1373 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
1374 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
1375 if let onEditNote {
1376 Button("Note") {
1377 onEditNote()
1378 }
1379 .buttonStyle(.bordered)
1380 .font(appDensity.font(.caption))
1381 // Never compress into a vertical letter column.
1382 .fixedSize()
1383 }
1384 }
1385 } else {
1386 Button("Track") {
1387 AppHaptics.track()
1388 onTrack()
1389 }
1390 .buttonStyle(.bordered)
1391 .font(appDensity.font(.caption))
1392 .fixedSize()
1393 }
1394 } content: {
1395 CardView(allowsHorizontalScroll: false) {
1396 SectionTrustMetadataView(
1397 provenance: provenance,
1398 confidence: confidence,
1399 note: snapshotNote == nil ? nil : "Audit note present"
1400 )
1401 ForEach(rows) { row in
1402 LabeledValueRow(row: row)
1403 }
1404 if let trackedDomain {
1405 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
1406 .padding(.top, 4)
1407 } else if let trackingLimitMessage {
1408 MessageRowView(text: trackingLimitMessage, isError: false)
1409 .padding(.top, 4)
1410 }
1411 if let onAddToWorkflow {
1412 Button {
1413 onAddToWorkflow()
1414 } label: {
1415 Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
1416 .font(appDensity.font(.caption))
1417 }
1418 .buttonStyle(.bordered)
1419 .padding(.top, 4)
1420 }
1421 if !workflows.isEmpty {
1422 VStack(alignment: .leading, spacing: 8) {
1423 Text("Part of workflow")
1424 .font(appDensity.font(.caption))
1425 .foregroundStyle(Color(.appTextSecondary))
1426
1427 ForEach(workflows) { workflow in
1428 HStack {
1429 Text(workflow.name)
1430 .font(appDensity.font(.caption))
1431 .foregroundStyle(.primary)
1432 Spacer()
1433 if let onOpenWorkflow {
1434 Button("Open") {
1435 onOpenWorkflow(workflow)
1436 }
1437 .buttonStyle(.bordered)
1438 .font(appDensity.font(.caption2))
1439 }
1440 if let onRunWorkflow {
1441 Button("Run") {
1442 onRunWorkflow(workflow)
1443 }
1444 .buttonStyle(.bordered)
1445 .font(appDensity.font(.caption2))
1446 }
1447 }
1448 }
1449 }
1450 .padding(.top, 4)
1451 }
1452 if availabilityLoading {
1453 ProgressView("Checking availability…")
1454 .appLoadingStyle()
1455 .padding(.top, 4)
1456 }
1457 if showSuggestions {
1458 Text("Suggestions")
1459 .font(appDensity.font(.caption))
1460 .foregroundStyle(Color(.appTextSecondary))
1461 .padding(.top, 4)
1462 if suggestionsLoading {
1463 ProgressView("Checking alternatives…")
1464 .appLoadingStyle()
1465 } else if suggestions.isEmpty {
1466 MessageRowView(text: "No suggestions", isError: false)
1467 } else {
1468 ForEach(suggestions) { suggestion in
1469 HStack {
1470 Text(suggestion.domain)
1471 .font(appDensity.font(.caption))
1472 .foregroundStyle(.primary)
1473 .textSelection(.enabled)
1474 Spacer()
1475 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
1476 }
1477 }
1478 }
1479 }
1480 if pricingLoading {
1481 ProgressView("Loading external pricing…")
1482 .appLoadingStyle()
1483 .padding(.top, 4)
1484 } else if let pricingError {
1485 MessageRowView(text: pricingError, isError: false)
1486 .padding(.top, 4)
1487 } else if showsPricingPlaceholder {
1488 MessageRowView(text: "Pricing signals available in Pro+", isError: false)
1489 .padding(.top, 4)
1490 }
1491 }
1492 }
1493 }
1494}
1495
1496struct OwnershipSectionView: View {
1497 @Environment(\.appDensity) private var appDensity
1498 @Binding var isCollapsed: Bool
1499 let rows: [InfoRowViewData]
1500 let loading: Bool
1501 let error: String?
1502 let provenance: SectionProvenance?
1503 let confidence: ConfidenceLevel?
1504 let showsHistoryPlaceholder: Bool
1505 let history: [DomainOwnershipHistoryEvent]
1506 let historyLoading: Bool
1507 let historyError: String?
1508 let historyCreditStatus: UsageCreditStatus?
1509 let onLoadHistory: (() -> Void)?
1510
1511 init(
1512 isCollapsed: Binding<Bool>,
1513 rows: [InfoRowViewData],
1514 loading: Bool,
1515 error: String?,
1516 provenance: SectionProvenance?,
1517 confidence: ConfidenceLevel?,
1518 showsHistoryPlaceholder: Bool,
1519 history: [DomainOwnershipHistoryEvent] = [],
1520 historyLoading: Bool = false,
1521 historyError: String? = nil,
1522 historyCreditStatus: UsageCreditStatus? = nil,
1523 onLoadHistory: (() -> Void)? = nil
1524 ) {
1525 _isCollapsed = isCollapsed
1526 self.rows = rows
1527 self.loading = loading
1528 self.error = error
1529 self.provenance = provenance
1530 self.confidence = confidence
1531 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1532 self.history = history
1533 self.historyLoading = historyLoading
1534 self.historyError = historyError
1535 self.historyCreditStatus = historyCreditStatus
1536 self.onLoadHistory = onLoadHistory
1537 }
1538
1539 var body: some View {
1540 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
1541 CardView(allowsHorizontalScroll: false) {
1542 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1543 if loading {
1544 ProgressView("Fetching RDAP ownership…")
1545 .appLoadingStyle()
1546 } else {
1547 ForEach(rows) { row in
1548 LabeledValueRow(row: row)
1549 }
1550 if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
1551 MessageRowView(text: error, isError: error != "Unavailable")
1552 .padding(.top, 4)
1553 }
1554 VStack(alignment: .leading, spacing: 8) {
1555 HStack {
1556 Text("History")
1557 .font(appDensity.font(.caption))
1558 .foregroundStyle(Color(.appTextSecondary))
1559 Spacer()
1560 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1561 Button("Load") {
1562 onLoadHistory()
1563 }
1564 .buttonStyle(.bordered)
1565 .font(appDensity.font(.caption2))
1566 }
1567 }
1568 if historyLoading {
1569 ProgressView("Loading history…")
1570 .appLoadingStyle()
1571 } else if !history.isEmpty {
1572 ForEach(history) { event in
1573 VStack(alignment: .leading, spacing: 3) {
1574 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1575 .font(appDensity.font(.caption2))
1576 .foregroundStyle(Color(.appTextSecondary))
1577 Text(event.summary)
1578 .font(appDensity.font(.caption))
1579 Text(event.source)
1580 .font(appDensity.font(.caption2))
1581 .foregroundStyle(Color(.appTextSecondary))
1582 }
1583 }
1584 } else if let historyError {
1585 MessageRowView(text: historyError, isError: false)
1586 } else if showsHistoryPlaceholder {
1587 MessageRowView(text: "Ownership history available in Pro+", isError: false)
1588 }
1589 }
1590 }
1591 }
1592 }
1593 }
1594}
1595
1596struct IntelligenceSectionView: View {
1597 @Environment(\.appDensity) private var appDensity
1598 @Binding var isCollapsed: Bool
1599 let report: DomainReport
1600 let showsPlaceholder: Bool
1601
1602 var body: some View {
1603 CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
1604 CardView(allowsHorizontalScroll: false) {
1605 if showsPlaceholder {
1606 MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
1607 } else {
1608 if let provider = report.inferredProvider {
1609 intelligenceBlock(title: "Infrastructure") {
1610 LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
1611 if !provider.evidence.isEmpty {
1612 MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false)
1613 }
1614 if !report.priorProviders.isEmpty {
1615 LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
1616 }
1617 }
1618 }
1619 if let classification = report.domainClassification {
1620 intelligenceBlock(title: "Classification") {
1621 LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
1622 MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false)
1623 }
1624 }
1625 intelligenceBlock(title: "Risk Signals") {
1626 if report.riskSignals.isEmpty {
1627 MessageRowView(text: "No material historical risk signals detected", isError: false)
1628 } else {
1629 ForEach(report.riskSignals.prefix(4)) { signal in
1630 VStack(alignment: .leading, spacing: 3) {
1631 Text(signal.title)
1632 .font(appDensity.font(.caption, weight: .semibold))
1633 Text(signal.detail)
1634 .font(appDensity.font(.caption2))
1635 .foregroundStyle(Color(.appTextSecondary))
1636 }
1637 }
1638 }
1639 }
1640 intelligenceBlock(title: "Ownership History") {
1641 if report.ownershipTransitions.isEmpty {
1642 MessageRowView(text: "No ownership transitions observed locally", isError: false)
1643 } else {
1644 ForEach(report.ownershipTransitions.prefix(4)) { event in
1645 intelligenceEventRow(date: event.date, title: event.summary)
1646 }
1647 }
1648 }
1649 intelligenceBlock(title: "Hosting History") {
1650 if report.hostingTransitions.isEmpty {
1651 MessageRowView(text: "No hosting transitions observed locally", isError: false)
1652 } else {
1653 ForEach(report.hostingTransitions.prefix(4)) { event in
1654 intelligenceEventRow(date: event.date, title: event.summary)
1655 }
1656 }
1657 }
1658 intelligenceBlock(title: "Subdomain Intelligence") {
1659 if report.subdomainHistory.isEmpty {
1660 MessageRowView(text: "No subdomain history available", isError: false)
1661 } else {
1662 ForEach(report.subdomainHistory.prefix(5)) { item in
1663 VStack(alignment: .leading, spacing: 3) {
1664 HStack {
1665 Text(item.hostname)
1666 .font(appDensity.font(.caption))
1667 Spacer()
1668 if item.isEphemeral {
1669 Text("Ephemeral")
1670 .font(appDensity.font(.caption2))
1671 .foregroundStyle(Color(.statusWarning))
1672 }
1673 }
1674 Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
1675 .font(appDensity.font(.caption2))
1676 .foregroundStyle(Color(.appTextSecondary))
1677 }
1678 }
1679 }
1680 }
1681 intelligenceBlock(title: "Timeline") {
1682 if report.intelligenceTimeline.isEmpty {
1683 MessageRowView(text: "No inferred intelligence events yet", isError: false)
1684 } else {
1685 ForEach(report.intelligenceTimeline.prefix(5)) { event in
1686 intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
1687 }
1688 }
1689 }
1690 }
1691 }
1692 }
1693 }
1694
1695 @ViewBuilder
1696 private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
1697 VStack(alignment: .leading, spacing: 8) {
1698 Text(title)
1699 .font(appDensity.font(.subheadline, weight: .semibold))
1700 .foregroundStyle(Color(.statusInfo))
1701 content()
1702 }
1703 }
1704
1705 private func intelligenceEventRow(date: Date, title: String) -> some View {
1706 VStack(alignment: .leading, spacing: 3) {
1707 Text(date.formatted(date: .abbreviated, time: .omitted))
1708 .font(appDensity.font(.caption2))
1709 .foregroundStyle(Color(.appTextSecondary))
1710 Text(title)
1711 .font(appDensity.font(.caption))
1712 }
1713 }
1714}
1715
1716struct SubdomainsSectionView: View {
1717 @Environment(\.appDensity) private var appDensity
1718 @Binding var isCollapsed: Bool
1719 let rows: [SubdomainRowViewData]
1720 let groups: [SubdomainGroup]
1721 let loading: Bool
1722 let error: String?
1723 let provenance: SectionProvenance?
1724 let confidence: ConfidenceLevel?
1725 let showsExtendedPlaceholder: Bool
1726 let extendedCount: Int
1727 let extendedLoading: Bool
1728 let extendedError: String?
1729 let extendedCreditStatus: UsageCreditStatus?
1730 let onLoadExtended: (() -> Void)?
1731
1732 init(
1733 isCollapsed: Binding<Bool>,
1734 rows: [SubdomainRowViewData],
1735 groups: [SubdomainGroup],
1736 loading: Bool,
1737 error: String?,
1738 provenance: SectionProvenance?,
1739 confidence: ConfidenceLevel?,
1740 showsExtendedPlaceholder: Bool,
1741 extendedCount: Int = 0,
1742 extendedLoading: Bool = false,
1743 extendedError: String? = nil,
1744 extendedCreditStatus: UsageCreditStatus? = nil,
1745 onLoadExtended: (() -> Void)? = nil
1746 ) {
1747 _isCollapsed = isCollapsed
1748 self.rows = rows
1749 self.groups = groups
1750 self.loading = loading
1751 self.error = error
1752 self.provenance = provenance
1753 self.confidence = confidence
1754 self.showsExtendedPlaceholder = showsExtendedPlaceholder
1755 self.extendedCount = extendedCount
1756 self.extendedLoading = extendedLoading
1757 self.extendedError = extendedError
1758 self.extendedCreditStatus = extendedCreditStatus
1759 self.onLoadExtended = onLoadExtended
1760 }
1761
1762 var body: some View {
1763 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
1764 CardView(allowsHorizontalScroll: false) {
1765 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1766 if loading {
1767 ProgressView("Checking certificate transparency…")
1768 .appLoadingStyle()
1769 } else if rows.isEmpty {
1770 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1771 if showsExtendedPlaceholder {
1772 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1773 .padding(.top, 4)
1774 }
1775 } else {
1776 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1777 Button("Load extended results") {
1778 onLoadExtended()
1779 }
1780 .buttonStyle(.bordered)
1781 .font(appDensity.font(.caption2))
1782 }
1783 if !groups.isEmpty {
1784 Text("Groups")
1785 .font(appDensity.font(.caption2))
1786 .foregroundStyle(Color(.appTextSecondary))
1787 ForEach(groups) { group in
1788 HStack {
1789 Text("\(group.label).*")
1790 .font(appDensity.font(.caption))
1791 .foregroundStyle(Color(.statusInfo))
1792 Spacer()
1793 Text("\(group.subdomains.count)")
1794 .font(appDensity.font(.caption2))
1795 .foregroundStyle(Color(.appTextSecondary))
1796 }
1797 }
1798 }
1799 ForEach(rows) { row in
1800 HStack(spacing: 8) {
1801 Text(row.hostname)
1802 .font(appDensity.font(.caption))
1803 .foregroundStyle(.primary)
1804 .textSelection(.enabled)
1805 Spacer()
1806 if row.isInteresting {
1807 Text("Interesting")
1808 .font(.system(.caption2, design: .monospaced))
1809 .foregroundStyle(Color(.statusWarning))
1810 .padding(.horizontal, 8)
1811 .padding(.vertical, 4)
1812 .background(Color(.statusWarningSurface))
1813 .clipShape(Capsule())
1814 }
1815 }
1816 }
1817 if extendedLoading {
1818 ProgressView("Loading extended subdomains…")
1819 .appLoadingStyle()
1820 .padding(.top, 4)
1821 } else if extendedCount > 0 {
1822 MessageRowView(text: "\(extendedCount) extended results included", isError: false)
1823 .padding(.top, 4)
1824 } else if let extendedError {
1825 MessageRowView(text: extendedError, isError: false)
1826 .padding(.top, 4)
1827 } else if showsExtendedPlaceholder {
1828 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1829 .padding(.top, 4)
1830 }
1831 }
1832 }
1833 }
1834 }
1835}
1836
1837struct DNSSectionView: View {
1838 @Environment(\.appDensity) private var appDensity
1839 @Binding var isCollapsed: Bool
1840 let dnssecLabel: String?
1841 let patternSummary: DNSPatternSummary?
1842 let sections: [DNSRecordSectionViewData]
1843 let ptrMessage: SectionMessageViewData?
1844 let loading: Bool
1845 let dnsProvenance: SectionProvenance?
1846 let ptrProvenance: SectionProvenance?
1847 let sectionError: String?
1848 let history: [DNSHistoryEvent]
1849 let historyLoading: Bool
1850 let historyError: String?
1851 let showsHistoryPlaceholder: Bool
1852 let historyCreditStatus: UsageCreditStatus?
1853 let onLoadHistory: (() -> Void)?
1854
1855 init(
1856 isCollapsed: Binding<Bool>,
1857 dnssecLabel: String?,
1858 patternSummary: DNSPatternSummary?,
1859 sections: [DNSRecordSectionViewData],
1860 ptrMessage: SectionMessageViewData?,
1861 loading: Bool,
1862 dnsProvenance: SectionProvenance?,
1863 ptrProvenance: SectionProvenance?,
1864 sectionError: String?,
1865 history: [DNSHistoryEvent] = [],
1866 historyLoading: Bool = false,
1867 historyError: String? = nil,
1868 showsHistoryPlaceholder: Bool = false,
1869 historyCreditStatus: UsageCreditStatus? = nil,
1870 onLoadHistory: (() -> Void)? = nil
1871 ) {
1872 _isCollapsed = isCollapsed
1873 self.dnssecLabel = dnssecLabel
1874 self.patternSummary = patternSummary
1875 self.sections = sections
1876 self.ptrMessage = ptrMessage
1877 self.loading = loading
1878 self.dnsProvenance = dnsProvenance
1879 self.ptrProvenance = ptrProvenance
1880 self.sectionError = sectionError
1881 self.history = history
1882 self.historyLoading = historyLoading
1883 self.historyError = historyError
1884 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1885 self.historyCreditStatus = historyCreditStatus
1886 self.onLoadHistory = onLoadHistory
1887 }
1888
1889 var body: some View {
1890 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
1891 if loading {
1892 LoadingCardView(text: "Querying DNS…")
1893 } else if let sectionError, sections.isEmpty {
1894 MessageCardView(text: sectionError, isError: true)
1895 } else {
1896 if dnsProvenance != nil {
1897 CardView(allowsHorizontalScroll: false) {
1898 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1899 if let patternSummary {
1900 if !patternSummary.providers.isEmpty {
1901 MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
1902 }
1903 if !patternSummary.patterns.isEmpty {
1904 ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
1905 MessageRowView(text: pattern, isError: false)
1906 }
1907 }
1908 }
1909 }
1910 }
1911 ForEach(sections) { section in
1912 CardView {
1913 Text(section.title)
1914 .font(.system(.subheadline, design: .monospaced))
1915 .fontWeight(.semibold)
1916 .foregroundStyle(Color(.statusInfo))
1917
1918 if let message = section.message {
1919 MessageRowView(text: message.text, isError: message.isError)
1920 }
1921
1922 ForEach(section.rows) { row in
1923 LabeledValueRow(row: row)
1924 }
1925
1926 if let wildcardTitle = section.wildcardTitle {
1927 Text(wildcardTitle)
1928 .font(.system(.caption, design: .monospaced))
1929 .foregroundStyle(Color(.appTextSecondary))
1930 .padding(.top, 4)
1931 ForEach(section.wildcardRows) { row in
1932 LabeledValueRow(row: row)
1933 }
1934 }
1935 }
1936 }
1937
1938 if let ptrMessage {
1939 CardView {
1940 Text("PTR")
1941 .font(.system(.subheadline, design: .monospaced))
1942 .fontWeight(.semibold)
1943 .foregroundStyle(Color(.statusInfo))
1944 SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
1945 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
1946 }
1947 }
1948
1949 CardView(allowsHorizontalScroll: false) {
1950 HStack {
1951 Text("History")
1952 .font(appDensity.font(.subheadline, weight: .semibold))
1953 .foregroundStyle(Color(.statusInfo))
1954 Spacer()
1955 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1956 Button("Load") {
1957 onLoadHistory()
1958 }
1959 .buttonStyle(.bordered)
1960 .font(appDensity.font(.caption2))
1961 }
1962 }
1963 if historyLoading {
1964 ProgressView("Loading DNS history…")
1965 .appLoadingStyle()
1966 } else if !history.isEmpty {
1967 ForEach(history) { event in
1968 VStack(alignment: .leading, spacing: 3) {
1969 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1970 .font(appDensity.font(.caption2))
1971 .foregroundStyle(Color(.appTextSecondary))
1972 Text(event.summary)
1973 .font(appDensity.font(.caption))
1974 if !event.aRecords.isEmpty {
1975 Text("A: \(event.aRecords.joined(separator: ", "))")
1976 .font(appDensity.font(.caption2))
1977 .foregroundStyle(Color(.appTextSecondary))
1978 }
1979 if !event.nameservers.isEmpty {
1980 Text("NS: \(event.nameservers.joined(separator: ", "))")
1981 .font(appDensity.font(.caption2))
1982 .foregroundStyle(Color(.appTextSecondary))
1983 }
1984 }
1985 }
1986 } else if let historyError {
1987 MessageRowView(text: historyError, isError: false)
1988 } else if showsHistoryPlaceholder {
1989 MessageRowView(text: "DNS history available in Pro+", isError: false)
1990 }
1991 }
1992 }
1993 }
1994 }
1995}
1996
1997struct WebSectionView: View {
1998 @Environment(\.appDensity) private var appDensity
1999 @Binding var isCollapsed: Bool
2000 let certificateRows: [InfoRowViewData]
2001 let sslInfo: SSLCertificateInfo?
2002 let tlsSummary: WebResultSummary?
2003 let sslLoading: Bool
2004 let sslError: String?
2005 let tlsProvenance: SectionProvenance?
2006 let responseRows: [InfoRowViewData]
2007 let headers: [HTTPHeader]
2008 let headersLoading: Bool
2009 let headersError: String?
2010 let httpProvenance: SectionProvenance?
2011 let redirects: [RedirectHopViewData]
2012 let redirectLoading: Bool
2013 let redirectError: String?
2014 let redirectProvenance: SectionProvenance?
2015 let finalURL: String?
2016
2017 var body: some View {
2018 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
2019 CardView {
2020 HStack {
2021 Text("TLS")
2022 .font(appDensity.font(.subheadline, weight: .semibold))
2023 .foregroundStyle(Color(.statusInfo))
2024 Spacer()
2025 if !sslLoading {
2026 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
2027 }
2028 }
2029 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
2030 if !sslLoading, let tlsSummary {
2031 LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
2032 ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
2033 MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
2034 }
2035 }
2036 if sslLoading {
2037 ProgressView("Checking certificate…")
2038 .appLoadingStyle()
2039 } else if let sslError {
2040 MessageRowView(text: sslError, isError: true)
2041 } else {
2042 ForEach(certificateRows) { row in
2043 LabeledValueRow(row: row)
2044 }
2045 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
2046 Text("SANs")
2047 .font(appDensity.font(.caption2))
2048 .foregroundStyle(Color(.appTextSecondary))
2049 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
2050 HStack(alignment: .top, spacing: 8) {
2051 Text(san)
2052 .font(appDensity.font(.caption))
2053 .lineLimit(nil)
2054 .fixedSize(horizontal: false, vertical: true)
2055 .textSelection(.enabled)
2056 Spacer()
2057 AppCopyButton(value: san, label: "Copy certificate SAN")
2058 }
2059 }
2060 }
2061 }
2062 }
2063
2064 CardView {
2065 Text("Headers")
2066 .font(appDensity.font(.subheadline, weight: .semibold))
2067 .foregroundStyle(Color(.statusInfo))
2068 SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
2069 if headersLoading {
2070 ProgressView("Fetching headers…")
2071 .appLoadingStyle()
2072 } else if let headersError {
2073 MessageRowView(text: headersError, isError: true)
2074 } else {
2075 ForEach(responseRows) { row in
2076 LabeledValueRow(row: row)
2077 }
2078 if headers.isEmpty {
2079 MessageRowView(text: "No HTTP headers returned", isError: false)
2080 } else {
2081 ForEach(headers) { header in
2082 HStack(alignment: .top, spacing: 4) {
2083 Text(header.name + ":")
2084 .font(appDensity.font(.caption))
2085 .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
2086 Text(header.value)
2087 .font(appDensity.font(.caption))
2088 .foregroundStyle(.primary)
2089 .textSelection(.enabled)
2090 }
2091 }
2092 }
2093 }
2094 }
2095
2096 CardView {
2097 HStack {
2098 Text("Redirects")
2099 .font(appDensity.font(.subheadline, weight: .semibold))
2100 .foregroundStyle(Color(.statusInfo))
2101 Spacer()
2102 if let finalURL {
2103 AppCopyButton(value: finalURL, label: "Copy redirect URL")
2104 }
2105 }
2106 SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
2107 if redirectLoading {
2108 ProgressView("Tracing redirects…")
2109 .appLoadingStyle()
2110 } else if let redirectError {
2111 MessageRowView(text: redirectError, isError: true)
2112 } else if redirects.isEmpty {
2113 MessageRowView(text: "No redirect data available", isError: false)
2114 } else {
2115 if let finalURL {
2116 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
2117 }
2118 ForEach(redirects) { redirect in
2119 HStack(alignment: .top, spacing: 6) {
2120 Text(redirect.stepLabel)
2121 .font(appDensity.font(.caption))
2122 .foregroundStyle(Color(.appTextSecondary))
2123 .frame(width: 16, alignment: .trailing)
2124 Text(redirect.statusCode)
2125 .font(appDensity.font(.caption))
2126 .foregroundStyle(Color(.statusInfo))
2127 .frame(width: 36, alignment: .leading)
2128 Text(redirect.url)
2129 .font(appDensity.font(.caption))
2130 .textSelection(.enabled)
2131 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
2132 if redirect.isFinal {
2133 Text("(final)")
2134 .font(appDensity.font(.caption2))
2135 .foregroundStyle(Color(.appTextSecondary))
2136 }
2137 }
2138 }
2139 }
2140 }
2141 }
2142 }
2143
2144 private func isTLSHighlightError(_ highlight: String) -> Bool {
2145 let normalized = highlight.lowercased()
2146 if normalized.contains("no weak tls indicators were detected") {
2147 return false
2148 }
2149 return normalized.contains("expires")
2150 || normalized.contains("weak")
2151 || normalized.contains("tls 1.0")
2152 || normalized.contains("tls 1.1")
2153 }
2154}
2155
2156struct EmailSectionView: View {
2157 @Environment(\.appDensity) private var appDensity
2158 @Binding var isCollapsed: Bool
2159 let rows: [EmailRowViewData]
2160 let assessment: EmailSecuritySummary?
2161 let loading: Bool
2162 let provenance: SectionProvenance?
2163 let confidence: ConfidenceLevel?
2164 let error: String?
2165
2166 var body: some View {
2167 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
2168 CardView {
2169 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
2170 HStack {
2171 Spacer()
2172 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
2173 .opacity(loading ? 0 : 1)
2174 }
2175 if let assessment, let grade = assessment.grade {
2176 LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
2177 if !assessment.reasons.isEmpty {
2178 Text(assessment.reasons.joined(separator: " | "))
2179 .font(appDensity.font(.caption2))
2180 .foregroundStyle(Color(.appTextSecondary))
2181 }
2182 }
2183 if loading {
2184 ProgressView("Checking email records…")
2185 .appLoadingStyle()
2186 } else if let error {
2187 MessageRowView(text: error, isError: true)
2188 } else if rows.isEmpty {
2189 MessageRowView(text: "No email security records found", isError: false)
2190 } else {
2191 ForEach(rows) { row in
2192 VStack(alignment: .leading, spacing: 4) {
2193 HStack(spacing: 8) {
2194 Text(row.label)
2195 .font(appDensity.font(.caption))
2196 .foregroundStyle(Color(.statusInfo))
2197 .frame(width: 76, alignment: .leading)
2198 AppStatusBadgeView(model: emailRowBadge(row))
2199 }
2200 Text(row.detail)
2201 .font(appDensity.font(.caption2))
2202 .foregroundStyle(.primary)
2203 .textSelection(.enabled)
2204 if let auxiliaryDetail = row.auxiliaryDetail {
2205 Text(auxiliaryDetail)
2206 .font(appDensity.font(.caption2))
2207 .foregroundStyle(Color(.appTextSecondary))
2208 }
2209 }
2210 }
2211 }
2212 }
2213 }
2214 }
2215
2216 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
2217 switch row.statusTone {
2218 case .success:
2219 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2220 case .warning:
2221 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2222 case .failure:
2223 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2224 case .primary, .secondary:
2225 return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2226 }
2227 }
2228}
2229
2230struct NetworkSectionView: View {
2231 @Environment(\.appDensity) private var appDensity
2232 @Binding var isCollapsed: Bool
2233 let reachabilityRows: [ReachabilityRowViewData]
2234 let reachabilityLoading: Bool
2235 let reachabilityError: String?
2236 let reachabilityProvenance: SectionProvenance?
2237 let locationRows: [InfoRowViewData]
2238 let geolocation: IPGeolocation?
2239 let geolocationLoading: Bool
2240 let geolocationError: String?
2241 let geolocationProvenance: SectionProvenance?
2242 let geolocationConfidence: ConfidenceLevel?
2243 let standardPortRows: [PortScanRowViewData]
2244 let customPortRows: [PortScanRowViewData]
2245 let portScanLoading: Bool
2246 let portScanError: String?
2247 let portScanProvenance: SectionProvenance?
2248 let customPortScanLoading: Bool
2249 let customPortScanError: String?
2250 let isCloudflareProxied: Bool
2251 @Binding var customPortsExpanded: Bool
2252 @Binding var customPortInput: String
2253 let onScanCustomPorts: () -> Void
2254
2255 var body: some View {
2256 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
2257 CardView {
2258 Text("Reachability")
2259 .font(appDensity.font(.subheadline, weight: .semibold))
2260 .foregroundStyle(Color(.statusInfo))
2261 SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
2262 if reachabilityLoading {
2263 ProgressView("Checking ports…")
2264 .appLoadingStyle()
2265 } else if let reachabilityError {
2266 MessageRowView(text: reachabilityError, isError: true)
2267 } else {
2268 ForEach(reachabilityRows) { row in
2269 HStack {
2270 Text(row.portLabel)
2271 .font(appDensity.font(.caption))
2272 Spacer()
2273 Text(row.latencyLabel)
2274 .font(appDensity.font(.caption2))
2275 .foregroundStyle(Color(.appTextSecondary))
2276 AppStatusBadgeView(model: reachabilityBadge(row))
2277 }
2278 }
2279 }
2280 }
2281
2282 CardView(allowsHorizontalScroll: false) {
2283 Text("Location")
2284 .font(appDensity.font(.subheadline, weight: .semibold))
2285 .foregroundStyle(Color(.statusInfo))
2286 SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
2287 if geolocationLoading {
2288 ProgressView("Looking up location…")
2289 .appLoadingStyle()
2290 } else if let geolocationError, geolocation == nil {
2291 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
2292 } else if let geolocation {
2293 ForEach(locationRows) { row in
2294 LabeledValueRow(row: row)
2295 }
2296 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
2297 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
2298 Map(initialPosition: .region(MKCoordinateRegion(
2299 center: coordinate,
2300 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
2301 ))) {
2302 Marker(geolocation.ip, coordinate: coordinate)
2303 }
2304 .mapStyle(.standard)
2305 .frame(maxWidth: .infinity)
2306 .frame(height: 180)
2307 .cornerRadius(8)
2308 }
2309 } else {
2310 MessageRowView(text: "No location data available", isError: false)
2311 }
2312 }
2313
2314 CardView(allowsHorizontalScroll: false) {
2315 Text("Port Scan")
2316 .font(appDensity.font(.subheadline, weight: .semibold))
2317 .foregroundStyle(Color(.statusInfo))
2318 SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
2319
2320 if isCloudflareProxied {
2321 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
2322 .font(appDensity.font(.caption2))
2323 .foregroundStyle(Color(.statusWarning))
2324 .fixedSize(horizontal: false, vertical: true)
2325 }
2326
2327 if portScanLoading {
2328 ProgressView("Scanning ports…")
2329 .appLoadingStyle()
2330 } else if let portScanError, standardPortRows.isEmpty {
2331 MessageRowView(text: portScanError, isError: true)
2332 } else {
2333 Text("Standard Ports")
2334 .font(.system(.caption, design: .monospaced))
2335 .foregroundStyle(Color(.appTextSecondary))
2336 PortRowsView(rows: standardPortRows)
2337 }
2338
2339 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
2340 VStack(alignment: .leading, spacing: 10) {
2341 TextField("8888, 9000, 27017", text: $customPortInput)
2342 .font(appDensity.font(.caption))
2343 .textInputAutocapitalization(.never)
2344 .autocorrectionDisabled()
2345 .keyboardType(.numberPad)
2346 .padding(10)
2347 .background(Color(.appSurface))
2348 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2349
2350 Button("Scan") {
2351 AppHaptics.refresh()
2352 onScanCustomPorts()
2353 }
2354 .buttonStyle(.borderedProminent)
2355 .tint(Color(.accentFill))
2356 .disabled(customPortScanLoading)
2357
2358 if customPortScanLoading {
2359 ProgressView("Scanning custom ports…")
2360 .appLoadingStyle()
2361 } else if let customPortScanError {
2362 MessageRowView(text: customPortScanError, isError: true)
2363 } else {
2364 PortRowsView(rows: customPortRows)
2365 }
2366 }
2367 .padding(.top, 8)
2368 }
2369 .font(.system(.caption, design: .monospaced))
2370 .tint(.secondary)
2371 }
2372 }
2373 }
2374
2375 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
2376 switch row.statusTone {
2377 case .success:
2378 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2379 case .warning:
2380 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2381 case .failure:
2382 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2383 case .primary, .secondary:
2384 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2385 }
2386 }
2387}
2388
2389struct PortRowsView: View {
2390 @Environment(\.appDensity) private var appDensity
2391 let rows: [PortScanRowViewData]
2392
2393 var body: some View {
2394 if rows.isEmpty {
2395 MessageRowView(text: "No results", isError: false)
2396 } else {
2397 ForEach(rows) { row in
2398 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2399 HStack {
2400 Text(row.portLabel)
2401 .font(appDensity.font(.caption))
2402 .frame(width: 52, alignment: .leading)
2403 Text(row.service)
2404 .font(appDensity.font(.caption))
2405 .foregroundStyle(.primary)
2406 Spacer()
2407 if let durationLabel = row.durationLabel {
2408 Text(durationLabel)
2409 .font(appDensity.font(.caption2))
2410 .foregroundStyle(Color(.appTextSecondary))
2411 }
2412 AppStatusBadgeView(model: portBadge(row))
2413 }
2414 if let banner = row.banner {
2415 Text(banner)
2416 .font(appDensity.font(.caption2))
2417 .foregroundStyle(Color(.appTextSecondary))
2418 .padding(.leading, 8)
2419 }
2420 }
2421 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2422 }
2423 }
2424 }
2425
2426 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
2427 switch row.statusTone {
2428 case .success:
2429 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2430 case .warning:
2431 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2432 case .failure:
2433 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2434 case .primary, .secondary:
2435 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2436 }
2437 }
2438}
2439
24401327struct SectionTitleView: View {
24411328 @Environment(\.appDensity) private var appDensity
24421329 let title: String
@@ -2691,7 +1578,7 @@ extension DateFormatter {
26911578 }()
26921579}
26931580
2694private extension View {
1581extension View {
26951582 func appLoadingStyle() -> some View {
26961583 font(.system(.caption, design: .monospaced))
26971584 }
DomainDig/ResultSectionViews.swift added +1121
@@ -0,0 +1,1121 @@
1import MapKit
2import SwiftUI
3
4// Result detail section views extracted from ContentView.swift — one per
5// inspection domain (availability/DNS, ownership, intelligence, subdomains,
6// DNS records, web, email, network, ports). Pure presentation over the
7// view-model report data; the shared primitives (CardView, SectionTitleView,
8// LabeledValueRow, ResultColors, appLoadingStyle) remain in ContentView.swift.
9
10struct DomainSectionView: View {
11 @Environment(\.appDensity) private var appDensity
12 @Binding var isCollapsed: Bool
13 let rows: [InfoRowViewData]
14 let suggestions: [DomainSuggestionViewData]
15 let showSuggestions: Bool
16 let availabilityLoading: Bool
17 let suggestionsLoading: Bool
18 let provenance: SectionProvenance?
19 let confidence: ConfidenceLevel?
20 let snapshotNote: String?
21 let trackedDomain: TrackedDomain?
22 let workflows: [DomainWorkflow]
23 let trackingLimitMessage: String?
24 let pricingLoading: Bool
25 let pricingError: String?
26 let showsPricingPlaceholder: Bool
27 let onTrack: () -> Void
28 let onTogglePinned: () -> Void
29 let onEditNote: (() -> Void)?
30 let onAddToWorkflow: (() -> Void)?
31 let onOpenWorkflow: ((DomainWorkflow) -> Void)?
32 let onRunWorkflow: ((DomainWorkflow) -> Void)?
33
34 var body: some View {
35 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
36 if let trackedDomain {
37 HStack(spacing: 8) {
38 // Icon-only: the header also carries Pin and Note, and the
39 // full "Tracked" pill compresses at larger text sizes.
40 // VoiceOver still hears the word via the label.
41 Image(systemName: "eye.fill")
42 .font(appDensity.font(.caption))
43 .foregroundStyle(Color(.statusPositive))
44 .padding(6)
45 .background(Color(.statusPositiveSurface), in: Circle())
46 .fixedSize()
47 .accessibilityLabel("Tracked")
48 Button {
49 onTogglePinned()
50 } label: {
51 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
52 }
53 .buttonStyle(.bordered)
54 .font(appDensity.font(.caption))
55 .accessibilityLabel("Pin domain")
56 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
57 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
58 if let onEditNote {
59 Button("Note") {
60 onEditNote()
61 }
62 .buttonStyle(.bordered)
63 .font(appDensity.font(.caption))
64 // Never compress into a vertical letter column.
65 .fixedSize()
66 }
67 }
68 } else {
69 Button("Track") {
70 AppHaptics.track()
71 onTrack()
72 }
73 .buttonStyle(.bordered)
74 .font(appDensity.font(.caption))
75 .fixedSize()
76 }
77 } content: {
78 CardView(allowsHorizontalScroll: false) {
79 SectionTrustMetadataView(
80 provenance: provenance,
81 confidence: confidence,
82 note: snapshotNote == nil ? nil : "Audit note present"
83 )
84 ForEach(rows) { row in
85 LabeledValueRow(row: row)
86 }
87 if let trackedDomain {
88 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
89 .padding(.top, 4)
90 } else if let trackingLimitMessage {
91 MessageRowView(text: trackingLimitMessage, isError: false)
92 .padding(.top, 4)
93 }
94 if let onAddToWorkflow {
95 Button {
96 onAddToWorkflow()
97 } label: {
98 Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
99 .font(appDensity.font(.caption))
100 }
101 .buttonStyle(.bordered)
102 .padding(.top, 4)
103 }
104 if !workflows.isEmpty {
105 VStack(alignment: .leading, spacing: 8) {
106 Text("Part of workflow")
107 .font(appDensity.font(.caption))
108 .foregroundStyle(Color(.appTextSecondary))
109
110 ForEach(workflows) { workflow in
111 HStack {
112 Text(workflow.name)
113 .font(appDensity.font(.caption))
114 .foregroundStyle(.primary)
115 Spacer()
116 if let onOpenWorkflow {
117 Button("Open") {
118 onOpenWorkflow(workflow)
119 }
120 .buttonStyle(.bordered)
121 .font(appDensity.font(.caption2))
122 }
123 if let onRunWorkflow {
124 Button("Run") {
125 onRunWorkflow(workflow)
126 }
127 .buttonStyle(.bordered)
128 .font(appDensity.font(.caption2))
129 }
130 }
131 }
132 }
133 .padding(.top, 4)
134 }
135 if availabilityLoading {
136 ProgressView("Checking availability…")
137 .appLoadingStyle()
138 .padding(.top, 4)
139 }
140 if showSuggestions {
141 Text("Suggestions")
142 .font(appDensity.font(.caption))
143 .foregroundStyle(Color(.appTextSecondary))
144 .padding(.top, 4)
145 if suggestionsLoading {
146 ProgressView("Checking alternatives…")
147 .appLoadingStyle()
148 } else if suggestions.isEmpty {
149 MessageRowView(text: "No suggestions", isError: false)
150 } else {
151 ForEach(suggestions) { suggestion in
152 HStack {
153 Text(suggestion.domain)
154 .font(appDensity.font(.caption))
155 .foregroundStyle(.primary)
156 .textSelection(.enabled)
157 Spacer()
158 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
159 }
160 }
161 }
162 }
163 if pricingLoading {
164 ProgressView("Loading external pricing…")
165 .appLoadingStyle()
166 .padding(.top, 4)
167 } else if let pricingError {
168 MessageRowView(text: pricingError, isError: false)
169 .padding(.top, 4)
170 } else if showsPricingPlaceholder {
171 MessageRowView(text: "Pricing signals available in Pro+", isError: false)
172 .padding(.top, 4)
173 }
174 }
175 }
176 }
177}
178
179struct OwnershipSectionView: View {
180 @Environment(\.appDensity) private var appDensity
181 @Binding var isCollapsed: Bool
182 let rows: [InfoRowViewData]
183 let loading: Bool
184 let error: String?
185 let provenance: SectionProvenance?
186 let confidence: ConfidenceLevel?
187 let showsHistoryPlaceholder: Bool
188 let history: [DomainOwnershipHistoryEvent]
189 let historyLoading: Bool
190 let historyError: String?
191 let historyCreditStatus: UsageCreditStatus?
192 let onLoadHistory: (() -> Void)?
193
194 init(
195 isCollapsed: Binding<Bool>,
196 rows: [InfoRowViewData],
197 loading: Bool,
198 error: String?,
199 provenance: SectionProvenance?,
200 confidence: ConfidenceLevel?,
201 showsHistoryPlaceholder: Bool,
202 history: [DomainOwnershipHistoryEvent] = [],
203 historyLoading: Bool = false,
204 historyError: String? = nil,
205 historyCreditStatus: UsageCreditStatus? = nil,
206 onLoadHistory: (() -> Void)? = nil
207 ) {
208 _isCollapsed = isCollapsed
209 self.rows = rows
210 self.loading = loading
211 self.error = error
212 self.provenance = provenance
213 self.confidence = confidence
214 self.showsHistoryPlaceholder = showsHistoryPlaceholder
215 self.history = history
216 self.historyLoading = historyLoading
217 self.historyError = historyError
218 self.historyCreditStatus = historyCreditStatus
219 self.onLoadHistory = onLoadHistory
220 }
221
222 var body: some View {
223 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
224 CardView(allowsHorizontalScroll: false) {
225 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
226 if loading {
227 ProgressView("Fetching RDAP ownership…")
228 .appLoadingStyle()
229 } else {
230 ForEach(rows) { row in
231 LabeledValueRow(row: row)
232 }
233 if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
234 MessageRowView(text: error, isError: error != "Unavailable")
235 .padding(.top, 4)
236 }
237 VStack(alignment: .leading, spacing: 8) {
238 HStack {
239 Text("History")
240 .font(appDensity.font(.caption))
241 .foregroundStyle(Color(.appTextSecondary))
242 Spacer()
243 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
244 Button("Load") {
245 onLoadHistory()
246 }
247 .buttonStyle(.bordered)
248 .font(appDensity.font(.caption2))
249 }
250 }
251 if historyLoading {
252 ProgressView("Loading history…")
253 .appLoadingStyle()
254 } else if !history.isEmpty {
255 ForEach(history) { event in
256 VStack(alignment: .leading, spacing: 3) {
257 Text(event.date.formatted(date: .abbreviated, time: .omitted))
258 .font(appDensity.font(.caption2))
259 .foregroundStyle(Color(.appTextSecondary))
260 Text(event.summary)
261 .font(appDensity.font(.caption))
262 Text(event.source)
263 .font(appDensity.font(.caption2))
264 .foregroundStyle(Color(.appTextSecondary))
265 }
266 }
267 } else if let historyError {
268 MessageRowView(text: historyError, isError: false)
269 } else if showsHistoryPlaceholder {
270 MessageRowView(text: "Ownership history available in Pro+", isError: false)
271 }
272 }
273 }
274 }
275 }
276 }
277}
278
279struct IntelligenceSectionView: View {
280 @Environment(\.appDensity) private var appDensity
281 @Binding var isCollapsed: Bool
282 let report: DomainReport
283 let showsPlaceholder: Bool
284
285 var body: some View {
286 CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
287 CardView(allowsHorizontalScroll: false) {
288 if showsPlaceholder {
289 MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
290 } else {
291 if let provider = report.inferredProvider {
292 intelligenceBlock(title: "Infrastructure") {
293 LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
294 if !provider.evidence.isEmpty {
295 MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false)
296 }
297 if !report.priorProviders.isEmpty {
298 LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
299 }
300 }
301 }
302 if let classification = report.domainClassification {
303 intelligenceBlock(title: "Classification") {
304 LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
305 MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false)
306 }
307 }
308 intelligenceBlock(title: "Risk Signals") {
309 if report.riskSignals.isEmpty {
310 MessageRowView(text: "No material historical risk signals detected", isError: false)
311 } else {
312 ForEach(report.riskSignals.prefix(4)) { signal in
313 VStack(alignment: .leading, spacing: 3) {
314 Text(signal.title)
315 .font(appDensity.font(.caption, weight: .semibold))
316 Text(signal.detail)
317 .font(appDensity.font(.caption2))
318 .foregroundStyle(Color(.appTextSecondary))
319 }
320 }
321 }
322 }
323 intelligenceBlock(title: "Ownership History") {
324 if report.ownershipTransitions.isEmpty {
325 MessageRowView(text: "No ownership transitions observed locally", isError: false)
326 } else {
327 ForEach(report.ownershipTransitions.prefix(4)) { event in
328 intelligenceEventRow(date: event.date, title: event.summary)
329 }
330 }
331 }
332 intelligenceBlock(title: "Hosting History") {
333 if report.hostingTransitions.isEmpty {
334 MessageRowView(text: "No hosting transitions observed locally", isError: false)
335 } else {
336 ForEach(report.hostingTransitions.prefix(4)) { event in
337 intelligenceEventRow(date: event.date, title: event.summary)
338 }
339 }
340 }
341 intelligenceBlock(title: "Subdomain Intelligence") {
342 if report.subdomainHistory.isEmpty {
343 MessageRowView(text: "No subdomain history available", isError: false)
344 } else {
345 ForEach(report.subdomainHistory.prefix(5)) { item in
346 VStack(alignment: .leading, spacing: 3) {
347 HStack {
348 Text(item.hostname)
349 .font(appDensity.font(.caption))
350 Spacer()
351 if item.isEphemeral {
352 Text("Ephemeral")
353 .font(appDensity.font(.caption2))
354 .foregroundStyle(Color(.statusWarning))
355 }
356 }
357 Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
358 .font(appDensity.font(.caption2))
359 .foregroundStyle(Color(.appTextSecondary))
360 }
361 }
362 }
363 }
364 intelligenceBlock(title: "Timeline") {
365 if report.intelligenceTimeline.isEmpty {
366 MessageRowView(text: "No inferred intelligence events yet", isError: false)
367 } else {
368 ForEach(report.intelligenceTimeline.prefix(5)) { event in
369 intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
370 }
371 }
372 }
373 }
374 }
375 }
376 }
377
378 @ViewBuilder
379 private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
380 VStack(alignment: .leading, spacing: 8) {
381 Text(title)
382 .font(appDensity.font(.subheadline, weight: .semibold))
383 .foregroundStyle(Color(.statusInfo))
384 content()
385 }
386 }
387
388 private func intelligenceEventRow(date: Date, title: String) -> some View {
389 VStack(alignment: .leading, spacing: 3) {
390 Text(date.formatted(date: .abbreviated, time: .omitted))
391 .font(appDensity.font(.caption2))
392 .foregroundStyle(Color(.appTextSecondary))
393 Text(title)
394 .font(appDensity.font(.caption))
395 }
396 }
397}
398
399struct SubdomainsSectionView: View {
400 @Environment(\.appDensity) private var appDensity
401 @Binding var isCollapsed: Bool
402 let rows: [SubdomainRowViewData]
403 let groups: [SubdomainGroup]
404 let loading: Bool
405 let error: String?
406 let provenance: SectionProvenance?
407 let confidence: ConfidenceLevel?
408 let showsExtendedPlaceholder: Bool
409 let extendedCount: Int
410 let extendedLoading: Bool
411 let extendedError: String?
412 let extendedCreditStatus: UsageCreditStatus?
413 let onLoadExtended: (() -> Void)?
414
415 init(
416 isCollapsed: Binding<Bool>,
417 rows: [SubdomainRowViewData],
418 groups: [SubdomainGroup],
419 loading: Bool,
420 error: String?,
421 provenance: SectionProvenance?,
422 confidence: ConfidenceLevel?,
423 showsExtendedPlaceholder: Bool,
424 extendedCount: Int = 0,
425 extendedLoading: Bool = false,
426 extendedError: String? = nil,
427 extendedCreditStatus: UsageCreditStatus? = nil,
428 onLoadExtended: (() -> Void)? = nil
429 ) {
430 _isCollapsed = isCollapsed
431 self.rows = rows
432 self.groups = groups
433 self.loading = loading
434 self.error = error
435 self.provenance = provenance
436 self.confidence = confidence
437 self.showsExtendedPlaceholder = showsExtendedPlaceholder
438 self.extendedCount = extendedCount
439 self.extendedLoading = extendedLoading
440 self.extendedError = extendedError
441 self.extendedCreditStatus = extendedCreditStatus
442 self.onLoadExtended = onLoadExtended
443 }
444
445 var body: some View {
446 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
447 CardView(allowsHorizontalScroll: false) {
448 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
449 if loading {
450 ProgressView("Checking certificate transparency…")
451 .appLoadingStyle()
452 } else if rows.isEmpty {
453 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
454 if showsExtendedPlaceholder {
455 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
456 .padding(.top, 4)
457 }
458 } else {
459 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
460 Button("Load extended results") {
461 onLoadExtended()
462 }
463 .buttonStyle(.bordered)
464 .font(appDensity.font(.caption2))
465 }
466 if !groups.isEmpty {
467 Text("Groups")
468 .font(appDensity.font(.caption2))
469 .foregroundStyle(Color(.appTextSecondary))
470 ForEach(groups) { group in
471 HStack {
472 Text("\(group.label).*")
473 .font(appDensity.font(.caption))
474 .foregroundStyle(Color(.statusInfo))
475 Spacer()
476 Text("\(group.subdomains.count)")
477 .font(appDensity.font(.caption2))
478 .foregroundStyle(Color(.appTextSecondary))
479 }
480 }
481 }
482 ForEach(rows) { row in
483 HStack(spacing: 8) {
484 Text(row.hostname)
485 .font(appDensity.font(.caption))
486 .foregroundStyle(.primary)
487 .textSelection(.enabled)
488 Spacer()
489 if row.isInteresting {
490 Text("Interesting")
491 .font(.system(.caption2, design: .monospaced))
492 .foregroundStyle(Color(.statusWarning))
493 .padding(.horizontal, 8)
494 .padding(.vertical, 4)
495 .background(Color(.statusWarningSurface))
496 .clipShape(Capsule())
497 }
498 }
499 }
500 if extendedLoading {
501 ProgressView("Loading extended subdomains…")
502 .appLoadingStyle()
503 .padding(.top, 4)
504 } else if extendedCount > 0 {
505 MessageRowView(text: "\(extendedCount) extended results included", isError: false)
506 .padding(.top, 4)
507 } else if let extendedError {
508 MessageRowView(text: extendedError, isError: false)
509 .padding(.top, 4)
510 } else if showsExtendedPlaceholder {
511 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
512 .padding(.top, 4)
513 }
514 }
515 }
516 }
517 }
518}
519
520struct DNSSectionView: View {
521 @Environment(\.appDensity) private var appDensity
522 @Binding var isCollapsed: Bool
523 let dnssecLabel: String?
524 let patternSummary: DNSPatternSummary?
525 let sections: [DNSRecordSectionViewData]
526 let ptrMessage: SectionMessageViewData?
527 let loading: Bool
528 let dnsProvenance: SectionProvenance?
529 let ptrProvenance: SectionProvenance?
530 let sectionError: String?
531 let history: [DNSHistoryEvent]
532 let historyLoading: Bool
533 let historyError: String?
534 let showsHistoryPlaceholder: Bool
535 let historyCreditStatus: UsageCreditStatus?
536 let onLoadHistory: (() -> Void)?
537
538 init(
539 isCollapsed: Binding<Bool>,
540 dnssecLabel: String?,
541 patternSummary: DNSPatternSummary?,
542 sections: [DNSRecordSectionViewData],
543 ptrMessage: SectionMessageViewData?,
544 loading: Bool,
545 dnsProvenance: SectionProvenance?,
546 ptrProvenance: SectionProvenance?,
547 sectionError: String?,
548 history: [DNSHistoryEvent] = [],
549 historyLoading: Bool = false,
550 historyError: String? = nil,
551 showsHistoryPlaceholder: Bool = false,
552 historyCreditStatus: UsageCreditStatus? = nil,
553 onLoadHistory: (() -> Void)? = nil
554 ) {
555 _isCollapsed = isCollapsed
556 self.dnssecLabel = dnssecLabel
557 self.patternSummary = patternSummary
558 self.sections = sections
559 self.ptrMessage = ptrMessage
560 self.loading = loading
561 self.dnsProvenance = dnsProvenance
562 self.ptrProvenance = ptrProvenance
563 self.sectionError = sectionError
564 self.history = history
565 self.historyLoading = historyLoading
566 self.historyError = historyError
567 self.showsHistoryPlaceholder = showsHistoryPlaceholder
568 self.historyCreditStatus = historyCreditStatus
569 self.onLoadHistory = onLoadHistory
570 }
571
572 var body: some View {
573 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
574 if loading {
575 LoadingCardView(text: "Querying DNS…")
576 } else if let sectionError, sections.isEmpty {
577 MessageCardView(text: sectionError, isError: true)
578 } else {
579 if dnsProvenance != nil {
580 CardView(allowsHorizontalScroll: false) {
581 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
582 if let patternSummary {
583 if !patternSummary.providers.isEmpty {
584 MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
585 }
586 if !patternSummary.patterns.isEmpty {
587 ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
588 MessageRowView(text: pattern, isError: false)
589 }
590 }
591 }
592 }
593 }
594 ForEach(sections) { section in
595 CardView {
596 Text(section.title)
597 .font(.system(.subheadline, design: .monospaced))
598 .fontWeight(.semibold)
599 .foregroundStyle(Color(.statusInfo))
600
601 if let message = section.message {
602 MessageRowView(text: message.text, isError: message.isError)
603 }
604
605 ForEach(section.rows) { row in
606 LabeledValueRow(row: row)
607 }
608
609 if let wildcardTitle = section.wildcardTitle {
610 Text(wildcardTitle)
611 .font(.system(.caption, design: .monospaced))
612 .foregroundStyle(Color(.appTextSecondary))
613 .padding(.top, 4)
614 ForEach(section.wildcardRows) { row in
615 LabeledValueRow(row: row)
616 }
617 }
618 }
619 }
620
621 if let ptrMessage {
622 CardView {
623 Text("PTR")
624 .font(.system(.subheadline, design: .monospaced))
625 .fontWeight(.semibold)
626 .foregroundStyle(Color(.statusInfo))
627 SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
628 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
629 }
630 }
631
632 CardView(allowsHorizontalScroll: false) {
633 HStack {
634 Text("History")
635 .font(appDensity.font(.subheadline, weight: .semibold))
636 .foregroundStyle(Color(.statusInfo))
637 Spacer()
638 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
639 Button("Load") {
640 onLoadHistory()
641 }
642 .buttonStyle(.bordered)
643 .font(appDensity.font(.caption2))
644 }
645 }
646 if historyLoading {
647 ProgressView("Loading DNS history…")
648 .appLoadingStyle()
649 } else if !history.isEmpty {
650 ForEach(history) { event in
651 VStack(alignment: .leading, spacing: 3) {
652 Text(event.date.formatted(date: .abbreviated, time: .omitted))
653 .font(appDensity.font(.caption2))
654 .foregroundStyle(Color(.appTextSecondary))
655 Text(event.summary)
656 .font(appDensity.font(.caption))
657 if !event.aRecords.isEmpty {
658 Text("A: \(event.aRecords.joined(separator: ", "))")
659 .font(appDensity.font(.caption2))
660 .foregroundStyle(Color(.appTextSecondary))
661 }
662 if !event.nameservers.isEmpty {
663 Text("NS: \(event.nameservers.joined(separator: ", "))")
664 .font(appDensity.font(.caption2))
665 .foregroundStyle(Color(.appTextSecondary))
666 }
667 }
668 }
669 } else if let historyError {
670 MessageRowView(text: historyError, isError: false)
671 } else if showsHistoryPlaceholder {
672 MessageRowView(text: "DNS history available in Pro+", isError: false)
673 }
674 }
675 }
676 }
677 }
678}
679
680struct WebSectionView: View {
681 @Environment(\.appDensity) private var appDensity
682 @Binding var isCollapsed: Bool
683 let certificateRows: [InfoRowViewData]
684 let sslInfo: SSLCertificateInfo?
685 let tlsSummary: WebResultSummary?
686 let sslLoading: Bool
687 let sslError: String?
688 let tlsProvenance: SectionProvenance?
689 let responseRows: [InfoRowViewData]
690 let headers: [HTTPHeader]
691 let headersLoading: Bool
692 let headersError: String?
693 let httpProvenance: SectionProvenance?
694 let redirects: [RedirectHopViewData]
695 let redirectLoading: Bool
696 let redirectError: String?
697 let redirectProvenance: SectionProvenance?
698 let finalURL: String?
699
700 var body: some View {
701 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
702 CardView {
703 HStack {
704 Text("TLS")
705 .font(appDensity.font(.subheadline, weight: .semibold))
706 .foregroundStyle(Color(.statusInfo))
707 Spacer()
708 if !sslLoading {
709 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
710 }
711 }
712 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
713 if !sslLoading, let tlsSummary {
714 LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
715 ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
716 MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
717 }
718 }
719 if sslLoading {
720 ProgressView("Checking certificate…")
721 .appLoadingStyle()
722 } else if let sslError {
723 MessageRowView(text: sslError, isError: true)
724 } else {
725 ForEach(certificateRows) { row in
726 LabeledValueRow(row: row)
727 }
728 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
729 Text("SANs")
730 .font(appDensity.font(.caption2))
731 .foregroundStyle(Color(.appTextSecondary))
732 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
733 HStack(alignment: .top, spacing: 8) {
734 Text(san)
735 .font(appDensity.font(.caption))
736 .lineLimit(nil)
737 .fixedSize(horizontal: false, vertical: true)
738 .textSelection(.enabled)
739 Spacer()
740 AppCopyButton(value: san, label: "Copy certificate SAN")
741 }
742 }
743 }
744 }
745 }
746
747 CardView {
748 Text("Headers")
749 .font(appDensity.font(.subheadline, weight: .semibold))
750 .foregroundStyle(Color(.statusInfo))
751 SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
752 if headersLoading {
753 ProgressView("Fetching headers…")
754 .appLoadingStyle()
755 } else if let headersError {
756 MessageRowView(text: headersError, isError: true)
757 } else {
758 ForEach(responseRows) { row in
759 LabeledValueRow(row: row)
760 }
761 if headers.isEmpty {
762 MessageRowView(text: "No HTTP headers returned", isError: false)
763 } else {
764 ForEach(headers) { header in
765 HStack(alignment: .top, spacing: 4) {
766 Text(header.name + ":")
767 .font(appDensity.font(.caption))
768 .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
769 Text(header.value)
770 .font(appDensity.font(.caption))
771 .foregroundStyle(.primary)
772 .textSelection(.enabled)
773 }
774 }
775 }
776 }
777 }
778
779 CardView {
780 HStack {
781 Text("Redirects")
782 .font(appDensity.font(.subheadline, weight: .semibold))
783 .foregroundStyle(Color(.statusInfo))
784 Spacer()
785 if let finalURL {
786 AppCopyButton(value: finalURL, label: "Copy redirect URL")
787 }
788 }
789 SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
790 if redirectLoading {
791 ProgressView("Tracing redirects…")
792 .appLoadingStyle()
793 } else if let redirectError {
794 MessageRowView(text: redirectError, isError: true)
795 } else if redirects.isEmpty {
796 MessageRowView(text: "No redirect data available", isError: false)
797 } else {
798 if let finalURL {
799 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
800 }
801 ForEach(redirects) { redirect in
802 HStack(alignment: .top, spacing: 6) {
803 Text(redirect.stepLabel)
804 .font(appDensity.font(.caption))
805 .foregroundStyle(Color(.appTextSecondary))
806 .frame(width: 16, alignment: .trailing)
807 Text(redirect.statusCode)
808 .font(appDensity.font(.caption))
809 .foregroundStyle(Color(.statusInfo))
810 .frame(width: 36, alignment: .leading)
811 Text(redirect.url)
812 .font(appDensity.font(.caption))
813 .textSelection(.enabled)
814 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
815 if redirect.isFinal {
816 Text("(final)")
817 .font(appDensity.font(.caption2))
818 .foregroundStyle(Color(.appTextSecondary))
819 }
820 }
821 }
822 }
823 }
824 }
825 }
826
827 private func isTLSHighlightError(_ highlight: String) -> Bool {
828 let normalized = highlight.lowercased()
829 if normalized.contains("no weak tls indicators were detected") {
830 return false
831 }
832 return normalized.contains("expires")
833 || normalized.contains("weak")
834 || normalized.contains("tls 1.0")
835 || normalized.contains("tls 1.1")
836 }
837}
838
839struct EmailSectionView: View {
840 @Environment(\.appDensity) private var appDensity
841 @Binding var isCollapsed: Bool
842 let rows: [EmailRowViewData]
843 let assessment: EmailSecuritySummary?
844 let loading: Bool
845 let provenance: SectionProvenance?
846 let confidence: ConfidenceLevel?
847 let error: String?
848
849 var body: some View {
850 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
851 CardView {
852 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
853 HStack {
854 Spacer()
855 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
856 .opacity(loading ? 0 : 1)
857 }
858 if let assessment, let grade = assessment.grade {
859 LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
860 if !assessment.reasons.isEmpty {
861 Text(assessment.reasons.joined(separator: " | "))
862 .font(appDensity.font(.caption2))
863 .foregroundStyle(Color(.appTextSecondary))
864 }
865 }
866 if loading {
867 ProgressView("Checking email records…")
868 .appLoadingStyle()
869 } else if let error {
870 MessageRowView(text: error, isError: true)
871 } else if rows.isEmpty {
872 MessageRowView(text: "No email security records found", isError: false)
873 } else {
874 ForEach(rows) { row in
875 VStack(alignment: .leading, spacing: 4) {
876 HStack(spacing: 8) {
877 Text(row.label)
878 .font(appDensity.font(.caption))
879 .foregroundStyle(Color(.statusInfo))
880 .frame(width: 76, alignment: .leading)
881 AppStatusBadgeView(model: emailRowBadge(row))
882 }
883 Text(row.detail)
884 .font(appDensity.font(.caption2))
885 .foregroundStyle(.primary)
886 .textSelection(.enabled)
887 if let auxiliaryDetail = row.auxiliaryDetail {
888 Text(auxiliaryDetail)
889 .font(appDensity.font(.caption2))
890 .foregroundStyle(Color(.appTextSecondary))
891 }
892 }
893 }
894 }
895 }
896 }
897 }
898
899 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
900 switch row.statusTone {
901 case .success:
902 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
903 case .warning:
904 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
905 case .failure:
906 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
907 case .primary, .secondary:
908 return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
909 }
910 }
911}
912
913struct NetworkSectionView: View {
914 @Environment(\.appDensity) private var appDensity
915 @Binding var isCollapsed: Bool
916 let reachabilityRows: [ReachabilityRowViewData]
917 let reachabilityLoading: Bool
918 let reachabilityError: String?
919 let reachabilityProvenance: SectionProvenance?
920 let locationRows: [InfoRowViewData]
921 let geolocation: IPGeolocation?
922 let geolocationLoading: Bool
923 let geolocationError: String?
924 let geolocationProvenance: SectionProvenance?
925 let geolocationConfidence: ConfidenceLevel?
926 let standardPortRows: [PortScanRowViewData]
927 let customPortRows: [PortScanRowViewData]
928 let portScanLoading: Bool
929 let portScanError: String?
930 let portScanProvenance: SectionProvenance?
931 let customPortScanLoading: Bool
932 let customPortScanError: String?
933 let isCloudflareProxied: Bool
934 @Binding var customPortsExpanded: Bool
935 @Binding var customPortInput: String
936 let onScanCustomPorts: () -> Void
937
938 var body: some View {
939 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
940 CardView {
941 Text("Reachability")
942 .font(appDensity.font(.subheadline, weight: .semibold))
943 .foregroundStyle(Color(.statusInfo))
944 SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
945 if reachabilityLoading {
946 ProgressView("Checking ports…")
947 .appLoadingStyle()
948 } else if let reachabilityError {
949 MessageRowView(text: reachabilityError, isError: true)
950 } else {
951 ForEach(reachabilityRows) { row in
952 HStack {
953 Text(row.portLabel)
954 .font(appDensity.font(.caption))
955 Spacer()
956 Text(row.latencyLabel)
957 .font(appDensity.font(.caption2))
958 .foregroundStyle(Color(.appTextSecondary))
959 AppStatusBadgeView(model: reachabilityBadge(row))
960 }
961 }
962 }
963 }
964
965 CardView(allowsHorizontalScroll: false) {
966 Text("Location")
967 .font(appDensity.font(.subheadline, weight: .semibold))
968 .foregroundStyle(Color(.statusInfo))
969 SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
970 if geolocationLoading {
971 ProgressView("Looking up location…")
972 .appLoadingStyle()
973 } else if let geolocationError, geolocation == nil {
974 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
975 } else if let geolocation {
976 ForEach(locationRows) { row in
977 LabeledValueRow(row: row)
978 }
979 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
980 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
981 Map(initialPosition: .region(MKCoordinateRegion(
982 center: coordinate,
983 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
984 ))) {
985 Marker(geolocation.ip, coordinate: coordinate)
986 }
987 .mapStyle(.standard)
988 .frame(maxWidth: .infinity)
989 .frame(height: 180)
990 .cornerRadius(8)
991 }
992 } else {
993 MessageRowView(text: "No location data available", isError: false)
994 }
995 }
996
997 CardView(allowsHorizontalScroll: false) {
998 Text("Port Scan")
999 .font(appDensity.font(.subheadline, weight: .semibold))
1000 .foregroundStyle(Color(.statusInfo))
1001 SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
1002
1003 if isCloudflareProxied {
1004 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
1005 .font(appDensity.font(.caption2))
1006 .foregroundStyle(Color(.statusWarning))
1007 .fixedSize(horizontal: false, vertical: true)
1008 }
1009
1010 if portScanLoading {
1011 ProgressView("Scanning ports…")
1012 .appLoadingStyle()
1013 } else if let portScanError, standardPortRows.isEmpty {
1014 MessageRowView(text: portScanError, isError: true)
1015 } else {
1016 Text("Standard Ports")
1017 .font(.system(.caption, design: .monospaced))
1018 .foregroundStyle(Color(.appTextSecondary))
1019 PortRowsView(rows: standardPortRows)
1020 }
1021
1022 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
1023 VStack(alignment: .leading, spacing: 10) {
1024 TextField("8888, 9000, 27017", text: $customPortInput)
1025 .font(appDensity.font(.caption))
1026 .textInputAutocapitalization(.never)
1027 .autocorrectionDisabled()
1028 .keyboardType(.numberPad)
1029 .padding(10)
1030 .background(Color(.appSurface))
1031 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1032
1033 Button("Scan") {
1034 AppHaptics.refresh()
1035 onScanCustomPorts()
1036 }
1037 .buttonStyle(.borderedProminent)
1038 .tint(Color(.accentFill))
1039 .disabled(customPortScanLoading)
1040
1041 if customPortScanLoading {
1042 ProgressView("Scanning custom ports…")
1043 .appLoadingStyle()
1044 } else if let customPortScanError {
1045 MessageRowView(text: customPortScanError, isError: true)
1046 } else {
1047 PortRowsView(rows: customPortRows)
1048 }
1049 }
1050 .padding(.top, 8)
1051 }
1052 .font(.system(.caption, design: .monospaced))
1053 .tint(.secondary)
1054 }
1055 }
1056 }
1057
1058 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
1059 switch row.statusTone {
1060 case .success:
1061 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1062 case .warning:
1063 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1064 case .failure:
1065 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1066 case .primary, .secondary:
1067 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1068 }
1069 }
1070}
1071
1072struct PortRowsView: View {
1073 @Environment(\.appDensity) private var appDensity
1074 let rows: [PortScanRowViewData]
1075
1076 var body: some View {
1077 if rows.isEmpty {
1078 MessageRowView(text: "No results", isError: false)
1079 } else {
1080 ForEach(rows) { row in
1081 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1082 HStack {
1083 Text(row.portLabel)
1084 .font(appDensity.font(.caption))
1085 .frame(width: 52, alignment: .leading)
1086 Text(row.service)
1087 .font(appDensity.font(.caption))
1088 .foregroundStyle(.primary)
1089 Spacer()
1090 if let durationLabel = row.durationLabel {
1091 Text(durationLabel)
1092 .font(appDensity.font(.caption2))
1093 .foregroundStyle(Color(.appTextSecondary))
1094 }
1095 AppStatusBadgeView(model: portBadge(row))
1096 }
1097 if let banner = row.banner {
1098 Text(banner)
1099 .font(appDensity.font(.caption2))
1100 .foregroundStyle(Color(.appTextSecondary))
1101 .padding(.leading, 8)
1102 }
1103 }
1104 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1105 }
1106 }
1107 }
1108
1109 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
1110 switch row.statusTone {
1111 case .success:
1112 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1113 case .warning:
1114 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1115 case .failure:
1116 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1117 case .primary, .secondary:
1118 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1119 }
1120 }
1121}