krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v2.0.0: DomainDig/DomainViewModel.swift · raw
1import Foundation
2import SwiftUI
3
4enum ResultTone {
5 case primary
6 case secondary
7 case success
8 case warning
9 case failure
10}
11
12struct SummaryFieldViewData: Identifiable {
13 let id = UUID()
14 let label: String
15 let value: String
16 let tone: ResultTone
17}
18
19struct InfoRowViewData: Identifiable {
20 let id = UUID()
21 let label: String
22 let value: String
23 let tone: ResultTone
24}
25
26struct SectionMessageViewData {
27 let text: String
28 let isError: Bool
29}
30
31struct DNSRecordSectionViewData: Identifiable {
32 let id = UUID()
33 let title: String
34 let rows: [InfoRowViewData]
35 let wildcardRows: [InfoRowViewData]
36 let wildcardTitle: String?
37 let message: SectionMessageViewData?
38}
39
40struct EmailRowViewData: Identifiable {
41 let id = UUID()
42 let label: String
43 let status: String
44 let statusTone: ResultTone
45 let detail: String
46 let auxiliaryDetail: String?
47}
48
49struct RedirectHopViewData: Identifiable {
50 let id = UUID()
51 let stepLabel: String
52 let statusCode: String
53 let url: String
54 let isFinal: Bool
55}
56
57struct ReachabilityRowViewData: Identifiable {
58 let id = UUID()
59 let portLabel: String
60 let latencyLabel: String
61 let statusLabel: String
62 let statusTone: ResultTone
63}
64
65struct PortScanRowViewData: Identifiable {
66 let id = UUID()
67 let portLabel: String
68 let service: String
69 let statusLabel: String
70 let statusTone: ResultTone
71 let banner: String?
72 let durationLabel: String?
73}
74
75struct DomainSuggestionViewData: Identifiable {
76 let id: UUID
77 let domain: String
78 let status: String
79 let tone: ResultTone
80}
81
82struct LookupSnapshot {
83 let historyEntryID: UUID?
84 let domain: String
85 let timestamp: Date
86 let trackedDomainID: UUID?
87 let resolverDisplayName: String
88 let resolverURLString: String
89 let totalLookupDurationMs: Int?
90 let dnsSections: [DNSSection]
91 let dnsError: String?
92 let availabilityResult: DomainAvailabilityResult?
93 let suggestions: [DomainSuggestionResult]
94 let sslInfo: SSLCertificateInfo?
95 let sslError: String?
96 let hstsPreloaded: Bool?
97 let httpHeaders: [HTTPHeader]
98 let httpSecurityGrade: String?
99 let httpStatusCode: Int?
100 let httpResponseTimeMs: Int?
101 let httpProtocol: String?
102 let http3Advertised: Bool
103 let httpHeadersError: String?
104 let reachabilityResults: [PortReachability]
105 let reachabilityError: String?
106 let ipGeolocation: IPGeolocation?
107 let ipGeolocationError: String?
108 let emailSecurity: EmailSecurityResult?
109 let emailSecurityError: String?
110 let ptrRecord: String?
111 let ptrError: String?
112 let redirectChain: [RedirectHop]
113 let redirectChainError: String?
114 let portScanResults: [PortScanResult]
115 let portScanError: String?
116 let changeSummary: DomainChangeSummary?
117 let isLive: Bool
118}
119
120extension HistoryEntry {
121 var snapshot: LookupSnapshot {
122 LookupSnapshot(
123 historyEntryID: id,
124 domain: domain,
125 timestamp: timestamp,
126 trackedDomainID: trackedDomainID,
127 resolverDisplayName: resolverDisplayName,
128 resolverURLString: resolverURLString,
129 totalLookupDurationMs: totalLookupDurationMs,
130 dnsSections: dnsSections,
131 dnsError: nil,
132 availabilityResult: availabilityResult,
133 suggestions: suggestions,
134 sslInfo: sslInfo,
135 sslError: sslError,
136 hstsPreloaded: hstsPreloaded,
137 httpHeaders: httpHeaders,
138 httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
139 httpStatusCode: nil,
140 httpResponseTimeMs: nil,
141 httpProtocol: nil,
142 http3Advertised: false,
143 httpHeadersError: httpHeadersError,
144 reachabilityResults: reachabilityResults,
145 reachabilityError: reachabilityError,
146 ipGeolocation: ipGeolocation,
147 ipGeolocationError: ipGeolocationError,
148 emailSecurity: emailSecurity,
149 emailSecurityError: emailSecurityError,
150 ptrRecord: ptrRecord,
151 ptrError: ptrError,
152 redirectChain: redirectChain,
153 redirectChainError: redirectChainError,
154 portScanResults: portScanResults,
155 portScanError: portScanError,
156 changeSummary: changeSummary,
157 isLive: false
158 )
159 }
160}
161
162@MainActor
163@Observable
164final class DomainViewModel {
165 var domain: String = ""
166
167 var dnsSections: [DNSSection] = []
168 var dnsLoading = false
169 var dnsError: String?
170 var availabilityResult: DomainAvailabilityResult?
171 var availabilityLoading = false
172 var suggestions: [DomainSuggestionResult] = []
173 var suggestionsLoading = false
174
175 var sslInfo: SSLCertificateInfo?
176 var sslLoading = false
177 var sslError: String?
178 var hstsPreloaded: Bool?
179 var hstsLoading = false
180
181 var httpHeaders: [HTTPHeader] = []
182 var httpSecurityGrade: String?
183 var httpStatusCode: Int?
184 var httpResponseTimeMs: Int?
185 var httpProtocol: String?
186 var http3Advertised = false
187 var httpHeadersLoading = false
188 var httpHeadersError: String?
189
190 var reachabilityResults: [PortReachability] = []
191 var reachabilityLoading = false
192 var reachabilityError: String?
193
194 var ipGeolocation: IPGeolocation?
195 var ipGeolocationLoading = false
196 var ipGeolocationError: String?
197
198 var emailSecurity: EmailSecurityResult?
199 var emailSecurityLoading = false
200 var emailSecurityError: String?
201
202 var ptrRecord: String?
203 var ptrLoading = false
204 var ptrError: String?
205
206 var redirectChain: [RedirectHop] = []
207 var redirectChainLoading = false
208 var redirectChainError: String?
209
210 var portScanResults: [PortScanResult] = []
211 var portScanLoading = false
212 var portScanError: String?
213 var customPortResults: [PortScanResult] = []
214 var customPortScanLoading = false
215 var customPortScanError: String?
216
217 var hasRun = false
218 private(set) var searchedDomain: String = ""
219 private(set) var lastLookupDurationMs: Int?
220 private(set) var currentDiffSections: [DomainDiffSection] = []
221 private(set) var currentChangeSummary: DomainChangeSummary?
222 private(set) var refreshingTrackedDomainID: UUID?
223 private(set) var rerunNavigationToken = UUID()
224
225 private var lookupTask: Task<Void, Never>?
226 private var customPortScanTask: Task<Void, Never>?
227 private var activeLookupID = UUID()
228 private var lookupStartedAt: Date?
229
230 private static let recentSearchesKey = "recentSearches"
231 private static let maxRecent = 20
232 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
233
234 private static let savedDomainsKey = "savedDomains"
235 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
236
237 private static let trackedDomainsKey = "trackedDomains"
238 private static let legacyWatchedDomainsKey = "watchedDomains"
239 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
240
241 private static let historyKey = "lookupHistory"
242 private static let maxHistory = 50
243 var history: [HistoryEntry] = {
244 guard let data = UserDefaults.standard.data(forKey: historyKey),
245 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
246 return []
247 }
248 return entries
249 }()
250
251 var trimmedDomain: String {
252 domain
253 .trimmingCharacters(in: .whitespacesAndNewlines)
254 .replacingOccurrences(of: "https://", with: "")
255 .replacingOccurrences(of: "http://", with: "")
256 .components(separatedBy: "/").first ?? ""
257 }
258
259 var resultsLoaded: Bool {
260 hasRun &&
261 !dnsLoading &&
262 !availabilityLoading &&
263 !suggestionsLoading &&
264 !sslLoading &&
265 !hstsLoading &&
266 !httpHeadersLoading &&
267 !reachabilityLoading &&
268 !ipGeolocationLoading &&
269 !emailSecurityLoading &&
270 !ptrLoading &&
271 !redirectChainLoading &&
272 !portScanLoading &&
273 !customPortScanLoading
274 }
275
276 var isCloudflareProxied: Bool {
277 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
278 }
279
280 var isCurrentDomainSaved: Bool {
281 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
282 }
283
284 var sortedTrackedDomains: [TrackedDomain] {
285 trackedDomains.sorted {
286 if $0.isPinned != $1.isPinned {
287 return $0.isPinned && !$1.isPinned
288 }
289 if $0.updatedAt != $1.updatedAt {
290 return $0.updatedAt > $1.updatedAt
291 }
292 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
293 }
294 }
295
296 var currentTrackedDomain: TrackedDomain? {
297 guard !searchedDomain.isEmpty else { return nil }
298 return trackedDomain(for: searchedDomain)
299 }
300
301 var isCurrentDomainTracked: Bool {
302 currentTrackedDomain != nil
303 }
304
305 var trackingLimitMessage: String? {
306 guard currentTrackedDomain == nil else { return nil }
307 guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
308 return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
309 }
310
311 var canTrackCurrentDomain: Bool {
312 currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
313 }
314
315 var resolverDisplayName: String {
316 DNSLookupService.currentResolverDisplayName()
317 }
318
319 var resolverURLString: String {
320 DNSLookupService.currentResolverURLString()
321 }
322
323 var allPortScanResults: [PortScanResult] {
324 (portScanResults + customPortResults).sorted {
325 if $0.kind == $1.kind {
326 return $0.port < $1.port
327 }
328 return $0.kind == .standard
329 }
330 }
331
332 var currentSnapshot: LookupSnapshot {
333 LookupSnapshot(
334 historyEntryID: nil,
335 domain: searchedDomain,
336 timestamp: Date(),
337 trackedDomainID: currentTrackedDomain?.id,
338 resolverDisplayName: resolverDisplayName,
339 resolverURLString: resolverURLString,
340 totalLookupDurationMs: lastLookupDurationMs,
341 dnsSections: dnsSections,
342 dnsError: dnsError,
343 availabilityResult: availabilityResult,
344 suggestions: suggestions,
345 sslInfo: sslInfo,
346 sslError: sslError,
347 hstsPreloaded: hstsPreloaded,
348 httpHeaders: httpHeaders,
349 httpSecurityGrade: httpSecurityGrade,
350 httpStatusCode: httpStatusCode,
351 httpResponseTimeMs: httpResponseTimeMs,
352 httpProtocol: httpProtocol,
353 http3Advertised: http3Advertised,
354 httpHeadersError: httpHeadersError,
355 reachabilityResults: reachabilityResults,
356 reachabilityError: reachabilityError,
357 ipGeolocation: ipGeolocation,
358 ipGeolocationError: ipGeolocationError,
359 emailSecurity: emailSecurity,
360 emailSecurityError: emailSecurityError,
361 ptrRecord: ptrRecord,
362 ptrError: ptrError,
363 redirectChain: redirectChain,
364 redirectChainError: redirectChainError,
365 portScanResults: allPortScanResults,
366 portScanError: combinedPortScanError,
367 changeSummary: currentChangeSummary,
368 isLive: true
369 )
370 }
371
372 var summaryFields: [SummaryFieldViewData] {
373 Self.summaryFields(from: currentSnapshot)
374 }
375
376 var domainRows: [InfoRowViewData] {
377 Self.domainRows(from: currentSnapshot)
378 }
379
380 var dnsRows: [DNSRecordSectionViewData] {
381 Self.dnsRows(from: currentSnapshot)
382 }
383
384 var suggestionRows: [DomainSuggestionViewData] {
385 Self.suggestionRows(from: currentSnapshot)
386 }
387
388 var dnssecLabel: String? {
389 Self.dnssecLabel(from: currentSnapshot)
390 }
391
392 var ptrMessage: SectionMessageViewData? {
393 Self.ptrMessage(from: currentSnapshot)
394 }
395
396 var webCertificateRows: [InfoRowViewData] {
397 Self.webCertificateRows(from: currentSnapshot)
398 }
399
400 var webResponseRows: [InfoRowViewData] {
401 Self.webResponseRows(from: currentSnapshot)
402 }
403
404 var redirectRows: [RedirectHopViewData] {
405 Self.redirectRows(from: currentSnapshot)
406 }
407
408 var emailRows: [EmailRowViewData] {
409 Self.emailRows(from: currentSnapshot)
410 }
411
412 var reachabilityRows: [ReachabilityRowViewData] {
413 Self.reachabilityRows(from: currentSnapshot)
414 }
415
416 var locationRows: [InfoRowViewData] {
417 Self.locationRows(from: currentSnapshot)
418 }
419
420 var standardPortRows: [PortScanRowViewData] {
421 Self.portRows(from: currentSnapshot, kind: .standard)
422 }
423
424 var customPortRows: [PortScanRowViewData] {
425 Self.portRows(from: currentSnapshot, kind: .custom)
426 }
427
428 var combinedPortScanError: String? {
429 [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
430 }
431
432 func toggleSavedDomain() {
433 if isCurrentDomainSaved {
434 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
435 } else {
436 savedDomains.append(searchedDomain)
437 }
438 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
439 }
440
441 func removeSavedDomains(at offsets: IndexSet) {
442 savedDomains.remove(atOffsets: offsets)
443 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
444 }
445
446 @discardableResult
447 func trackCurrentDomain() -> Bool {
448 guard !searchedDomain.isEmpty else { return false }
449 return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
450 }
451
452 @discardableResult
453 func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
454 let normalizedDomain = normalizedDomain(domain)
455 guard !normalizedDomain.isEmpty else { return false }
456
457 if trackedDomain(for: normalizedDomain) != nil {
458 return true
459 }
460
461 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
462 return false
463 }
464
465 trackedDomains.insert(
466 TrackedDomain(
467 domain: normalizedDomain,
468 createdAt: Date(),
469 updatedAt: Date(),
470 lastKnownAvailability: availabilityStatus
471 ),
472 at: 0
473 )
474 persistTrackedDomains()
475 linkTrackedDomainHistory(for: normalizedDomain)
476 return true
477 }
478
479 func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
480 refreshingTrackedDomainID = trackedDomain.id
481 domain = trackedDomain.domain
482 run()
483 }
484
485 func rerunInspection(for trackedDomain: TrackedDomain) {
486 domain = trackedDomain.domain
487 run()
488 rerunNavigationToken = UUID()
489 }
490
491 func deleteTrackedDomains(at offsets: IndexSet) {
492 let ids = offsets.map { sortedTrackedDomains[$0].id }
493 trackedDomains.removeAll { ids.contains($0.id) }
494 history.indices.forEach { index in
495 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
496 history[index].trackedDomainID = nil
497 }
498 }
499 persistTrackedDomains()
500 persistHistory()
501 }
502
503 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
504 trackedDomains.removeAll { $0.id == trackedDomain.id }
505 history.indices.forEach { index in
506 if history[index].trackedDomainID == trackedDomain.id {
507 history[index].trackedDomainID = nil
508 }
509 }
510 persistTrackedDomains()
511 persistHistory()
512 }
513
514 func togglePinned(for trackedDomain: TrackedDomain) {
515 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
516 trackedDomains[index].isPinned.toggle()
517 persistTrackedDomains()
518 }
519
520 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
521 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
522 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
523 persistTrackedDomains()
524 }
525
526 func removeHistoryEntries(at offsets: IndexSet) {
527 history.remove(atOffsets: offsets)
528 persistHistory()
529 }
530
531 func clearHistory() {
532 history.removeAll()
533 persistHistory()
534 }
535
536 func clearRecentSearches() {
537 recentSearches.removeAll()
538 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
539 }
540
541 func rerunLookup(from entry: HistoryEntry) {
542 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
543 domain = entry.domain
544 run()
545 rerunNavigationToken = UUID()
546 }
547
548 func reset() {
549 lookupTask?.cancel()
550 customPortScanTask?.cancel()
551 hasRun = false
552 searchedDomain = ""
553 lastLookupDurationMs = nil
554 currentDiffSections = []
555 currentChangeSummary = nil
556 refreshingTrackedDomainID = nil
557 clearLookupState()
558 }
559
560 func run() {
561 let target = trimmedDomain
562 guard !target.isEmpty else { return }
563
564 lookupTask?.cancel()
565 customPortScanTask?.cancel()
566
567 let lookupID = UUID()
568 activeLookupID = lookupID
569 lookupStartedAt = Date()
570 lastLookupDurationMs = nil
571 addRecentSearch(target)
572 searchedDomain = target
573 hasRun = true
574 currentDiffSections = []
575 currentChangeSummary = nil
576 clearLookupState()
577 setAllLoadingStates(true)
578 customPortScanLoading = false
579
580 lookupTask = Task { [weak self] in
581 guard let self else { return }
582 await self.performLookup(domain: target, lookupID: lookupID)
583 }
584 }
585
586 func runCustomPortScan(ports: [UInt16]) async {
587 guard !searchedDomain.isEmpty else {
588 customPortScanError = "Run a domain lookup first"
589 return
590 }
591
592 guard !ports.isEmpty else {
593 customPortScanError = "Enter at least one valid port"
594 customPortResults = []
595 return
596 }
597
598 customPortScanTask?.cancel()
599 let domain = searchedDomain
600 let lookupID = activeLookupID
601
602 customPortScanLoading = true
603 customPortScanError = nil
604 customPortResults = []
605
606 customPortScanTask = Task { [weak self] in
607 guard let self else { return }
608 let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
609 guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
610 self.applyCustomPortResult(result)
611 }
612 }
613
614 func exportText() -> String {
615 Self.formatExportText(
616 from: currentSnapshot,
617 trackedDomain: currentTrackedDomain,
618 changeSummary: currentChangeSummary,
619 diffSections: currentDiffSections
620 )
621 }
622
623 private func performLookup(domain: String, lookupID: UUID) async {
624 await withTaskGroup(of: Void.self) { group in
625 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
626 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
627 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
628 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
629 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
630 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
631 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
632 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
633 }
634
635 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
636
637 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
638 let primaryIP = primaryIPAddress(from: dnsSections)
639
640 await withTaskGroup(of: Void.self) { group in
641 group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
642 if let primaryIP {
643 group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
644 group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
645 } else {
646 group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
647 }
648 }
649
650 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
651
652 if availabilityResult?.status == .registered {
653 await runSuggestions(domain: domain, lookupID: lookupID)
654 } else {
655 suggestions = []
656 suggestionsLoading = false
657 }
658
659 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
660 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
661 saveHistoryEntry(replaceLatest: false)
662 refreshingTrackedDomainID = nil
663 }
664
665 private func runDNS(domain: String, lookupID: UUID) async {
666 let result = await DNSLookupService.lookupAll(domain: domain)
667 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
668 switch result {
669 case let .success(sections):
670 dnsSections = sections
671 dnsError = nil
672 case let .empty(message):
673 dnsSections = []
674 dnsError = message
675 case let .error(message):
676 dnsSections = []
677 dnsError = message
678 }
679 dnsLoading = false
680 }
681
682 private func runAvailability(domain: String, lookupID: UUID) async {
683 let result = await DomainAvailabilityService.check(domain: domain)
684 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
685 availabilityResult = result
686 availabilityLoading = false
687 updateTrackedDomainAvailability(for: result.domain, status: result.status)
688 }
689
690 private func runSSL(domain: String, lookupID: UUID) async {
691 let result = await SSLCheckService.check(domain: domain)
692 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
693 switch result {
694 case let .success(info):
695 sslInfo = info
696 sslError = nil
697 case let .empty(message):
698 sslInfo = nil
699 sslError = message
700 case let .error(message):
701 sslInfo = nil
702 sslError = message
703 }
704 sslLoading = false
705 }
706
707 private func runHSTSPreload(domain: String, lookupID: UUID) async {
708 let result = await SSLCheckService.checkHSTSPreload(domain: domain)
709 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
710 hstsPreloaded = result
711 hstsLoading = false
712 }
713
714 private func runHTTPHeaders(domain: String, lookupID: UUID) async {
715 let result = await HTTPHeadersService.fetch(domain: domain)
716 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
717 switch result {
718 case let .success(headersResult):
719 httpHeaders = headersResult.headers
720 httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
721 httpStatusCode = headersResult.statusCode
722 httpResponseTimeMs = headersResult.responseTimeMs
723 httpProtocol = headersResult.httpProtocol
724 http3Advertised = headersResult.http3Advertised
725 httpHeadersError = nil
726 case let .empty(message):
727 httpHeaders = []
728 httpSecurityGrade = nil
729 httpStatusCode = nil
730 httpResponseTimeMs = nil
731 httpProtocol = nil
732 http3Advertised = false
733 httpHeadersError = message
734 case let .error(message):
735 httpHeaders = []
736 httpSecurityGrade = nil
737 httpStatusCode = nil
738 httpResponseTimeMs = nil
739 httpProtocol = nil
740 http3Advertised = false
741 httpHeadersError = message
742 }
743 httpHeadersLoading = false
744 }
745
746 private func runReachability(domain: String, lookupID: UUID) async {
747 let result = await ReachabilityService.checkAll(domain: domain)
748 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
749 switch result {
750 case let .success(results):
751 reachabilityResults = results
752 reachabilityError = nil
753 case let .empty(message):
754 reachabilityResults = []
755 reachabilityError = message
756 case let .error(message):
757 reachabilityResults = []
758 reachabilityError = message
759 }
760 reachabilityLoading = false
761 }
762
763 private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
764 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
765 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
766 switch result {
767 case let .success(emailResult):
768 emailSecurity = emailResult
769 emailSecurityError = nil
770 case let .empty(message):
771 emailSecurity = nil
772 emailSecurityError = message
773 case let .error(message):
774 emailSecurity = nil
775 emailSecurityError = message
776 }
777 emailSecurityLoading = false
778 }
779
780 private func runReverseDNS(ip: String, lookupID: UUID) async {
781 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
782 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
783 switch result {
784 case let .success(record):
785 ptrRecord = record
786 ptrError = nil
787 case let .empty(message):
788 ptrRecord = nil
789 ptrError = message
790 case let .error(message):
791 ptrRecord = nil
792 ptrError = message
793 }
794 ptrLoading = false
795 }
796
797 private func runRedirectChain(domain: String, lookupID: UUID) async {
798 let result = await RedirectChainService.trace(domain: domain)
799 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
800 switch result {
801 case let .success(hops):
802 redirectChain = hops
803 redirectChainError = nil
804 case let .empty(message):
805 redirectChain = []
806 redirectChainError = message
807 case let .error(message):
808 redirectChain = []
809 redirectChainError = message
810 }
811 redirectChainLoading = false
812 }
813
814 private func runPortScan(domain: String, lookupID: UUID) async {
815 let result = await PortScanService.scanAll(domain: domain)
816 switch result {
817 case let .success(results):
818 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
819 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
820 portScanResults = enrichedResults
821 portScanError = nil
822 case let .empty(message):
823 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
824 portScanResults = []
825 portScanError = message
826 case let .error(message):
827 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
828 portScanResults = []
829 portScanError = message
830 }
831 portScanLoading = false
832 }
833
834 private func runIPGeolocation(ip: String, lookupID: UUID) async {
835 let result = await IPGeolocationService.lookup(ip: ip)
836 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
837 switch result {
838 case let .success(geolocation):
839 ipGeolocation = geolocation
840 ipGeolocationError = nil
841 case let .empty(message):
842 ipGeolocation = nil
843 ipGeolocationError = message
844 case let .error(message):
845 ipGeolocation = nil
846 ipGeolocationError = message
847 }
848 ipGeolocationLoading = false
849 }
850
851 private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
852 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
853 ptrLoading = false
854 ptrError = "No A record available"
855 ipGeolocationLoading = false
856 ipGeolocationError = "No A record available"
857 }
858
859 private func runSuggestions(domain: String, lookupID: UUID) async {
860 let results = await DomainAvailabilityService.suggestions(for: domain)
861 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
862 suggestions = results
863 suggestionsLoading = false
864 }
865
866 private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
867 switch result {
868 case let .success(results):
869 customPortResults = results
870 customPortScanError = nil
871 saveHistoryEntry(replaceLatest: true)
872 case let .empty(message):
873 customPortResults = []
874 customPortScanError = message
875 case let .error(message):
876 customPortResults = []
877 customPortScanError = message
878 }
879 customPortScanLoading = false
880 }
881
882 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
883 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
884 for result in results where result.open {
885 group.addTask {
886 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
887 return (result.port, banner)
888 }
889 }
890
891 var collected: [UInt16: String] = [:]
892 for await (port, banner) in group {
893 if let banner {
894 collected[port] = banner
895 }
896 }
897 return collected
898 }
899
900 return results.map { result in
901 var updated = result
902 updated.banner = banners[result.port]
903 return updated
904 }
905 }
906
907 private func saveHistoryEntry(replaceLatest: Bool) {
908 guard !searchedDomain.isEmpty else { return }
909
910 let trackedDomainID = trackedDomain(for: searchedDomain)?.id
911 let timestamp = Date()
912 let snapshot = currentSnapshot
913 let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
914 let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
915
916 currentChangeSummary = changeSummary
917 currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
918
919 let entry = HistoryEntry(
920 domain: searchedDomain,
921 timestamp: timestamp,
922 trackedDomainID: trackedDomainID,
923 dnsSections: dnsSections,
924 sslInfo: sslInfo,
925 httpHeaders: httpHeaders,
926 reachabilityResults: reachabilityResults,
927 ipGeolocation: ipGeolocation,
928 emailSecurity: emailSecurity,
929 mtaSts: emailSecurity?.mtaSts,
930 ptrRecord: ptrRecord,
931 redirectChain: redirectChain,
932 portScanResults: allPortScanResults,
933 hstsPreloaded: hstsPreloaded,
934 availabilityResult: availabilityResult,
935 suggestions: suggestions,
936 resolverDisplayName: resolverDisplayName,
937 resolverURLString: resolverURLString,
938 totalLookupDurationMs: lastLookupDurationMs,
939 primaryIP: Self.primaryIPAddress(from: snapshot),
940 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
941 tlsStatusSummary: Self.httpsSummary(from: snapshot),
942 emailSecuritySummary: Self.emailSummary(from: snapshot),
943 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
944 changeSummary: changeSummary,
945 sslError: sslError,
946 httpHeadersError: httpHeadersError,
947 reachabilityError: reachabilityError,
948 ipGeolocationError: ipGeolocationError,
949 emailSecurityError: emailSecurityError,
950 ptrError: ptrError,
951 redirectChainError: redirectChainError,
952 portScanError: combinedPortScanError
953 )
954
955 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
956 history[0] = entry
957 } else {
958 history.insert(entry, at: 0)
959 if history.count > Self.maxHistory {
960 history = Array(history.prefix(Self.maxHistory))
961 }
962 }
963
964 updateTrackedDomainSnapshotMetadata(
965 domain: searchedDomain,
966 snapshotID: entry.id,
967 availabilityStatus: availabilityResult?.status,
968 updatedAt: timestamp,
969 changeSummary: changeSummary
970 )
971 persistHistory()
972 }
973
974 private func persistHistory() {
975 if let data = try? JSONEncoder().encode(history) {
976 UserDefaults.standard.set(data, forKey: Self.historyKey)
977 }
978 }
979
980 private func persistTrackedDomains() {
981 if let data = try? JSONEncoder().encode(trackedDomains) {
982 UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
983 }
984 }
985
986 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
987 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
988 return
989 }
990 trackedDomains[index].lastKnownAvailability = status
991 persistTrackedDomains()
992 }
993
994 private func updateTrackedDomainSnapshotMetadata(
995 domain: String,
996 snapshotID: UUID,
997 availabilityStatus: DomainAvailabilityStatus?,
998 updatedAt: Date,
999 changeSummary: DomainChangeSummary?
1000 ) {
1001 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
1002 return
1003 }
1004 trackedDomains[index].lastSnapshotID = snapshotID
1005 trackedDomains[index].lastKnownAvailability = availabilityStatus
1006 trackedDomains[index].updatedAt = updatedAt
1007 trackedDomains[index].lastChangeSummary = changeSummary
1008 persistTrackedDomains()
1009 }
1010
1011 private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
1012 let matchingEntries = history.filter { entry in
1013 if let trackedDomainID {
1014 return entry.trackedDomainID == trackedDomainID
1015 }
1016 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
1017 }
1018
1019 if replacingLatest {
1020 return matchingEntries.dropFirst().first?.snapshot
1021 }
1022 return matchingEntries.first?.snapshot
1023 }
1024
1025 private func trackedDomain(for domain: String) -> TrackedDomain? {
1026 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
1027 }
1028
1029 private func normalizedDomain(_ domain: String) -> String {
1030 domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1031 }
1032
1033 private func linkTrackedDomainHistory(for domain: String) {
1034 guard let trackedDomain = trackedDomain(for: domain) else { return }
1035 var didChange = false
1036
1037 for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
1038 if history[index].trackedDomainID != trackedDomain.id {
1039 history[index].trackedDomainID = trackedDomain.id
1040 didChange = true
1041 }
1042 }
1043
1044 if didChange {
1045 persistHistory()
1046 }
1047
1048 if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
1049 let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
1050 trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
1051 trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
1052 trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
1053 trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
1054 persistTrackedDomains()
1055 }
1056 }
1057
1058 private func addRecentSearch(_ domain: String) {
1059 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
1060 recentSearches.insert(domain, at: 0)
1061 if recentSearches.count > Self.maxRecent {
1062 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
1063 }
1064 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
1065 }
1066
1067 private func clearLookupState() {
1068 dnsSections = []
1069 dnsError = nil
1070 dnsLoading = false
1071 availabilityResult = nil
1072 availabilityLoading = false
1073 suggestions = []
1074 suggestionsLoading = false
1075 sslInfo = nil
1076 sslError = nil
1077 sslLoading = false
1078 hstsPreloaded = nil
1079 hstsLoading = false
1080 httpHeaders = []
1081 httpSecurityGrade = nil
1082 httpStatusCode = nil
1083 httpResponseTimeMs = nil
1084 httpProtocol = nil
1085 http3Advertised = false
1086 httpHeadersError = nil
1087 httpHeadersLoading = false
1088 reachabilityResults = []
1089 reachabilityError = nil
1090 reachabilityLoading = false
1091 ipGeolocation = nil
1092 ipGeolocationError = nil
1093 ipGeolocationLoading = false
1094 emailSecurity = nil
1095 emailSecurityError = nil
1096 emailSecurityLoading = false
1097 ptrRecord = nil
1098 ptrError = nil
1099 ptrLoading = false
1100 redirectChain = []
1101 redirectChainError = nil
1102 redirectChainLoading = false
1103 portScanResults = []
1104 portScanError = nil
1105 portScanLoading = false
1106 customPortResults = []
1107 customPortScanError = nil
1108 customPortScanLoading = false
1109 }
1110
1111 private func setAllLoadingStates(_ loading: Bool) {
1112 dnsLoading = loading
1113 availabilityLoading = loading
1114 suggestionsLoading = loading
1115 sslLoading = loading
1116 hstsLoading = loading
1117 httpHeadersLoading = loading
1118 reachabilityLoading = loading
1119 ipGeolocationLoading = loading
1120 emailSecurityLoading = loading
1121 ptrLoading = loading
1122 redirectChainLoading = loading
1123 portScanLoading = loading
1124 }
1125
1126 private func primaryIPAddress(from sections: [DNSSection]) -> String? {
1127 sections.first(where: { $0.recordType == .A })?.records.first?.value
1128 }
1129
1130 private func isCurrentLookup(_ lookupID: UUID) -> Bool {
1131 activeLookupID == lookupID
1132 }
1133
1134 func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
1135 history
1136 .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
1137 .sorted { $0.timestamp > $1.timestamp }
1138 .prefix(limit)
1139 .map { $0 }
1140 }
1141
1142 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
1143 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
1144 guard snapshots.count == 2 else { return [] }
1145 return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
1146 }
1147
1148 func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
1149 trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
1150 }
1151
1152 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
1153 let siblings = history.filter { candidate in
1154 if let trackedDomainID = entry.trackedDomainID {
1155 return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
1156 }
1157 return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
1158 }
1159 .sorted { $0.timestamp > $1.timestamp }
1160
1161 return siblings.first?.snapshot
1162 }
1163
1164 private static func loadTrackedDomains() -> [TrackedDomain] {
1165 let defaults = UserDefaults.standard
1166 let decoder = JSONDecoder()
1167
1168 if let data = defaults.data(forKey: trackedDomainsKey),
1169 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
1170 return deduplicatedTrackedDomains(domains)
1171 }
1172
1173 if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
1174 let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
1175 return deduplicatedTrackedDomains(
1176 legacyDomains.map {
1177 TrackedDomain(
1178 id: $0.id,
1179 domain: $0.domain.lowercased(),
1180 createdAt: $0.createdAt,
1181 updatedAt: $0.createdAt,
1182 lastKnownAvailability: $0.lastKnownAvailability
1183 )
1184 }
1185 )
1186 }
1187
1188 return []
1189 }
1190
1191 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
1192 var seen = Set<String>()
1193 return domains.filter { domain in
1194 let key = domain.domain.lowercased()
1195 return seen.insert(key).inserted
1196 }
1197 }
1198
1199 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
1200 [
1201 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
1202 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
1203 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
1204 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
1205 SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
1206 ]
1207 }
1208
1209 static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1210 var rows = [
1211 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
1212 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
1213 InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
1214 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
1215 ]
1216 rows.insert(
1217 InfoRowViewData(
1218 label: "Availability",
1219 value: availabilityLabel(snapshot.availabilityResult?.status),
1220 tone: availabilityTone(snapshot.availabilityResult?.status)
1221 ),
1222 at: 1
1223 )
1224 return rows
1225 }
1226
1227 static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] {
1228 snapshot.suggestions.map {
1229 DomainSuggestionViewData(
1230 id: $0.id,
1231 domain: $0.domain,
1232 status: availabilityLabel($0.status),
1233 tone: availabilityTone($0.status)
1234 )
1235 }
1236 }
1237
1238 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
1239 snapshot.dnsSections.map { section in
1240 DNSRecordSectionViewData(
1241 title: section.recordType.rawValue,
1242 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
1243 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
1244 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
1245 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
1246 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
1247 )
1248 }
1249 }
1250
1251 static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
1252 guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
1253 return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
1254 }
1255
1256 static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
1257 if let ptrRecord = snapshot.ptrRecord {
1258 return SectionMessageViewData(text: ptrRecord, isError: false)
1259 }
1260 if let ptrError = snapshot.ptrError {
1261 return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
1262 }
1263 return nil
1264 }
1265
1266 static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1267 guard let sslInfo = snapshot.sslInfo else { return [] }
1268 var rows = [
1269 InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
1270 InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
1271 InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
1272 InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
1273 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
1274 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
1275 ]
1276 if let tlsVersion = sslInfo.tlsVersion {
1277 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
1278 }
1279 if let cipherSuite = sslInfo.cipherSuite {
1280 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
1281 }
1282 if let hstsPreloaded = snapshot.hstsPreloaded {
1283 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
1284 }
1285 return rows
1286 }
1287
1288 static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1289 var rows: [InfoRowViewData] = []
1290 if let httpStatusCode = snapshot.httpStatusCode {
1291 rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
1292 }
1293 if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
1294 rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
1295 }
1296 if let httpProtocol = snapshot.httpProtocol {
1297 rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
1298 }
1299 if let httpSecurityGrade = snapshot.httpSecurityGrade {
1300 rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
1301 }
1302 if snapshot.http3Advertised {
1303 rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
1304 }
1305 return rows
1306 }
1307
1308 static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
1309 snapshot.redirectChain.map {
1310 RedirectHopViewData(
1311 stepLabel: "\($0.stepNumber)",
1312 statusCode: "\($0.statusCode)",
1313 url: $0.url,
1314 isFinal: $0.isFinal
1315 )
1316 }
1317 }
1318
1319 static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
1320 guard let emailSecurity = snapshot.emailSecurity else { return [] }
1321 return [
1322 EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
1323 EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
1324 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)" }),
1325 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),
1326 EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
1327 ]
1328 }
1329
1330 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
1331 snapshot.reachabilityResults.map {
1332 ReachabilityRowViewData(
1333 portLabel: "Port \($0.port)",
1334 latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
1335 statusLabel: $0.reachable ? "Reachable" : "Unreachable",
1336 statusTone: $0.reachable ? .success : .failure
1337 )
1338 }
1339 }
1340
1341 static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
1342 guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
1343 var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
1344 if let org = ipGeolocation.org {
1345 rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
1346 }
1347 let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
1348 if !location.isEmpty {
1349 rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
1350 }
1351 if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
1352 rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
1353 }
1354 return rows
1355 }
1356
1357 static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
1358 snapshot.portScanResults
1359 .filter { $0.kind == kind }
1360 .map {
1361 PortScanRowViewData(
1362 portLabel: "\($0.port)",
1363 service: $0.service,
1364 statusLabel: $0.open ? "Open" : "Closed",
1365 statusTone: $0.open ? .success : .secondary,
1366 banner: $0.banner,
1367 durationLabel: $0.durationMs.map { "\($0) ms" }
1368 )
1369 }
1370 }
1371
1372 static func formatExportText(
1373 from snapshot: LookupSnapshot,
1374 trackedDomain: TrackedDomain?,
1375 changeSummary: DomainChangeSummary?,
1376 diffSections: [DomainDiffSection]
1377 ) -> String {
1378 let exportDateFormatter = DateFormatter()
1379 exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
1380
1381 var lines: [String] = [
1382 "DomainDig Export",
1383 "Domain: \(snapshot.domain)",
1384 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
1385 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
1386 "Resolver: \(snapshot.resolverDisplayName)",
1387 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
1388 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
1389 ]
1390
1391 if let note = trackedDomain?.note?.nilIfEmpty {
1392 lines.append("Tracking Note: \(note)")
1393 }
1394
1395 func appendSection(_ title: String, body: () -> Void) {
1396 lines.append("")
1397 lines.append(title)
1398 lines.append(String(repeating: "-", count: title.count))
1399 body()
1400 }
1401
1402 appendSection("Summary") {
1403 for item in summaryFields(from: snapshot) {
1404 lines.append(" \(item.label): \(item.value)")
1405 }
1406 if let changeSummary {
1407 lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
1408 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
1409 }
1410 }
1411
1412 appendSection("Tracking") {
1413 if let trackedDomain {
1414 lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
1415 lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
1416 lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
1417 if let note = trackedDomain.note?.nilIfEmpty {
1418 lines.append(" Note: \(note)")
1419 }
1420 } else {
1421 lines.append(" This domain is not currently tracked.")
1422 }
1423 }
1424
1425 appendSection("Diff Summary") {
1426 if diffSections.isEmpty {
1427 lines.append(" No comparison available")
1428 } else {
1429 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
1430 lines.append(" \(section.title)")
1431 for item in section.items where item.changeType != .unchanged {
1432 lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
1433 }
1434 }
1435 }
1436 }
1437
1438 appendSection("Domain") {
1439 for row in domainRows(from: snapshot) {
1440 lines.append(" \(row.label): \(row.value)")
1441 }
1442 if snapshot.suggestions.isEmpty {
1443 lines.append(" Suggestions: None")
1444 } else {
1445 lines.append(" Suggestions:")
1446 for suggestion in snapshot.suggestions {
1447 lines.append(" \(suggestion.domain): \(availabilityLabel(suggestion.status))")
1448 }
1449 }
1450 }
1451
1452 appendSection("DNS") {
1453 if let dnsError = snapshot.dnsError {
1454 lines.append(" Error: \(dnsError)")
1455 }
1456 if let dnssecLabel = dnssecLabel(from: snapshot) {
1457 lines.append(" \(dnssecLabel)")
1458 }
1459 for section in dnsRows(from: snapshot) {
1460 lines.append(" \(section.title)")
1461 if let message = section.message {
1462 lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
1463 }
1464 for row in section.rows {
1465 lines.append(" \(row.value) (\(row.label))")
1466 }
1467 if let wildcardTitle = section.wildcardTitle {
1468 lines.append(" \(wildcardTitle)")
1469 for row in section.wildcardRows {
1470 lines.append(" \(row.value) (\(row.label))")
1471 }
1472 }
1473 }
1474 if let ptrRecord = snapshot.ptrRecord {
1475 lines.append(" PTR: \(ptrRecord)")
1476 } else if let ptrError = snapshot.ptrError {
1477 lines.append(" PTR Error: \(ptrError)")
1478 }
1479 }
1480
1481 appendSection("Web") {
1482 if let sslError = snapshot.sslError {
1483 lines.append(" TLS Error: \(sslError)")
1484 } else {
1485 for row in webCertificateRows(from: snapshot) {
1486 lines.append(" \(row.label): \(row.value)")
1487 }
1488 }
1489
1490 if let httpHeadersError = snapshot.httpHeadersError {
1491 lines.append(" Headers Error: \(httpHeadersError)")
1492 } else {
1493 for row in webResponseRows(from: snapshot) {
1494 lines.append(" \(row.label): \(row.value)")
1495 }
1496 if snapshot.httpHeaders.isEmpty {
1497 lines.append(" Headers: No headers returned")
1498 } else {
1499 lines.append(" Headers:")
1500 for header in snapshot.httpHeaders {
1501 lines.append(" \(header.name): \(header.value)")
1502 }
1503 }
1504 }
1505
1506 if let redirectChainError = snapshot.redirectChainError {
1507 lines.append(" Redirect Error: \(redirectChainError)")
1508 } else if snapshot.redirectChain.isEmpty {
1509 lines.append(" Redirects: No redirect data available")
1510 } else {
1511 lines.append(" Redirects:")
1512 for hop in redirectRows(from: snapshot) {
1513 lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
1514 }
1515 }
1516 }
1517
1518 appendSection("Email") {
1519 if let emailSecurityError = snapshot.emailSecurityError {
1520 lines.append(" Error: \(emailSecurityError)")
1521 } else if emailRows(from: snapshot).isEmpty {
1522 lines.append(" No email security records found")
1523 } else {
1524 for row in emailRows(from: snapshot) {
1525 lines.append(" \(row.label): \(row.status)")
1526 lines.append(" \(row.detail)")
1527 if let auxiliaryDetail = row.auxiliaryDetail {
1528 lines.append(" \(auxiliaryDetail)")
1529 }
1530 }
1531 }
1532 }
1533
1534 appendSection("Network") {
1535 if let reachabilityError = snapshot.reachabilityError {
1536 lines.append(" Reachability Error: \(reachabilityError)")
1537 } else if reachabilityRows(from: snapshot).isEmpty {
1538 lines.append(" Reachability: No results")
1539 } else {
1540 lines.append(" Reachability:")
1541 for row in reachabilityRows(from: snapshot) {
1542 lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
1543 }
1544 }
1545
1546 if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
1547 lines.append(" Location Error: \(ipGeolocationError)")
1548 } else if locationRows(from: snapshot).isEmpty {
1549 lines.append(" Location: No data")
1550 } else {
1551 lines.append(" Location:")
1552 for row in locationRows(from: snapshot) {
1553 lines.append(" \(row.label): \(row.value)")
1554 }
1555 }
1556
1557 if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
1558 lines.append(" Port Scan Error: \(portScanError)")
1559 }
1560
1561 lines.append(" Standard Ports:")
1562 let standardRows = portRows(from: snapshot, kind: .standard)
1563 if standardRows.isEmpty {
1564 lines.append(" No results")
1565 } else {
1566 for row in standardRows {
1567 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1568 if let banner = row.banner {
1569 lines.append(" Banner: \(banner)")
1570 }
1571 }
1572 }
1573
1574 lines.append(" Custom Ports:")
1575 let customRows = portRows(from: snapshot, kind: .custom)
1576 if customRows.isEmpty {
1577 lines.append(" No results")
1578 } else {
1579 for row in customRows {
1580 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1581 if let banner = row.banner {
1582 lines.append(" Banner: \(banner)")
1583 }
1584 }
1585 }
1586 }
1587
1588 return lines.joined(separator: "\n")
1589 }
1590
1591 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
1592 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1593 }
1594
1595 private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
1596 snapshot.redirectChain.last?.url
1597 }
1598
1599 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
1600 if snapshot.sslInfo != nil {
1601 return "Valid"
1602 }
1603 if let sslError = snapshot.sslError {
1604 return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
1605 }
1606 return "Unavailable"
1607 }
1608
1609 private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
1610 if snapshot.sslInfo != nil {
1611 return .success
1612 }
1613 return snapshot.sslError == nil ? .secondary : .failure
1614 }
1615
1616 private static func emailSummary(from snapshot: LookupSnapshot) -> String {
1617 guard let emailSecurity = snapshot.emailSecurity else {
1618 return snapshot.emailSecurityError ?? "Unavailable"
1619 }
1620 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
1621 }
1622
1623 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
1624 switch status {
1625 case .available:
1626 return "Available"
1627 case .registered:
1628 return "Registered"
1629 case .unknown, .none:
1630 return "Unknown"
1631 }
1632 }
1633
1634 private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
1635 switch status {
1636 case .available:
1637 return .success
1638 case .registered:
1639 return .warning
1640 case .unknown, .none:
1641 return .secondary
1642 }
1643 }
1644
1645 private static func securityGradeTone(_ grade: String) -> ResultTone {
1646 switch grade {
1647 case "A", "B":
1648 return .success
1649 case "C":
1650 return .warning
1651 case "D", "F":
1652 return .failure
1653 default:
1654 return .secondary
1655 }
1656 }
1657
1658 private static func durationLabel(_ durationMs: Int?) -> String {
1659 durationMs.map { "\($0) ms" } ?? "Unavailable"
1660 }
1661
1662 private static let certificateDateFormatter: DateFormatter = {
1663 let formatter = DateFormatter()
1664 formatter.dateStyle = .medium
1665 formatter.timeStyle = .short
1666 return formatter
1667 }()
1668}
1669
1670private extension String {
1671 var nonEmpty: String? {
1672 isEmpty ? nil : self
1673 }
1674
1675 var nilIfEmpty: String? {
1676 isEmpty ? nil : self
1677 }
1678}