diff options
Diffstat (limited to 'DomainDig/ContentView.swift')
| -rw-r--r-- | DomainDig/ContentView.swift | 1184 |
1 files changed, 520 insertions, 664 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index d9c1299..68f3a6f 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MapKit +import SwiftUI struct ContentView: View { @State private var viewModel = DomainViewModel() @@ -14,14 +14,59 @@ struct ContentView: View { inputSection if viewModel.hasRun { actionButtons - reachabilitySection - redirectChainSection - dnsResultsSection - emailSecuritySection - sslResultsSection - httpHeadersSection - ipGeolocationSection - portScanSection + SummaryView(fields: viewModel.summaryFields) + .padding(.top, 8) + DomainSectionView(rows: viewModel.domainRows) + .padding(.top, 16) + DNSSectionView( + dnssecLabel: viewModel.dnssecLabel, + sections: viewModel.dnsRows, + ptrMessage: viewModel.ptrMessage, + loading: viewModel.dnsLoading || viewModel.ptrLoading, + sectionError: viewModel.dnsError + ) + .padding(.top, 16) + WebSectionView( + certificateRows: viewModel.webCertificateRows, + sslInfo: viewModel.sslInfo, + sslLoading: viewModel.sslLoading || viewModel.hstsLoading, + sslError: viewModel.sslError, + responseRows: viewModel.webResponseRows, + headers: viewModel.httpHeaders, + headersLoading: viewModel.httpHeadersLoading, + headersError: viewModel.httpHeadersError, + redirects: viewModel.redirectRows, + redirectLoading: viewModel.redirectChainLoading, + redirectError: viewModel.redirectChainError, + finalURL: viewModel.currentSnapshot.redirectChain.last?.url + ) + .padding(.top, 16) + EmailSectionView( + rows: viewModel.emailRows, + loading: viewModel.emailSecurityLoading, + error: viewModel.emailSecurityError + ) + .padding(.top, 16) + NetworkSectionView( + reachabilityRows: viewModel.reachabilityRows, + reachabilityLoading: viewModel.reachabilityLoading, + reachabilityError: viewModel.reachabilityError, + locationRows: viewModel.locationRows, + geolocation: viewModel.ipGeolocation, + geolocationLoading: viewModel.ipGeolocationLoading, + geolocationError: viewModel.ipGeolocationError, + standardPortRows: viewModel.standardPortRows, + customPortRows: viewModel.customPortRows, + portScanLoading: viewModel.portScanLoading, + portScanError: viewModel.portScanError, + customPortScanLoading: viewModel.customPortScanLoading, + customPortScanError: viewModel.customPortScanError, + isCloudflareProxied: viewModel.isCloudflareProxied, + customPortsExpanded: $customPortsExpanded, + customPortInput: $customPortInput, + onScanCustomPorts: runCustomPortScan + ) + .padding(.top, 16) } else if !viewModel.recentSearches.isEmpty { recentSearchesSection } @@ -55,8 +100,6 @@ struct ContentView: View { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .foregroundStyle(.secondary) } - } - ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView() } label: { @@ -71,8 +114,6 @@ struct ContentView: View { } } - // MARK: - Input - private var inputSection: some View { VStack(spacing: 12) { TextField("e.g. cleberg.net", text: $viewModel.domain) @@ -101,8 +142,6 @@ struct ContentView: View { .padding(.vertical, 16) } - // MARK: - Action Buttons (Share + Bookmark) - private var actionButtons: some View { HStack { Spacer() @@ -123,11 +162,8 @@ struct ContentView: View { } } } - .padding(.top, 8) } - // MARK: - Recent Searches - private var recentSearchesSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -162,778 +198,605 @@ struct ContentView: View { .padding(.top, 8) } - // MARK: - Reachability - - private var reachabilitySection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("Reachability") - - if viewModel.reachabilityLoading { - ProgressView("Checking ports…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.reachabilityError { - errorLabel(error) - } else { - VStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.reachabilityResults) { result in - HStack(spacing: 8) { - Circle() - .fill(result.reachable ? Color.green : Color.red) - .frame(width: 8, height: 8) - Text("Port \(result.port)") - .font(.system(.caption, design: .monospaced)) - if result.reachable, let ms = result.latencyMs { - Text("\(ms)ms") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else if !result.reachable { - Text("—") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - Spacer() - Text(result.reachable ? "Reachable" : "Unreachable") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.reachable ? .green : .red) - } - } - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } + private func runCustomPortScan() { + let ports = parsedCustomPorts(from: customPortInput) + Task { + await viewModel.runCustomPortScan(ports: ports) } - .padding(.top, 8) } - // MARK: - Redirect Chain - - private var redirectChainSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("Redirect Chain") - - if viewModel.redirectChainLoading { - ProgressView("Tracing redirects…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.redirectChainError { - errorLabel(error) - } else if viewModel.redirectChain.isEmpty { - Text("No redirect data") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(8) - } else if viewModel.redirectChain.count == 1, - let only = viewModel.redirectChain.first, - only.isFinal, !(300...399).contains(only.statusCode) { - Text("No redirects — direct connection") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } else { - horizontallyScrollableCard { - ForEach(viewModel.redirectChain) { hop in - HStack(alignment: .top, spacing: 6) { - Text("\(hop.stepNumber)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 16, alignment: .trailing) - Text("\(hop.statusCode)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.cyan) - .frame(width: 30, alignment: .leading) - Text(hop.url) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - if hop.isFinal { - Text("(final)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - .padding(.top, 16) - } - - // MARK: - DNS Results + private func parsedCustomPorts(from input: String) -> [UInt16] { + let parts = input.split(separator: ",", omittingEmptySubsequences: true) + var seen = Set<UInt16>() + var ports: [UInt16] = [] - private var dnsResultsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 8) { - sectionHeader("DNS Records") - Spacer() - if let dnssecSigned = dnssecStatus { - Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(dnssecSigned ? .green : .red) - .padding(.top, 1) - } + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = UInt16(trimmed), seen.insert(value).inserted else { + continue } - - if viewModel.dnsLoading { - ProgressView("Querying DNS…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.dnsError { - errorLabel(error) - } else { - ForEach(viewModel.dnsSections) { section in - dnsRecordSection(section) - if section.recordType == .A { - ptrRow - } - } + ports.append(value) + if ports.count == 20 { + break } } - .padding(.top, 16) - } - private var ptrRow: some View { - horizontallyScrollableCard { - Text("PTR (Reverse DNS)") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if viewModel.ptrLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding(4) - } else if let ptr = viewModel.ptrRecord { - Text(ptr) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text("No PTR record found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } + return ports } - private func dnsRecordSection(_ section: DNSSection) -> some View { - horizontallyScrollableCard { - Text(section.recordType.rawValue) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if let error = section.error { - errorLabel(error) - } else if section.records.isEmpty { - Text("No records found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else { - dnsRecordRows(section.records) - } - - if !section.wildcardRecords.isEmpty { - Text("*.\(viewModel.searchedDomain)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(.cyan.opacity(0.7)) - .padding(.top, 4) + private func shareResults() { + let text = viewModel.exportText() + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyyMMdd_HHmmss" + let timestamp = dateFmt.string(from: Date()) + let filename = "\(timestamp)_domaindigresults.txt" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - dnsRecordRows(section.wildcardRecords) - } + do { + try text.write(to: tempURL, atomically: true, encoding: .utf8) + } catch { + return } - } - private func dnsRecordRows(_ records: [DNSRecord]) -> some View { - ForEach(records) { record in - HStack(alignment: .top) { - Text(record.value) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - Spacer() - Text("TTL \(record.ttl)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.keyWindow?.rootViewController else { return } + var presenter = rootVC + while let presented = presenter.presentedViewController { + presenter = presented } + activityVC.popoverPresentationController?.sourceView = presenter.view + presenter.present(activityVC, animated: true) } +} - // MARK: - Email Security - - @State private var expandedEmailField: String? +struct SummaryView: View { + let fields: [SummaryFieldViewData] - private var emailSecuritySection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("Email Security") - - if viewModel.emailSecurityLoading { - ProgressView("Checking email records…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.emailSecurityError { - errorLabel(error) - } else if let email = viewModel.emailSecurity { - horizontallyScrollableCard(spacing: 6) { - emailSecurityRow("SPF", record: email.spf) - emailSecurityRow("DMARC", record: email.dmarc) - emailSecurityRow("DKIM", record: email.dkim) - emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) - emailSecurityRow("BIMI", record: email.bimi) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(record.found ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(record.found ? .green : .red) - if let value = record.value { - let isExpanded = expandedEmailField == label - let displayValue = isExpanded ? value : String(value.prefix(80)) - Text(displayValue) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - .lineLimit(isExpanded ? nil : 1) - .onTapGesture { - withAnimation { - expandedEmailField = isExpanded ? nil : label - } - } - if let selector = record.matchedSelector { - Text("(selector: \(selector))") + SectionTitleView(title: "Summary") + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(fields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.label) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) + Text(field.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: field.tone)) + .lineLimit(2) + .textSelection(.enabled) } - } else { - Text("No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - - private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(mtaSts?.txtFound == true ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) - if let policyMode = mtaSts?.policyMode { - Text(policyMode) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) } } } } +} - // MARK: - SSL Results +struct DomainSectionView: View { + let rows: [InfoRowViewData] - private var sslResultsSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("SSL / TLS Certificate") - - if viewModel.sslLoading { - ProgressView("Checking certificate…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.sslError { - errorLabel(error) - } else if let info = viewModel.sslInfo { - sslDetail(info, domain: viewModel.searchedDomain) + SectionTitleView(title: "Domain") + CardView { + ForEach(rows) { row in + LabeledValueRow(row: row) + } } } - .padding(.top, 16) } +} - private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View { - horizontallyScrollableCard(spacing: 8) { - certRow("Common Name", info.commonName) - certRow("Issuer", info.issuer) +struct DNSSectionView: View { + let dnssecLabel: String? + let sections: [DNSRecordSectionViewData] + let ptrMessage: SectionMessageViewData? + let loading: Bool + let sectionError: String? - VStack(alignment: .leading, spacing: 2) { - Text("SANs") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ForEach(info.subjectAltNames, id: \.self) { san in - Text(san) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 8) { + SectionTitleView(title: "DNS") + Spacer() + if let dnssecLabel { + Text(dnssecLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) } } - let formatter = DateFormatter.certDate - certRow("Valid From", formatter.string(from: info.validFrom)) - certRow("Valid Until", formatter.string(from: info.validUntil)) + if loading { + LoadingCardView(text: "Querying DNS…") + } else if let sectionError, sections.isEmpty { + MessageCardView(text: sectionError, isError: true) + } else { + ForEach(sections) { section in + CardView { + Text(section.title) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let message = section.message { + MessageRowView(text: message.text, isError: message.isError) + } - VStack(alignment: .leading, spacing: 2) { - Text("Days Until Expiry") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text("\(info.daysUntilExpiry)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.bold) - .foregroundStyle(expiryColor(info.daysUntilExpiry)) - } + ForEach(section.rows) { row in + LabeledValueRow(row: row) + } - certRow("Chain Depth", "\(info.chainDepth)") - if viewModel.hstsLoading { - hstsLoadingRow - } else if let hstsPreloaded = viewModel.hstsPreloaded { - hstsStatusRow(hstsPreloaded) - } - if let tlsVersion = info.tlsVersion { - certRow("TLS Version", tlsVersion) - } - if let cipherSuite = info.cipherSuite { - certRow("Cipher Suite", cipherSuite) - } - if !info.chain.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("Certificate Chain") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in - DisclosureGroup { - Text(certificate.issuer) + if let wildcardTitle = section.wildcardTitle { + Text(wildcardTitle) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) - .textSelection(.enabled) - } label: { - Text(certificate.subject) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) + .padding(.top, 4) + ForEach(section.wildcardRows) { row in + LabeledValueRow(row: row) + } } - .tint(index == 0 ? .cyan : .secondary) + } + } + + if let ptrMessage { + CardView { + Text("PTR") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) } } } - Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.cyan) } } +} - // MARK: - HTTP Headers +struct WebSectionView: View { + let certificateRows: [InfoRowViewData] + let sslInfo: SSLCertificateInfo? + let sslLoading: Bool + let sslError: String? + let responseRows: [InfoRowViewData] + let headers: [HTTPHeader] + let headersLoading: Bool + let headersError: String? + let redirects: [RedirectHopViewData] + let redirectLoading: Bool + let redirectError: String? + let finalURL: String? - private var httpHeadersSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - sectionHeader("HTTP Headers") - if let grade = viewModel.httpSecurityGrade { - Text(grade) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(httpSecurityGradeColor(for: grade)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(httpSecurityGradeColor(for: grade).opacity(0.18)) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + SectionTitleView(title: "Web") + + CardView { + Text("TLS") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if sslLoading { + ProgressView("Checking certificate…") + } else if let sslError { + MessageRowView(text: sslError, isError: true) + } else { + ForEach(certificateRows) { row in + LabeledValueRow(row: row) + } + if let sslInfo, !sslInfo.subjectAltNames.isEmpty { + Text("SANs") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + ForEach(sslInfo.subjectAltNames, id: \.self) { san in + Text(san) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } } - Spacer() } - if viewModel.httpHeadersLoading { - ProgressView("Fetching headers…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.httpHeadersError { - errorLabel(error) - } else { - horizontallyScrollableCard { - if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil { - HStack(alignment: .top, spacing: 0) { - ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in - if index > 0 { - Text(" ") - .font(.system(.caption, design: .monospaced)) - } - Text(part.text) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(part.color) - } - if let http3AvailabilityNote { - Text(" ") + CardView { + Text("Headers") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if headersLoading { + ProgressView("Fetching headers…") + } else if let headersError { + MessageRowView(text: headersError, isError: true) + } else { + ForEach(responseRows) { row in + LabeledValueRow(row: row) + } + if headers.isEmpty { + MessageRowView(text: "No HTTP headers returned", isError: false) + } else { + ForEach(headers) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") .font(.system(.caption, design: .monospaced)) - Text(http3AvailabilityNote) + .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) + Text(header.value) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(.primary) + .textSelection(.enabled) } } } - ForEach(viewModel.httpHeaders) { header in - HStack(alignment: .top, spacing: 4) { - Text(header.name + ":") + } + } + + CardView { + Text("Redirects") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if redirectLoading { + ProgressView("Tracing redirects…") + } else if let redirectError { + MessageRowView(text: redirectError, isError: true) + } else if redirects.isEmpty { + MessageRowView(text: "No redirect data available", isError: false) + } else { + if let finalURL { + LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary)) + } + ForEach(redirects) { redirect in + HStack(alignment: .top, spacing: 6) { + Text(redirect.stepLabel) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) - Text(header.value) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text(redirect.statusCode) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 36, alignment: .leading) + Text(redirect.url) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) .textSelection(.enabled) + if redirect.isFinal { + Text("(final)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } } } } } } - .padding(.top, 16) } +} - // MARK: - IP Geolocation +struct EmailSectionView: View { + let rows: [EmailRowViewData] + let loading: Bool + let error: String? - private var ipGeolocationSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("IP Location") - - if viewModel.ipGeolocationLoading { - ProgressView("Looking up location…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let geo = viewModel.ipGeolocation { - ipGeolocationDetail(geo) - } else if let error = viewModel.ipGeolocationError { - if error == "No A record available" { - Text("No location data available") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(8) + SectionTitleView(title: "Email") + CardView { + if loading { + ProgressView("Checking email records…") + } else if let error { + MessageRowView(text: error, isError: true) + } else if rows.isEmpty { + MessageRowView(text: "No email security records found", isError: false) } else { - errorLabel(error) - } - } - } - .padding(.top, 16) - } - - private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { - VStack(alignment: .leading, spacing: 6) { - horizontallyScrollableContent(spacing: 6) { - certRow("IP", geo.ip) - if let org = geo.org { - certRow("Org / ISP", org) - } - let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") - if !location.isEmpty { - certRow("Location", location) - } - } - - if let lat = geo.latitude, let lon = geo.longitude { - let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) - Map(initialPosition: .region(MKCoordinateRegion( - center: coordinate, - span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) - ))) { - Marker(geo.ip, coordinate: coordinate) + ForEach(rows) { row in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(row.label) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 76, alignment: .leading) + Text(row.status) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) + } + Text(row.detail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if let auxiliaryDetail = row.auxiliaryDetail { + Text(auxiliaryDetail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } } - .mapStyle(.standard) - .frame(maxWidth: .infinity) - .frame(height: 180) - .cornerRadius(8) } } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) } +} - // MARK: - Port Scan +struct NetworkSectionView: View { + let reachabilityRows: [ReachabilityRowViewData] + let reachabilityLoading: Bool + let reachabilityError: String? + let locationRows: [InfoRowViewData] + let geolocation: IPGeolocation? + let geolocationLoading: Bool + let geolocationError: String? + let standardPortRows: [PortScanRowViewData] + let customPortRows: [PortScanRowViewData] + let portScanLoading: Bool + let portScanError: String? + let customPortScanLoading: Bool + let customPortScanError: String? + let isCloudflareProxied: Bool + @Binding var customPortsExpanded: Bool + @Binding var customPortInput: String + let onScanCustomPorts: () -> Void - private var portScanSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("Open Ports") - - if viewModel.portScanLoading { - ProgressView("Scanning ports…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.portScanError { - errorLabel(error) - } else { - VStack(alignment: .leading, spacing: 12) { - if viewModel.isCloudflareProxied { - Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.orange) - .padding(8) - .background(Color.orange.opacity(0.1)) - .cornerRadius(6) - } - portScanResultsCard(viewModel.portScanResults) + SectionTitleView(title: "Network") - DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { - VStack(alignment: .leading, spacing: 10) { - TextField("8888, 9000, 27017", text: $customPortInput) + CardView { + Text("Reachability") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if reachabilityLoading { + ProgressView("Checking ports…") + } else if let reachabilityError { + MessageRowView(text: reachabilityError, isError: true) + } else { + ForEach(reachabilityRows) { row in + HStack { + Text(row.portLabel) .font(.system(.caption, design: .monospaced)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.numberPad) - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - - Button("Scan") { - let ports = parsedCustomPorts(from: customPortInput) - Task { - await viewModel.runCustomPortScan(ports: ports) - } - } - .buttonStyle(.borderedProminent) - .tint(.blue) - .disabled(viewModel.customPortScanLoading) - - if viewModel.customPortScanLoading { - ProgressView("Scanning custom ports…") - .font(.system(.caption, design: .monospaced)) - } else if let error = viewModel.customPortScanError { - errorLabel(error) - } else if !viewModel.customPortResults.isEmpty { - portScanResultsCard(viewModel.customPortResults) - } + Spacer() + Text(row.latencyLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(row.statusLabel) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) } - .padding(.top, 8) } - .font(.system(.caption, design: .monospaced)) - .tint(.secondary) } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) } - } - .padding(.top, 16) - } - // MARK: - Helpers + CardView { + Text("Location") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if geolocationLoading { + ProgressView("Looking up location…") + } else if let geolocationError, geolocation == nil { + MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") + } else if let geolocation { + ForEach(locationRows) { row in + LabeledValueRow(row: row) + } + if let latitude = geolocation.latitude, let longitude = geolocation.longitude { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geolocation.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(height: 180) + .cornerRadius(8) + } + } else { + MessageRowView(text: "No location data available", isError: false) + } + } - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(.headline, design: .default)) - .foregroundStyle(.white) - } + CardView { + Text("Port Scan") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) - private var dnssecStatus: Bool? { - viewModel.dnsSections.compactMap(\.dnssecSigned).first - } + if isCloudflareProxied { + Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.orange) + } - private func certRow(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } + if portScanLoading { + ProgressView("Scanning ports…") + } else if let portScanError, standardPortRows.isEmpty { + MessageRowView(text: portScanError, isError: true) + } else { + Text("Standard Ports") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + PortRowsView(rows: standardPortRows) + } - private var hstsLoadingRow: some View { - VStack(alignment: .leading, spacing: 2) { - Text("HSTS Preload") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ProgressView() - .controlSize(.small) - } - } + DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { + VStack(alignment: .leading, spacing: 10) { + TextField("8888, 9000, 27017", text: $customPortInput) + .font(.system(.caption, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numberPad) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) - private func hstsStatusRow(_ isPreloaded: Bool) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text("HSTS Preload") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(isPreloaded ? "Preloaded" : "Not preloaded") + Button("Scan") { + onScanCustomPorts() + } + .buttonStyle(.borderedProminent) + .disabled(customPortScanLoading) + + if customPortScanLoading { + ProgressView("Scanning custom ports…") + } else if let customPortScanError { + MessageRowView(text: customPortScanError, isError: true) + } else { + PortRowsView(rows: customPortRows) + } + } + .padding(.top, 8) + } .font(.system(.caption, design: .monospaced)) - .foregroundStyle(isPreloaded ? .green : .secondary) - } - } - - private func horizontallyScrollableCard<Content: View>( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - horizontallyScrollableContent(spacing: spacing) { - content() - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - - private func horizontallyScrollableContent<Content: View>( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - ScrollView(.horizontal) { - VStack(alignment: .leading, spacing: spacing) { - content() + .tint(.secondary) } - .scrollTargetLayout() } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) - .frame(maxWidth: .infinity, alignment: .leading) } +} - private func errorLabel(_ message: String) -> some View { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.red) - .padding(8) - } +struct PortRowsView: View { + let rows: [PortScanRowViewData] - private func portScanResultsCard(_ results: [PortScanResult]) -> some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(results) { result in + var body: some View { + if rows.isEmpty { + MessageRowView(text: "No results", isError: false) + } else { + ForEach(rows) { row in VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Circle() - .fill(result.open ? Color.green : Color(.systemGray4)) - .frame(width: 8, height: 8) - Text("\(result.port)") + HStack { + Text(row.portLabel) .font(.system(.caption, design: .monospaced)) - .lineLimit(1) .frame(width: 52, alignment: .leading) - Text(result.service) + Text(row.service) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.open ? .primary : .secondary) + .foregroundStyle(.primary) Spacer() - if result.open { - Text("Open") + if let durationLabel = row.durationLabel { + Text(durationLabel) .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.green) + .foregroundStyle(.secondary) } + Text(row.statusLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) } - - if let banner = result.banner { + if let banner = row.banner { Text(banner) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.leading, 16) + .padding(.leading, 8) } } } } } +} - private func parsedCustomPorts(from input: String) -> [UInt16] { - let parts = input.split(separator: ",", omittingEmptySubsequences: true) - var seen = Set<UInt16>() - var ports: [UInt16] = [] - - for part in parts { - let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = UInt16(trimmed), seen.insert(value).inserted else { - continue - } - ports.append(value) - if ports.count == 20 { - break - } - } +struct SectionTitleView: View { + let title: String - return ports + var body: some View { + Text(title) + .font(.system(.headline)) + .foregroundStyle(.white) } +} - private var httpStatusSummaryParts: [(text: String, color: Color)] { - var parts: [(text: String, color: Color)] = [] +struct CardView<Content: View>: View { + let content: Content - if let statusCode = viewModel.httpStatusCode { - parts.append(("HTTP \(statusCode)", .cyan)) - } - if let responseTimeMs = viewModel.httpResponseTimeMs { - parts.append(("\(responseTimeMs)ms", .secondary)) - } - if let httpProtocol = viewModel.httpProtocol { - parts.append((httpProtocol, .secondary)) - } + init(@ViewBuilder content: () -> Content) { + self.content = content() + } - return parts + var body: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 6) { + content + } + .scrollTargetLayout() + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) } +} - private var http3AvailabilityNote: String? { - guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else { - return nil +struct LoadingCardView: View { + let text: String + + var body: some View { + CardView { + ProgressView(text) + .frame(maxWidth: .infinity, alignment: .center) } - return "(HTTP/3 available)" } +} + +struct MessageCardView: View { + let text: String + let isError: Bool - private func httpSecurityGradeColor(for grade: String) -> Color { - switch grade { - case "A", "B": - .green - case "C": - .yellow - case "D", "F": - .red - default: - .secondary + var body: some View { + CardView { + MessageRowView(text: text, isError: isError) } } +} + +struct MessageRowView: View { + let text: String + let isError: Bool - private func expiryColor(_ days: Int) -> Color { - if days < 30 { return .red } - if days < 60 { return .yellow } - return .green + var body: some View { + Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(isError ? .red : .secondary) } +} - private func shareResults() { - let text = viewModel.exportText() - let dateFmt = DateFormatter() - dateFmt.dateFormat = "yyyyMMdd_HHmmss" - let timestamp = dateFmt.string(from: Date()) - let filename = "\(timestamp)_domaindigresults.txt" - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) +struct LabeledValueRow: View { + let row: InfoRowViewData - do { - try text.write(to: tempURL, atomically: true, encoding: .utf8) - } catch { - return + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(row.label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(row.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.tone)) + .textSelection(.enabled) } + } +} - let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.keyWindow?.rootViewController else { return } - var presenter = rootVC - while let presented = presenter.presentedViewController { - presenter = presented +enum ResultColors { + static func color(for tone: ResultTone) -> Color { + switch tone { + case .primary: + return .primary + case .secondary: + return .secondary + case .success: + return .green + case .warning: + return .yellow + case .failure: + return .red } - activityVC.popoverPresentationController?.sourceView = presenter.view - presenter.present(activityVC, animated: true) } } extension DateFormatter { static let certDate: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter }() } @@ -948,10 +811,7 @@ private struct SettingsView: View { guard resolverOption == .custom else { return nil } - - return DNSResolverOption.isValidCustomURL(customResolverURL) - ? nil - : "Resolver URL must start with https://" + return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" } var body: some View { @@ -981,9 +841,7 @@ private struct SettingsView: View { .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) - customResolverURL = resolverOption == .custom - ? currentResolverURL - : DNSResolverOption.defaultURLString + customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString } .onChange(of: resolverOption) { _, newValue in guard let presetURL = newValue.urlString else { @@ -993,9 +851,7 @@ private struct SettingsView: View { storedResolverURL = presetURL } .onChange(of: customResolverURL) { _, newValue in - guard resolverOption == .custom else { - return - } + guard resolverOption == .custom else { return } storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) } } |
