summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 15:28:12 -0500
committerChristian Cleberg <[email protected]>2026-04-20 15:28:12 -0500
commitee2520f2c0c2d806fd284c3d6b8a81a3822f5270 (patch)
tree0680550a1071e01546ba074c2f20b1bf5a452238 /DomainDig/DomainViewModel.swift
parent4e94ee723cd19c964b1cf37792931e0c7c4614f4 (diff)
downloaddomain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.tar.gz
domain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.tar.bz2
domain-dig-ee2520f2c0c2d806fd284c3d6b8a81a3822f5270.zip
feat(v1.8): improve result structure, history, and consistency
- normalize sections and add summary card - fix resolver usage in reverse DNS - include custom port scans in history/export - standardize error handling - decompose ContentView into smaller views
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
-rw-r--r--DomainDig/DomainViewModel.swift1441
1 files changed, 965 insertions, 476 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 314703f..638a96b 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -1,24 +1,162 @@
import Foundation
import SwiftUI
+enum ResultTone {
+ case primary
+ case secondary
+ case success
+ case warning
+ case failure
+}
+
+struct SummaryFieldViewData: Identifiable {
+ let id = UUID()
+ let label: String
+ let value: String
+ let tone: ResultTone
+}
+
+struct InfoRowViewData: Identifiable {
+ let id = UUID()
+ let label: String
+ let value: String
+ let tone: ResultTone
+}
+
+struct SectionMessageViewData {
+ let text: String
+ let isError: Bool
+}
+
+struct DNSRecordSectionViewData: Identifiable {
+ let id = UUID()
+ let title: String
+ let rows: [InfoRowViewData]
+ let wildcardRows: [InfoRowViewData]
+ let wildcardTitle: String?
+ let message: SectionMessageViewData?
+}
+
+struct EmailRowViewData: Identifiable {
+ let id = UUID()
+ let label: String
+ let status: String
+ let statusTone: ResultTone
+ let detail: String
+ let auxiliaryDetail: String?
+}
+
+struct RedirectHopViewData: Identifiable {
+ let id = UUID()
+ let stepLabel: String
+ let statusCode: String
+ let url: String
+ let isFinal: Bool
+}
+
+struct ReachabilityRowViewData: Identifiable {
+ let id = UUID()
+ let portLabel: String
+ let latencyLabel: String
+ let statusLabel: String
+ let statusTone: ResultTone
+}
+
+struct PortScanRowViewData: Identifiable {
+ let id = UUID()
+ let portLabel: String
+ let service: String
+ let statusLabel: String
+ let statusTone: ResultTone
+ let banner: String?
+ let durationLabel: String?
+}
+
+struct LookupSnapshot {
+ let domain: String
+ let timestamp: Date
+ let resolverDisplayName: String
+ let resolverURLString: String
+ let totalLookupDurationMs: Int?
+ let dnsSections: [DNSSection]
+ let dnsError: String?
+ let sslInfo: SSLCertificateInfo?
+ let sslError: String?
+ let hstsPreloaded: Bool?
+ let httpHeaders: [HTTPHeader]
+ let httpSecurityGrade: String?
+ let httpStatusCode: Int?
+ let httpResponseTimeMs: Int?
+ let httpProtocol: String?
+ let http3Advertised: Bool
+ let httpHeadersError: String?
+ let reachabilityResults: [PortReachability]
+ let reachabilityError: String?
+ let ipGeolocation: IPGeolocation?
+ let ipGeolocationError: String?
+ let emailSecurity: EmailSecurityResult?
+ let emailSecurityError: String?
+ let ptrRecord: String?
+ let ptrError: String?
+ let redirectChain: [RedirectHop]
+ let redirectChainError: String?
+ let portScanResults: [PortScanResult]
+ let portScanError: String?
+ let isLive: Bool
+}
+
+extension HistoryEntry {
+ var snapshot: LookupSnapshot {
+ LookupSnapshot(
+ domain: domain,
+ timestamp: timestamp,
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: totalLookupDurationMs,
+ dnsSections: dnsSections,
+ dnsError: nil,
+ sslInfo: sslInfo,
+ sslError: sslError,
+ hstsPreloaded: hstsPreloaded,
+ httpHeaders: httpHeaders,
+ httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
+ httpStatusCode: nil,
+ httpResponseTimeMs: nil,
+ httpProtocol: nil,
+ http3Advertised: false,
+ httpHeadersError: httpHeadersError,
+ reachabilityResults: reachabilityResults,
+ reachabilityError: reachabilityError,
+ ipGeolocation: ipGeolocation,
+ ipGeolocationError: ipGeolocationError,
+ emailSecurity: emailSecurity,
+ emailSecurityError: emailSecurityError,
+ ptrRecord: ptrRecord,
+ ptrError: ptrError,
+ redirectChain: redirectChain,
+ redirectChainError: redirectChainError,
+ portScanResults: portScanResults,
+ portScanError: portScanError,
+ isLive: false
+ )
+ }
+}
+
@MainActor
@Observable
final class DomainViewModel {
var domain: String = ""
- // DNS
var dnsSections: [DNSSection] = []
var dnsLoading = false
var dnsError: String?
- // SSL
var sslInfo: SSLCertificateInfo?
var sslLoading = false
var sslError: String?
var hstsPreloaded: Bool?
var hstsLoading = false
- // HTTP Headers
var httpHeaders: [HTTPHeader] = []
var httpSecurityGrade: String?
var httpStatusCode: Int?
@@ -28,32 +166,26 @@ final class DomainViewModel {
var httpHeadersLoading = false
var httpHeadersError: String?
- // Reachability
var reachabilityResults: [PortReachability] = []
var reachabilityLoading = false
var reachabilityError: String?
- // IP Geolocation
var ipGeolocation: IPGeolocation?
var ipGeolocationLoading = false
var ipGeolocationError: String?
- // Email Security
var emailSecurity: EmailSecurityResult?
var emailSecurityLoading = false
var emailSecurityError: String?
- // PTR / Reverse DNS
var ptrRecord: String?
var ptrLoading = false
var ptrError: String?
- // Redirect Chain
var redirectChain: [RedirectHop] = []
var redirectChainLoading = false
var redirectChainError: String?
- // Port Scan
var portScanResults: [PortScanResult] = []
var portScanLoading = false
var portScanError: String?
@@ -63,368 +195,494 @@ final class DomainViewModel {
var hasRun = false
private(set) var searchedDomain: String = ""
+ private(set) var lastLookupDurationMs: Int?
- // MARK: - Recent Searches
+ private var lookupTask: Task<Void, Never>?
+ private var customPortScanTask: Task<Void, Never>?
+ private var activeLookupID = UUID()
+ private var lookupStartedAt: Date?
private static let recentSearchesKey = "recentSearches"
private static let maxRecent = 20
-
var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
- // MARK: - Saved Domains
-
private static let savedDomainsKey = "savedDomains"
-
var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
- var isCurrentDomainSaved: Bool {
- !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
+ private static let historyKey = "lookupHistory"
+ private static let maxHistory = 50
+ var history: [HistoryEntry] = {
+ guard let data = UserDefaults.standard.data(forKey: historyKey),
+ let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
+ return []
+ }
+ return entries
+ }()
+
+ var trimmedDomain: String {
+ domain
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "https://", with: "")
+ .replacingOccurrences(of: "http://", with: "")
+ .components(separatedBy: "/").first ?? ""
}
- func toggleSavedDomain() {
- if isCurrentDomainSaved {
- savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
- } else {
- savedDomains.append(searchedDomain)
- }
- UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ var resultsLoaded: Bool {
+ hasRun &&
+ !dnsLoading &&
+ !sslLoading &&
+ !hstsLoading &&
+ !httpHeadersLoading &&
+ !reachabilityLoading &&
+ !ipGeolocationLoading &&
+ !emailSecurityLoading &&
+ !ptrLoading &&
+ !redirectChainLoading &&
+ !portScanLoading &&
+ !customPortScanLoading
}
- func removeSavedDomain(_ domain: String) {
- savedDomains.removeAll { $0 == domain }
- UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ var isCloudflareProxied: Bool {
+ httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
}
- func removeSavedDomains(at offsets: IndexSet) {
- savedDomains.remove(atOffsets: offsets)
- UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ var isCurrentDomainSaved: Bool {
+ !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
}
- // MARK: - History
+ var resolverDisplayName: String {
+ DNSLookupService.currentResolverDisplayName()
+ }
- private static let historyKey = "lookupHistory"
- private static let maxHistory = 50
+ var resolverURLString: String {
+ DNSLookupService.currentResolverURLString()
+ }
- var history: [HistoryEntry] = {
- guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
- let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
- return []
+ var allPortScanResults: [PortScanResult] {
+ (portScanResults + customPortResults).sorted {
+ if $0.kind == $1.kind {
+ return $0.port < $1.port
+ }
+ return $0.kind == .standard
}
- return entries
- }()
+ }
- private func saveHistoryEntry() {
- let entry = HistoryEntry(
+ var currentSnapshot: LookupSnapshot {
+ LookupSnapshot(
domain: searchedDomain,
timestamp: Date(),
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: lastLookupDurationMs,
dnsSections: dnsSections,
+ dnsError: dnsError,
sslInfo: sslInfo,
+ sslError: sslError,
+ hstsPreloaded: hstsPreloaded,
httpHeaders: httpHeaders,
+ httpSecurityGrade: httpSecurityGrade,
+ httpStatusCode: httpStatusCode,
+ httpResponseTimeMs: httpResponseTimeMs,
+ httpProtocol: httpProtocol,
+ http3Advertised: http3Advertised,
+ httpHeadersError: httpHeadersError,
reachabilityResults: reachabilityResults,
+ reachabilityError: reachabilityError,
ipGeolocation: ipGeolocation,
+ ipGeolocationError: ipGeolocationError,
emailSecurity: emailSecurity,
- mtaSts: emailSecurity?.mtaSts,
+ emailSecurityError: emailSecurityError,
ptrRecord: ptrRecord,
+ ptrError: ptrError,
redirectChain: redirectChain,
- portScanResults: portScanResults,
- hstsPreloaded: hstsPreloaded
+ redirectChainError: redirectChainError,
+ portScanResults: allPortScanResults,
+ portScanError: combinedPortScanError,
+ isLive: true
)
- history.insert(entry, at: 0)
- if history.count > Self.maxHistory {
- history = Array(history.prefix(Self.maxHistory))
- }
- if let data = try? JSONEncoder().encode(history) {
- UserDefaults.standard.set(data, forKey: Self.historyKey)
- }
}
- func removeHistoryEntries(at offsets: IndexSet) {
- history.remove(atOffsets: offsets)
- if let data = try? JSONEncoder().encode(history) {
- UserDefaults.standard.set(data, forKey: Self.historyKey)
- }
+ var summaryFields: [SummaryFieldViewData] {
+ Self.summaryFields(from: currentSnapshot)
}
- // MARK: - Computed
+ var domainRows: [InfoRowViewData] {
+ Self.domainRows(from: currentSnapshot)
+ }
- var trimmedDomain: String {
- domain
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .replacingOccurrences(of: "https://", with: "")
- .replacingOccurrences(of: "http://", with: "")
- .components(separatedBy: "/").first ?? ""
+ var dnsRows: [DNSRecordSectionViewData] {
+ Self.dnsRows(from: currentSnapshot)
}
- /// True when all lookups have finished (regardless of success/failure).
- var resultsLoaded: Bool {
- hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
- && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
- && !redirectChainLoading && !portScanLoading
+ var dnssecLabel: String? {
+ Self.dnssecLabel(from: currentSnapshot)
}
- /// True when response headers indicate the domain is behind Cloudflare's proxy.
- /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud
- /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge.
- var isCloudflareProxied: Bool {
- httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
+ var ptrMessage: SectionMessageViewData? {
+ Self.ptrMessage(from: currentSnapshot)
+ }
+
+ var webCertificateRows: [InfoRowViewData] {
+ Self.webCertificateRows(from: currentSnapshot)
+ }
+
+ var webResponseRows: [InfoRowViewData] {
+ Self.webResponseRows(from: currentSnapshot)
}
- // MARK: - Reset
+ var redirectRows: [RedirectHopViewData] {
+ Self.redirectRows(from: currentSnapshot)
+ }
+
+ var emailRows: [EmailRowViewData] {
+ Self.emailRows(from: currentSnapshot)
+ }
+
+ var reachabilityRows: [ReachabilityRowViewData] {
+ Self.reachabilityRows(from: currentSnapshot)
+ }
+
+ var locationRows: [InfoRowViewData] {
+ Self.locationRows(from: currentSnapshot)
+ }
+
+ var standardPortRows: [PortScanRowViewData] {
+ Self.portRows(from: currentSnapshot, kind: .standard)
+ }
+
+ var customPortRows: [PortScanRowViewData] {
+ Self.portRows(from: currentSnapshot, kind: .custom)
+ }
+
+ var combinedPortScanError: String? {
+ [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
+ }
+
+ func toggleSavedDomain() {
+ if isCurrentDomainSaved {
+ savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
+ } else {
+ savedDomains.append(searchedDomain)
+ }
+ UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ }
+
+ func removeSavedDomains(at offsets: IndexSet) {
+ savedDomains.remove(atOffsets: offsets)
+ UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ }
+
+ func removeHistoryEntries(at offsets: IndexSet) {
+ history.remove(atOffsets: offsets)
+ persistHistory()
+ }
+
+ func clearRecentSearches() {
+ recentSearches.removeAll()
+ UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
+ }
+
+ func rerunLookup(from entry: HistoryEntry) {
+ UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
+ domain = entry.domain
+ run()
+ }
func reset() {
+ lookupTask?.cancel()
+ customPortScanTask?.cancel()
hasRun = false
searchedDomain = ""
- dnsSections = []
- dnsError = nil
- dnsLoading = false
- sslInfo = nil
- sslError = nil
- sslLoading = false
- hstsPreloaded = nil
- hstsLoading = false
- httpHeaders = []
- httpSecurityGrade = nil
- httpStatusCode = nil
- httpResponseTimeMs = nil
- httpProtocol = nil
- http3Advertised = false
- httpHeadersError = nil
- httpHeadersLoading = false
- reachabilityResults = []
- reachabilityError = nil
- reachabilityLoading = false
- ipGeolocation = nil
- ipGeolocationError = nil
- ipGeolocationLoading = false
- emailSecurity = nil
- emailSecurityError = nil
- emailSecurityLoading = false
- ptrRecord = nil
- ptrError = nil
- ptrLoading = false
- redirectChain = []
- redirectChainError = nil
- redirectChainLoading = false
- portScanResults = []
- portScanError = nil
- portScanLoading = false
- customPortResults = []
- customPortScanError = nil
- customPortScanLoading = false
+ lastLookupDurationMs = nil
+ clearLookupState()
}
- // MARK: - Run
-
func run() {
let target = trimmedDomain
guard !target.isEmpty else { return }
+ lookupTask?.cancel()
+ customPortScanTask?.cancel()
+
+ let lookupID = UUID()
+ activeLookupID = lookupID
+ lookupStartedAt = Date()
+ lastLookupDurationMs = nil
addRecentSearch(target)
searchedDomain = target
hasRun = true
+ clearLookupState()
+ setAllLoadingStates(true)
+ customPortScanLoading = false
- // Reset all state
- dnsSections = []
- dnsError = nil
- dnsLoading = true
- sslInfo = nil
- sslError = nil
- sslLoading = true
- hstsPreloaded = nil
- hstsLoading = true
- httpHeaders = []
- httpSecurityGrade = nil
- httpStatusCode = nil
- httpResponseTimeMs = nil
- httpProtocol = nil
- http3Advertised = false
- httpHeadersError = nil
- httpHeadersLoading = true
- reachabilityResults = []
- reachabilityError = nil
- reachabilityLoading = true
- ipGeolocation = nil
- ipGeolocationError = nil
- ipGeolocationLoading = true
- emailSecurity = nil
- emailSecurityError = nil
- emailSecurityLoading = true
- ptrRecord = nil
- ptrError = nil
- ptrLoading = true
- redirectChain = []
- redirectChainError = nil
- redirectChainLoading = true
- portScanResults = []
- portScanError = nil
- portScanLoading = true
- customPortResults = []
+ lookupTask = Task { [weak self] in
+ guard let self else { return }
+ await self.performLookup(domain: target, lookupID: lookupID)
+ }
+ }
+
+ func runCustomPortScan(ports: [UInt16]) async {
+ guard !searchedDomain.isEmpty else {
+ customPortScanError = "Run a domain lookup first"
+ return
+ }
+
+ guard !ports.isEmpty else {
+ customPortScanError = "Enter at least one valid port"
+ customPortResults = []
+ return
+ }
+
+ customPortScanTask?.cancel()
+ let domain = searchedDomain
+ let lookupID = activeLookupID
+
+ customPortScanLoading = true
customPortScanError = nil
- customPortScanLoading = false
+ customPortResults = []
- Task {
- await withTaskGroup(of: Void.self) { group in
- // DNS → chained: email security, PTR, geolocation
- group.addTask { @MainActor in
- await self.runDNS(domain: target)
- // These depend on DNS results and run in parallel after DNS
- await withTaskGroup(of: Void.self) { postDNS in
- postDNS.addTask { @MainActor in
- await self.runEmailSecurity(domain: target)
- }
- postDNS.addTask { @MainActor in
- await self.runReverseDNS()
- }
- postDNS.addTask { @MainActor in
- await self.runIPGeolocation()
- }
- }
- }
- group.addTask { @MainActor in
- await self.runSSL(domain: target)
- }
- group.addTask { @MainActor in
- await self.runHSTSPreload(domain: target)
- }
- group.addTask { @MainActor in
- await self.runHTTPHeaders(domain: target)
- }
- group.addTask { @MainActor in
- await self.runReachability(domain: target)
- }
- group.addTask { @MainActor in
- await self.runRedirectChain(domain: target)
- }
- group.addTask { @MainActor in
- await self.runPortScan(domain: target)
- }
- }
- // Save history after all lookups complete so the snapshot is complete
- saveHistoryEntry()
+ customPortScanTask = Task { [weak self] in
+ guard let self else { return }
+ let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
+ guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
+ self.applyCustomPortResult(result)
}
}
- // MARK: - Lookup Methods
+ func exportText() -> String {
+ Self.formatExportText(from: currentSnapshot)
+ }
+
+ private func performLookup(domain: String, lookupID: UUID) async {
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
+ }
+
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+
+ let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+ let primaryIP = primaryIPAddress(from: dnsSections)
+
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
+ if let primaryIP {
+ group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
+ group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
+ } else {
+ group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
+ }
+ }
+
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
+ saveHistoryEntry(replaceLatest: false)
+ }
- private func runDNS(domain: String) async {
- do {
- let sections = await DNSLookupService.lookupAll(domain: domain)
+ private func runDNS(domain: String, lookupID: UUID) async {
+ let result = await DNSLookupService.lookupAll(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(sections):
dnsSections = sections
+ dnsError = nil
+ case let .empty(message):
+ dnsSections = []
+ dnsError = message
+ case let .error(message):
+ dnsSections = []
+ dnsError = message
}
dnsLoading = false
}
- private func runSSL(domain: String) async {
- do {
- let info = try await SSLCheckService.check(domain: domain)
+ private func runSSL(domain: String, lookupID: UUID) async {
+ let result = await SSLCheckService.check(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(info):
sslInfo = info
- } catch {
- sslError = error.localizedDescription
+ sslError = nil
+ case let .empty(message):
+ sslInfo = nil
+ sslError = message
+ case let .error(message):
+ sslInfo = nil
+ sslError = message
}
sslLoading = false
}
- private func runHSTSPreload(domain: String) async {
- hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
+ private func runHSTSPreload(domain: String, lookupID: UUID) async {
+ let result = await SSLCheckService.checkHSTSPreload(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ hstsPreloaded = result
hstsLoading = false
}
- private func runHTTPHeaders(domain: String) async {
- do {
- let result = try await HTTPHeadersService.fetch(domain: domain)
- httpHeaders = result.headers
- httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
- httpStatusCode = result.statusCode
- httpResponseTimeMs = result.responseTimeMs
- httpProtocol = result.httpProtocol
- http3Advertised = result.http3Advertised
- } catch {
- httpHeadersError = error.localizedDescription
+ private func runHTTPHeaders(domain: String, lookupID: UUID) async {
+ let result = await HTTPHeadersService.fetch(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(headersResult):
+ httpHeaders = headersResult.headers
+ httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
+ httpStatusCode = headersResult.statusCode
+ httpResponseTimeMs = headersResult.responseTimeMs
+ httpProtocol = headersResult.httpProtocol
+ http3Advertised = headersResult.http3Advertised
+ httpHeadersError = nil
+ case let .empty(message):
+ httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
+ httpHeadersError = message
+ case let .error(message):
+ httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
+ httpHeadersError = message
}
httpHeadersLoading = false
}
- private func runReachability(domain: String) async {
- let results = await ReachabilityService.checkAll(domain: domain)
- reachabilityResults = results
- reachabilityLoading = false
- }
-
- private func runIPGeolocation() async {
- // Find the first A record IP
- guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
- let firstIP = aSection.records.first?.value else {
- ipGeolocationError = "No A record available"
- ipGeolocationLoading = false
- return
+ private func runReachability(domain: String, lookupID: UUID) async {
+ let result = await ReachabilityService.checkAll(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(results):
+ reachabilityResults = results
+ reachabilityError = nil
+ case let .empty(message):
+ reachabilityResults = []
+ reachabilityError = message
+ case let .error(message):
+ reachabilityResults = []
+ reachabilityError = message
}
- do {
- let geo = try await IPGeolocationService.lookup(ip: firstIP)
- ipGeolocation = geo
- } catch {
- ipGeolocationError = error.localizedDescription
- }
- ipGeolocationLoading = false
+ reachabilityLoading = false
}
- private func runEmailSecurity(domain: String) async {
- // Extract TXT records from already-fetched DNS sections
- let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+ private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
- emailSecurity = result
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(emailResult):
+ emailSecurity = emailResult
+ emailSecurityError = nil
+ case let .empty(message):
+ emailSecurity = nil
+ emailSecurityError = message
+ case let .error(message):
+ emailSecurity = nil
+ emailSecurityError = message
+ }
emailSecurityLoading = false
}
- private func runReverseDNS() async {
- guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
- let firstIP = aSection.records.first?.value else {
- ptrError = "No A record available"
- ptrLoading = false
- return
- }
- let result = await ReverseDNSService.lookup(ip: firstIP)
- ptrRecord = result
- if result == nil {
- ptrError = "No PTR record found"
+ private func runReverseDNS(ip: String, lookupID: UUID) async {
+ let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(record):
+ ptrRecord = record
+ ptrError = nil
+ case let .empty(message):
+ ptrRecord = nil
+ ptrError = message
+ case let .error(message):
+ ptrRecord = nil
+ ptrError = message
}
ptrLoading = false
}
- private func runRedirectChain(domain: String) async {
- do {
- let hops = try await RedirectChainService.trace(domain: domain)
+ private func runRedirectChain(domain: String, lookupID: UUID) async {
+ let result = await RedirectChainService.trace(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(hops):
redirectChain = hops
- } catch {
- redirectChainError = error.localizedDescription
+ redirectChainError = nil
+ case let .empty(message):
+ redirectChain = []
+ redirectChainError = message
+ case let .error(message):
+ redirectChain = []
+ redirectChainError = message
}
redirectChainLoading = false
}
- private func runPortScan(domain: String) async {
- let results = await PortScanService.scanAll(domain: domain)
- let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
- portScanResults = enrichedResults
+ private func runPortScan(domain: String, lookupID: UUID) async {
+ let result = await PortScanService.scanAll(domain: domain)
+ switch result {
+ case let .success(results):
+ let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ portScanResults = enrichedResults
+ portScanError = nil
+ case let .empty(message):
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ portScanResults = []
+ portScanError = message
+ case let .error(message):
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ portScanResults = []
+ portScanError = message
+ }
portScanLoading = false
}
- func runCustomPortScan(ports: [UInt16]) async {
- guard !searchedDomain.isEmpty else {
- customPortScanError = "Run a domain lookup first"
- return
+ private func runIPGeolocation(ip: String, lookupID: UUID) async {
+ let result = await IPGeolocationService.lookup(ip: ip)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(geolocation):
+ ipGeolocation = geolocation
+ ipGeolocationError = nil
+ case let .empty(message):
+ ipGeolocation = nil
+ ipGeolocationError = message
+ case let .error(message):
+ ipGeolocation = nil
+ ipGeolocationError = message
}
+ ipGeolocationLoading = false
+ }
- guard !ports.isEmpty else {
- customPortScanError = "Enter at least one valid port"
+ private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ ptrLoading = false
+ ptrError = "No A record available"
+ ipGeolocationLoading = false
+ ipGeolocationError = "No A record available"
+ }
+
+ private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
+ switch result {
+ case let .success(results):
+ customPortResults = results
+ customPortScanError = nil
+ saveHistoryEntry(replaceLatest: true)
+ case let .empty(message):
customPortResults = []
- return
+ customPortScanError = message
+ case let .error(message):
+ customPortResults = []
+ customPortScanError = message
}
-
- customPortScanLoading = true
- customPortScanError = nil
- customPortResults = []
-
- let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0)
- customPortResults = results
customPortScanLoading = false
}
@@ -453,271 +711,502 @@ final class DomainViewModel {
}
}
- // MARK: - Export
-
- func exportText() -> String {
- return Self.formatExportText(
+ private func saveHistoryEntry(replaceLatest: Bool) {
+ guard !searchedDomain.isEmpty else { return }
+ let entry = HistoryEntry(
domain: searchedDomain,
- date: Date(),
+ timestamp: Date(),
dnsSections: dnsSections,
sslInfo: sslInfo,
- sslError: sslError,
- hstsPreloaded: hstsPreloaded,
httpHeaders: httpHeaders,
- httpSecurityGrade: httpSecurityGrade,
- httpStatusCode: httpStatusCode,
- httpResponseTimeMs: httpResponseTimeMs,
- httpProtocol: httpProtocol,
- http3Advertised: http3Advertised,
- httpHeadersError: httpHeadersError,
reachabilityResults: reachabilityResults,
ipGeolocation: ipGeolocation,
- ipGeolocationError: ipGeolocationError,
emailSecurity: emailSecurity,
+ mtaSts: emailSecurity?.mtaSts,
ptrRecord: ptrRecord,
redirectChain: redirectChain,
- portScanResults: portScanResults
+ portScanResults: allPortScanResults,
+ hstsPreloaded: hstsPreloaded,
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: lastLookupDurationMs,
+ sslError: sslError,
+ httpHeadersError: httpHeadersError,
+ reachabilityError: reachabilityError,
+ ipGeolocationError: ipGeolocationError,
+ emailSecurityError: emailSecurityError,
+ ptrError: ptrError,
+ redirectChainError: redirectChainError,
+ portScanError: combinedPortScanError
)
+
+ if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
+ history[0] = entry
+ } else {
+ history.insert(entry, at: 0)
+ if history.count > Self.maxHistory {
+ history = Array(history.prefix(Self.maxHistory))
+ }
+ }
+ persistHistory()
+ }
+
+ private func persistHistory() {
+ if let data = try? JSONEncoder().encode(history) {
+ UserDefaults.standard.set(data, forKey: Self.historyKey)
+ }
+ }
+
+ private func addRecentSearch(_ domain: String) {
+ recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
+ recentSearches.insert(domain, at: 0)
+ if recentSearches.count > Self.maxRecent {
+ recentSearches = Array(recentSearches.prefix(Self.maxRecent))
+ }
+ UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
+ }
+
+ private func clearLookupState() {
+ dnsSections = []
+ dnsError = nil
+ dnsLoading = false
+ sslInfo = nil
+ sslError = nil
+ sslLoading = false
+ hstsPreloaded = nil
+ hstsLoading = false
+ httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
+ httpHeadersError = nil
+ httpHeadersLoading = false
+ reachabilityResults = []
+ reachabilityError = nil
+ reachabilityLoading = false
+ ipGeolocation = nil
+ ipGeolocationError = nil
+ ipGeolocationLoading = false
+ emailSecurity = nil
+ emailSecurityError = nil
+ emailSecurityLoading = false
+ ptrRecord = nil
+ ptrError = nil
+ ptrLoading = false
+ redirectChain = []
+ redirectChainError = nil
+ redirectChainLoading = false
+ portScanResults = []
+ portScanError = nil
+ portScanLoading = false
+ customPortResults = []
+ customPortScanError = nil
+ customPortScanLoading = false
+ }
+
+ private func setAllLoadingStates(_ loading: Bool) {
+ dnsLoading = loading
+ sslLoading = loading
+ hstsLoading = loading
+ httpHeadersLoading = loading
+ reachabilityLoading = loading
+ ipGeolocationLoading = loading
+ emailSecurityLoading = loading
+ ptrLoading = loading
+ redirectChainLoading = loading
+ portScanLoading = loading
+ }
+
+ private func primaryIPAddress(from sections: [DNSSection]) -> String? {
+ sections.first(where: { $0.recordType == .A })?.records.first?.value
+ }
+
+ private func isCurrentLookup(_ lookupID: UUID) -> Bool {
+ activeLookupID == lookupID
+ }
+
+ static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
+ [
+ SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
+ SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
+ SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
+ SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
+ SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
+ ]
+ }
+
+ static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+ [
+ InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
+ InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
+ InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
+ InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
+ ]
+ }
+
+ static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
+ snapshot.dnsSections.map { section in
+ DNSRecordSectionViewData(
+ title: section.recordType.rawValue,
+ rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
+ wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
+ wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
+ message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
+ ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
+ )
+ }
+ }
+
+ static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
+ guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
+ return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
+ }
+
+ static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
+ if let ptrRecord = snapshot.ptrRecord {
+ return SectionMessageViewData(text: ptrRecord, isError: false)
+ }
+ if let ptrError = snapshot.ptrError {
+ return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
+ }
+ return nil
+ }
+
+ static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+ guard let sslInfo = snapshot.sslInfo else { return [] }
+ var rows = [
+ InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
+ InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
+ InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary),
+ InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary),
+ InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
+ InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
+ ]
+ if let tlsVersion = sslInfo.tlsVersion {
+ rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
+ }
+ if let cipherSuite = sslInfo.cipherSuite {
+ rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
+ }
+ if let hstsPreloaded = snapshot.hstsPreloaded {
+ rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
+ }
+ return rows
+ }
+
+ static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+ var rows: [InfoRowViewData] = []
+ if let httpStatusCode = snapshot.httpStatusCode {
+ rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
+ }
+ if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
+ rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
+ }
+ if let httpProtocol = snapshot.httpProtocol {
+ rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
+ }
+ if let httpSecurityGrade = snapshot.httpSecurityGrade {
+ rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
+ }
+ if snapshot.http3Advertised {
+ rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
+ }
+ return rows
+ }
+
+ static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
+ snapshot.redirectChain.map {
+ RedirectHopViewData(
+ stepLabel: "\($0.stepNumber)",
+ statusCode: "\($0.statusCode)",
+ url: $0.url,
+ isFinal: $0.isFinal
+ )
+ }
}
- static func formatExportText(
- domain: String,
- date: Date,
- dnsSections: [DNSSection],
- sslInfo: SSLCertificateInfo?,
- sslError: String? = nil,
- hstsPreloaded: Bool? = nil,
- httpHeaders: [HTTPHeader],
- httpSecurityGrade: String? = nil,
- httpStatusCode: Int? = nil,
- httpResponseTimeMs: Int? = nil,
- httpProtocol: String? = nil,
- http3Advertised: Bool = false,
- httpHeadersError: String? = nil,
- reachabilityResults: [PortReachability],
- ipGeolocation: IPGeolocation?,
- ipGeolocationError: String? = nil,
- emailSecurity: EmailSecurityResult? = nil,
- ptrRecord: String? = nil,
- redirectChain: [RedirectHop] = [],
- portScanResults: [PortScanResult] = []
- ) -> String {
- let dateFmt = DateFormatter()
- dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
+ static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
+ guard let emailSecurity = snapshot.emailSecurity else { return [] }
+ return [
+ EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
+ EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
+ EmailRowViewData(label: "DKIM", status: emailSecurity.dkim.found ? "Present" : "Missing", statusTone: emailSecurity.dkim.found ? .success : .warning, detail: emailSecurity.dkim.value ?? "No record found", auxiliaryDetail: emailSecurity.dkim.matchedSelector.map { "Selector: \($0)" }),
+ EmailRowViewData(label: "MTA-STS", status: emailSecurity.mtaSts?.txtFound == true ? "Present" : "Missing", statusTone: emailSecurity.mtaSts?.txtFound == true ? .success : .warning, detail: emailSecurity.mtaSts?.policyMode ?? (emailSecurity.mtaSts?.txtFound == true ? "Policy unavailable" : "No record found"), auxiliaryDetail: nil),
+ EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
+ ]
+ }
+
+ static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
+ snapshot.reachabilityResults.map {
+ ReachabilityRowViewData(
+ portLabel: "Port \($0.port)",
+ latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
+ statusLabel: $0.reachable ? "Reachable" : "Unreachable",
+ statusTone: $0.reachable ? .success : .failure
+ )
+ }
+ }
+
+ static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+ guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
+ var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
+ if let org = ipGeolocation.org {
+ rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
+ }
+ let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
+ if !location.isEmpty {
+ rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
+ }
+ if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
+ rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
+ }
+ return rows
+ }
+
+ static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
+ snapshot.portScanResults
+ .filter { $0.kind == kind }
+ .map {
+ PortScanRowViewData(
+ portLabel: "\($0.port)",
+ service: $0.service,
+ statusLabel: $0.open ? "Open" : "Closed",
+ statusTone: $0.open ? .success : .secondary,
+ banner: $0.banner,
+ durationLabel: $0.durationMs.map { "\($0) ms" }
+ )
+ }
+ }
+
+ static func formatExportText(from snapshot: LookupSnapshot) -> String {
+ let exportDateFormatter = DateFormatter()
+ exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
var lines: [String] = [
"DomainDig Export",
- "Domain: \(domain)",
- "Date: \(dateFmt.string(from: date))",
+ "Domain: \(snapshot.domain)",
+ "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
+ "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
+ "Resolver: \(snapshot.resolverDisplayName)",
+ "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
]
- // Reachability
- if !reachabilityResults.isEmpty {
+ func appendSection(_ title: String, body: () -> Void) {
lines.append("")
- lines.append("Reachability")
- lines.append("------------")
- for result in reachabilityResults {
- if result.reachable, let ms = result.latencyMs {
- lines.append(" Port \(result.port) \(ms)ms Reachable")
- } else {
- lines.append(" Port \(result.port) — Unreachable")
- }
+ lines.append(title)
+ lines.append(String(repeating: "-", count: title.count))
+ body()
+ }
+
+ appendSection("Summary") {
+ for item in summaryFields(from: snapshot) {
+ lines.append(" \(item.label): \(item.value)")
}
}
- // Redirect Chain
- if !redirectChain.isEmpty {
- lines.append("")
- lines.append("Redirect Chain")
- lines.append("--------------")
- if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
- lines.append(" No redirects — direct connection")
- } else {
- for hop in redirectChain {
- let final = hop.isFinal ? " (final)" : ""
- lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)")
- }
+ appendSection("Domain") {
+ for row in domainRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.value)")
}
}
- // DNS
- lines.append("")
- lines.append("DNS Records")
- lines.append("-----------")
- for section in dnsSections {
- lines.append(section.recordType.rawValue)
- if let error = section.error {
- lines.append(" Error: \(error)")
- } else if section.records.isEmpty {
- lines.append(" No records found")
- } else {
- for record in section.records {
- lines.append(" \(record.value) TTL \(record.ttl)")
- }
+ appendSection("DNS") {
+ if let dnsError = snapshot.dnsError {
+ lines.append(" Error: \(dnsError)")
+ }
+ if let dnssecLabel = dnssecLabel(from: snapshot) {
+ lines.append(" \(dnssecLabel)")
}
- if !section.wildcardRecords.isEmpty {
- lines.append("*.\(domain)")
- for record in section.wildcardRecords {
- lines.append(" \(record.value) TTL \(record.ttl)")
+ for section in dnsRows(from: snapshot) {
+ lines.append(" \(section.title)")
+ if let message = section.message {
+ lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
}
+ for row in section.rows {
+ lines.append(" \(row.value) (\(row.label))")
+ }
+ if let wildcardTitle = section.wildcardTitle {
+ lines.append(" \(wildcardTitle)")
+ for row in section.wildcardRows {
+ lines.append(" \(row.value) (\(row.label))")
+ }
+ }
+ }
+ if let ptrRecord = snapshot.ptrRecord {
+ lines.append(" PTR: \(ptrRecord)")
+ } else if let ptrError = snapshot.ptrError {
+ lines.append(" PTR Error: \(ptrError)")
}
}
- // PTR
- if let ptr = ptrRecord {
- lines.append("PTR (Reverse DNS)")
- lines.append(" \(ptr)")
- }
+ appendSection("Web") {
+ if let sslError = snapshot.sslError {
+ lines.append(" TLS Error: \(sslError)")
+ } else {
+ for row in webCertificateRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.value)")
+ }
+ }
- // Email Security
- if let email = emailSecurity {
- lines.append("")
- lines.append("Email Security")
- lines.append("--------------")
- lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
- lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
- let dkimValue = if let selector = email.dkim.matchedSelector,
- let value = email.dkim.value {
- "\(value) (selector: \(selector))"
+ if let httpHeadersError = snapshot.httpHeadersError {
+ lines.append(" Headers Error: \(httpHeadersError)")
} else {
- email.dkim.value ?? "No record found"
+ for row in webResponseRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.value)")
+ }
+ if snapshot.httpHeaders.isEmpty {
+ lines.append(" Headers: No headers returned")
+ } else {
+ lines.append(" Headers:")
+ for header in snapshot.httpHeaders {
+ lines.append(" \(header.name): \(header.value)")
+ }
+ }
}
- lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)")
- let mtaDescription = if let mode = email.mtaSts?.policyMode {
- "mode: \(mode)"
- } else if email.mtaSts?.txtFound == true {
- "Policy unavailable"
+
+ if let redirectChainError = snapshot.redirectChainError {
+ lines.append(" Redirect Error: \(redirectChainError)")
+ } else if snapshot.redirectChain.isEmpty {
+ lines.append(" Redirects: No redirect data available")
} else {
- "No record found"
+ lines.append(" Redirects:")
+ for hop in redirectRows(from: snapshot) {
+ lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
+ }
}
- lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)")
- lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")")
}
- // SSL
- if let info = sslInfo {
- let certDateFmt = DateFormatter()
- certDateFmt.dateStyle = .medium
- certDateFmt.timeStyle = .none
-
- lines.append("")
- lines.append("SSL / TLS Certificate")
- lines.append("---------------------")
- lines.append("Common Name: \(info.commonName)")
- lines.append("Issuer: \(info.issuer)")
- lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))")
- lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))")
- lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
- lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
- lines.append("Chain Depth: \(info.chainDepth)")
- if let tlsVersion = info.tlsVersion {
- lines.append("TLS Version: \(tlsVersion)")
- }
- if let cipherSuite = info.cipherSuite {
- lines.append("Cipher Suite: \(cipherSuite)")
- }
- if let hstsPreloaded {
- lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")")
- }
- if !info.chain.isEmpty {
- lines.append("Certificate Chain:")
- for certificate in info.chain {
- lines.append(" Subject: \(certificate.subject)")
- lines.append(" Issuer: \(certificate.issuer)")
+ appendSection("Email") {
+ if let emailSecurityError = snapshot.emailSecurityError {
+ lines.append(" Error: \(emailSecurityError)")
+ } else if emailRows(from: snapshot).isEmpty {
+ lines.append(" No email security records found")
+ } else {
+ for row in emailRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.status)")
+ lines.append(" \(row.detail)")
+ if let auxiliaryDetail = row.auxiliaryDetail {
+ lines.append(" \(auxiliaryDetail)")
+ }
}
}
- } else if let error = sslError {
- lines.append("")
- lines.append("SSL / TLS Certificate")
- lines.append("---------------------")
- lines.append("Error: \(error)")
}
- // HTTP Headers
- if !httpHeaders.isEmpty {
- lines.append("")
- lines.append("HTTP Headers")
- lines.append("------------")
- for header in httpHeaders {
- lines.append(" \(header.name): \(header.value)")
- }
- if let httpSecurityGrade {
- lines.append("Grade: \(httpSecurityGrade)")
- }
- if let httpStatusCode {
- lines.append("Status: \(httpStatusCode)")
- }
- if let httpResponseTimeMs {
- lines.append("Response Time: \(httpResponseTimeMs)ms")
- }
- if let httpProtocol {
- lines.append("Protocol: \(httpProtocol)")
+ appendSection("Network") {
+ if let reachabilityError = snapshot.reachabilityError {
+ lines.append(" Reachability Error: \(reachabilityError)")
+ } else if reachabilityRows(from: snapshot).isEmpty {
+ lines.append(" Reachability: No results")
+ } else {
+ lines.append(" Reachability:")
+ for row in reachabilityRows(from: snapshot) {
+ lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
+ }
}
- if http3Advertised {
- lines.append("HTTP/3 Advertised: Yes")
+
+ if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
+ lines.append(" Location Error: \(ipGeolocationError)")
+ } else if locationRows(from: snapshot).isEmpty {
+ lines.append(" Location: No data")
+ } else {
+ lines.append(" Location:")
+ for row in locationRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.value)")
+ }
}
- } else if let error = httpHeadersError {
- lines.append("")
- lines.append("HTTP Headers")
- lines.append("------------")
- lines.append("Error: \(error)")
- }
- // IP Geolocation
- if let geo = ipGeolocation {
- lines.append("")
- lines.append("IP Location")
- lines.append("-----------")
- lines.append("IP: \(geo.ip)")
- if let org = geo.org { lines.append("Org: \(org)") }
- let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
- if !location.isEmpty { lines.append("Location: \(location)") }
- if let lat = geo.latitude, let lon = geo.longitude {
- lines.append("Coordinates: \(lat), \(lon)")
+ if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
+ lines.append(" Port Scan Error: \(portScanError)")
}
- } else if let error = ipGeolocationError, error != "No A record available" {
- lines.append("")
- lines.append("IP Location")
- lines.append("-----------")
- lines.append("Error: \(error)")
- }
- // Open Ports
- if !portScanResults.isEmpty {
- lines.append("")
- lines.append("Open Ports")
- lines.append("----------")
- let openPorts = portScanResults.filter { $0.open }
- if openPorts.isEmpty {
- lines.append(" No open ports detected")
+ lines.append(" Standard Ports:")
+ let standardRows = portRows(from: snapshot, kind: .standard)
+ if standardRows.isEmpty {
+ lines.append(" No results")
} else {
- for port in openPorts {
- let bannerSuffix = port.banner.map { " \($0)" } ?? ""
- lines.append(" \(port.port) \(port.service)\(bannerSuffix)")
+ for row in standardRows {
+ lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
+ if let banner = row.banner {
+ lines.append(" Banner: \(banner)")
+ }
}
}
- let closedPorts = portScanResults.filter { !$0.open }
- if !closedPorts.isEmpty {
- lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
+
+ lines.append(" Custom Ports:")
+ let customRows = portRows(from: snapshot, kind: .custom)
+ if customRows.isEmpty {
+ lines.append(" No results")
+ } else {
+ for row in customRows {
+ lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
+ if let banner = row.banner {
+ lines.append(" Banner: \(banner)")
+ }
+ }
}
}
return lines.joined(separator: "\n")
}
- // MARK: - Recent Searches
+ private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
+ snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+ }
- private func addRecentSearch(_ domain: String) {
- recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
- recentSearches.insert(domain, at: 0)
- if recentSearches.count > Self.maxRecent {
- recentSearches = Array(recentSearches.prefix(Self.maxRecent))
+ private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
+ snapshot.redirectChain.last?.url
+ }
+
+ private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
+ if snapshot.sslInfo != nil {
+ return "Valid"
}
- UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
+ if let sslError = snapshot.sslError {
+ return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
+ }
+ return "Unavailable"
}
- func clearRecentSearches() {
- recentSearches.removeAll()
- UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
+ private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
+ if snapshot.sslInfo != nil {
+ return .success
+ }
+ return snapshot.sslError == nil ? .secondary : .failure
+ }
+
+ private static func emailSummary(from snapshot: LookupSnapshot) -> String {
+ guard let emailSecurity = snapshot.emailSecurity else {
+ return snapshot.emailSecurityError ?? "Unavailable"
+ }
+ return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
+ }
+
+ private static func securityGradeTone(_ grade: String) -> ResultTone {
+ switch grade {
+ case "A", "B":
+ return .success
+ case "C":
+ return .warning
+ case "D", "F":
+ return .failure
+ default:
+ return .secondary
+ }
+ }
+
+ private static func durationLabel(_ durationMs: Int?) -> String {
+ durationMs.map { "\($0) ms" } ?? "Unavailable"
+ }
+}
+
+private extension String {
+ var nonEmpty: String? {
+ isEmpty ? nil : self
+ }
+
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
}
}