Commit ee2520f2c0

ee2520f2c0c2d806fd284c3d6b8a81a3822f5270

parent: 4e94ee723c

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-20 20:28 UTC

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

Layout: unified · split

DomainDig/ContentView.swift +520 −664
@@ -1,5 +1,5 @@
1import SwiftUI
21import MapKit
2import SwiftUI
33
44struct ContentView: View {
55 @State private var viewModel = DomainViewModel()
@@ -14,14 +14,59 @@ struct ContentView: View {
1414 inputSection
1515 if viewModel.hasRun {
1616 actionButtons
17 reachabilitySection
18 redirectChainSection
19 dnsResultsSection
20 emailSecuritySection
21 sslResultsSection
22 httpHeadersSection
23 ipGeolocationSection
24 portScanSection
17 SummaryView(fields: viewModel.summaryFields)
18 .padding(.top, 8)
19 DomainSectionView(rows: viewModel.domainRows)
20 .padding(.top, 16)
21 DNSSectionView(
22 dnssecLabel: viewModel.dnssecLabel,
23 sections: viewModel.dnsRows,
24 ptrMessage: viewModel.ptrMessage,
25 loading: viewModel.dnsLoading || viewModel.ptrLoading,
26 sectionError: viewModel.dnsError
27 )
28 .padding(.top, 16)
29 WebSectionView(
30 certificateRows: viewModel.webCertificateRows,
31 sslInfo: viewModel.sslInfo,
32 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
33 sslError: viewModel.sslError,
34 responseRows: viewModel.webResponseRows,
35 headers: viewModel.httpHeaders,
36 headersLoading: viewModel.httpHeadersLoading,
37 headersError: viewModel.httpHeadersError,
38 redirects: viewModel.redirectRows,
39 redirectLoading: viewModel.redirectChainLoading,
40 redirectError: viewModel.redirectChainError,
41 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
42 )
43 .padding(.top, 16)
44 EmailSectionView(
45 rows: viewModel.emailRows,
46 loading: viewModel.emailSecurityLoading,
47 error: viewModel.emailSecurityError
48 )
49 .padding(.top, 16)
50 NetworkSectionView(
51 reachabilityRows: viewModel.reachabilityRows,
52 reachabilityLoading: viewModel.reachabilityLoading,
53 reachabilityError: viewModel.reachabilityError,
54 locationRows: viewModel.locationRows,
55 geolocation: viewModel.ipGeolocation,
56 geolocationLoading: viewModel.ipGeolocationLoading,
57 geolocationError: viewModel.ipGeolocationError,
58 standardPortRows: viewModel.standardPortRows,
59 customPortRows: viewModel.customPortRows,
60 portScanLoading: viewModel.portScanLoading,
61 portScanError: viewModel.portScanError,
62 customPortScanLoading: viewModel.customPortScanLoading,
63 customPortScanError: viewModel.customPortScanError,
64 isCloudflareProxied: viewModel.isCloudflareProxied,
65 customPortsExpanded: $customPortsExpanded,
66 customPortInput: $customPortInput,
67 onScanCustomPorts: runCustomPortScan
68 )
69 .padding(.top, 16)
2570 } else if !viewModel.recentSearches.isEmpty {
2671 recentSearchesSection
2772 }
@@ -55,8 +100,6 @@ struct ContentView: View {
55100 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
56101 .foregroundStyle(.secondary)
57102 }
58 }
59 ToolbarItem(placement: .topBarTrailing) {
60103 NavigationLink {
61104 SettingsView()
62105 } label: {
@@ -71,8 +114,6 @@ struct ContentView: View {
71114 }
72115 }
73116
74 // MARK: - Input
75
76117 private var inputSection: some View {
77118 VStack(spacing: 12) {
78119 TextField("e.g. cleberg.net", text: $viewModel.domain)
@@ -101,8 +142,6 @@ struct ContentView: View {
101142 .padding(.vertical, 16)
102143 }
103144
104 // MARK: - Action Buttons (Share + Bookmark)
105
106145 private var actionButtons: some View {
107146 HStack {
108147 Spacer()
@@ -123,11 +162,8 @@ struct ContentView: View {
123162 }
124163 }
125164 }
126 .padding(.top, 8)
127165 }
128166
129 // MARK: - Recent Searches
130
131167 private var recentSearchesSection: some View {
132168 VStack(alignment: .leading, spacing: 8) {
133169 HStack {
@@ -162,778 +198,605 @@ struct ContentView: View {
162198 .padding(.top, 8)
163199 }
164200
165 // MARK: - Reachability
166
167 private var reachabilitySection: some View {
168 VStack(alignment: .leading, spacing: 12) {
169 sectionHeader("Reachability")
170
171 if viewModel.reachabilityLoading {
172 ProgressView("Checking ports…")
173 .frame(maxWidth: .infinity, alignment: .center)
174 .padding()
175 } else if let error = viewModel.reachabilityError {
176 errorLabel(error)
177 } else {
178 VStack(alignment: .leading, spacing: 4) {
179 ForEach(viewModel.reachabilityResults) { result in
180 HStack(spacing: 8) {
181 Circle()
182 .fill(result.reachable ? Color.green : Color.red)
183 .frame(width: 8, height: 8)
184 Text("Port \(result.port)")
185 .font(.system(.caption, design: .monospaced))
186 if result.reachable, let ms = result.latencyMs {
187 Text("\(ms)ms")
188 .font(.system(.caption, design: .monospaced))
189 .foregroundStyle(.secondary)
190 } else if !result.reachable {
191 Text("—")
192 .font(.system(.caption, design: .monospaced))
193 .foregroundStyle(.secondary)
194 }
195 Spacer()
196 Text(result.reachable ? "Reachable" : "Unreachable")
197 .font(.system(.caption, design: .monospaced))
198 .foregroundStyle(result.reachable ? .green : .red)
199 }
200 }
201 }
202 .padding(10)
203 .background(Color(.systemGray6).opacity(0.5))
204 .cornerRadius(6)
205 }
201 private func runCustomPortScan() {
202 let ports = parsedCustomPorts(from: customPortInput)
203 Task {
204 await viewModel.runCustomPortScan(ports: ports)
206205 }
207 .padding(.top, 8)
208206 }
209207
210 // MARK: - Redirect Chain
211
212 private var redirectChainSection: some View {
213 VStack(alignment: .leading, spacing: 12) {
214 sectionHeader("Redirect Chain")
215
216 if viewModel.redirectChainLoading {
217 ProgressView("Tracing redirects…")
218 .frame(maxWidth: .infinity, alignment: .center)
219 .padding()
220 } else if let error = viewModel.redirectChainError {
221 errorLabel(error)
222 } else if viewModel.redirectChain.isEmpty {
223 Text("No redirect data")
224 .font(.system(.caption, design: .monospaced))
225 .foregroundStyle(.secondary)
226 .padding(8)
227 } else if viewModel.redirectChain.count == 1,
228 let only = viewModel.redirectChain.first,
229 only.isFinal, !(300...399).contains(only.statusCode) {
230 Text("No redirects — direct connection")
231 .font(.system(.caption, design: .monospaced))
232 .foregroundStyle(.secondary)
233 .padding(10)
234 .frame(maxWidth: .infinity, alignment: .leading)
235 .background(Color(.systemGray6).opacity(0.5))
236 .cornerRadius(6)
237 } else {
238 horizontallyScrollableCard {
239 ForEach(viewModel.redirectChain) { hop in
240 HStack(alignment: .top, spacing: 6) {
241 Text("\(hop.stepNumber)")
242 .font(.system(.caption, design: .monospaced))
243 .foregroundStyle(.secondary)
244 .frame(width: 16, alignment: .trailing)
245 Text("\(hop.statusCode)")
246 .font(.system(.caption, design: .monospaced))
247 .foregroundStyle(.cyan)
248 .frame(width: 30, alignment: .leading)
249 Text(hop.url)
250 .font(.system(.caption, design: .monospaced))
251 .foregroundStyle(.primary)
252 .textSelection(.enabled)
253 if hop.isFinal {
254 Text("(final)")
255 .font(.system(.caption2, design: .monospaced))
256 .foregroundStyle(.secondary)
257 }
258 }
259 }
260 }
261 }
262 }
263 .padding(.top, 16)
264 }
265
266 // MARK: - DNS Results
208 private func parsedCustomPorts(from input: String) -> [UInt16] {
209 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
210 var seen = Set<UInt16>()
211 var ports: [UInt16] = []
267212
268 private var dnsResultsSection: some View {
269 VStack(alignment: .leading, spacing: 12) {
270 HStack(alignment: .top, spacing: 8) {
271 sectionHeader("DNS Records")
272 Spacer()
273 if let dnssecSigned = dnssecStatus {
274 Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗")
275 .font(.system(.caption2, design: .monospaced))
276 .foregroundStyle(dnssecSigned ? .green : .red)
277 .padding(.top, 1)
278 }
213 for part in parts {
214 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
215 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
216 continue
279217 }
280
281 if viewModel.dnsLoading {
282 ProgressView("Querying DNS…")
283 .frame(maxWidth: .infinity, alignment: .center)
284 .padding()
285 } else if let error = viewModel.dnsError {
286 errorLabel(error)
287 } else {
288 ForEach(viewModel.dnsSections) { section in
289 dnsRecordSection(section)
290 if section.recordType == .A {
291 ptrRow
292 }
293 }
218 ports.append(value)
219 if ports.count == 20 {
220 break
294221 }
295222 }
296 .padding(.top, 16)
297 }
298223
299 private var ptrRow: some View {
300 horizontallyScrollableCard {
301 Text("PTR (Reverse DNS)")
302 .font(.system(.subheadline, design: .monospaced))
303 .fontWeight(.semibold)
304 .foregroundStyle(.cyan)
305
306 if viewModel.ptrLoading {
307 ProgressView()
308 .frame(maxWidth: .infinity, alignment: .center)
309 .padding(4)
310 } else if let ptr = viewModel.ptrRecord {
311 Text(ptr)
312 .font(.system(.caption, design: .monospaced))
313 .foregroundStyle(.primary)
314 .textSelection(.enabled)
315 } else {
316 Text("No PTR record found")
317 .font(.system(.caption, design: .monospaced))
318 .foregroundStyle(.secondary)
319 }
320 }
224 return ports
321225 }
322226
323 private func dnsRecordSection(_ section: DNSSection) -> some View {
324 horizontallyScrollableCard {
325 Text(section.recordType.rawValue)
326 .font(.system(.subheadline, design: .monospaced))
327 .fontWeight(.semibold)
328 .foregroundStyle(.cyan)
329
330 if let error = section.error {
331 errorLabel(error)
332 } else if section.records.isEmpty {
333 Text("No records found")
334 .font(.system(.caption, design: .monospaced))
335 .foregroundStyle(.secondary)
336 } else {
337 dnsRecordRows(section.records)
338 }
339
340 if !section.wildcardRecords.isEmpty {
341 Text("*.\(viewModel.searchedDomain)")
342 .font(.system(.caption, design: .monospaced))
343 .fontWeight(.medium)
344 .foregroundStyle(.cyan.opacity(0.7))
345 .padding(.top, 4)
227 private func shareResults() {
228 let text = viewModel.exportText()
229 let dateFmt = DateFormatter()
230 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
231 let timestamp = dateFmt.string(from: Date())
232 let filename = "\(timestamp)_domaindigresults.txt"
233 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
346234
347 dnsRecordRows(section.wildcardRecords)
348 }
235 do {
236 try text.write(to: tempURL, atomically: true, encoding: .utf8)
237 } catch {
238 return
349239 }
350 }
351240
352 private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
353 ForEach(records) { record in
354 HStack(alignment: .top) {
355 Text(record.value)
356 .font(.system(.caption, design: .monospaced))
357 .foregroundStyle(.primary)
358 .textSelection(.enabled)
359 Spacer()
360 Text("TTL \(record.ttl)")
361 .font(.system(.caption2, design: .monospaced))
362 .foregroundStyle(.secondary)
363 }
241 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
242 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
243 let rootVC = windowScene.keyWindow?.rootViewController else { return }
244 var presenter = rootVC
245 while let presented = presenter.presentedViewController {
246 presenter = presented
364247 }
248 activityVC.popoverPresentationController?.sourceView = presenter.view
249 presenter.present(activityVC, animated: true)
365250 }
251}
366252
367 // MARK: - Email Security
368
369 @State private var expandedEmailField: String?
253struct SummaryView: View {
254 let fields: [SummaryFieldViewData]
370255
371 private var emailSecuritySection: some View {
256 var body: some View {
372257 VStack(alignment: .leading, spacing: 12) {
373 sectionHeader("Email Security")
374
375 if viewModel.emailSecurityLoading {
376 ProgressView("Checking email records…")
377 .frame(maxWidth: .infinity, alignment: .center)
378 .padding()
379 } else if let error = viewModel.emailSecurityError {
380 errorLabel(error)
381 } else if let email = viewModel.emailSecurity {
382 horizontallyScrollableCard(spacing: 6) {
383 emailSecurityRow("SPF", record: email.spf)
384 emailSecurityRow("DMARC", record: email.dmarc)
385 emailSecurityRow("DKIM", record: email.dkim)
386 emailSecurityRow("MTA-STS", mtaSts: email.mtaSts)
387 emailSecurityRow("BIMI", record: email.bimi)
388 }
389 }
390 }
391 .frame(maxWidth: .infinity, alignment: .leading)
392 .padding(.top, 16)
393 }
394
395 private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
396 VStack(alignment: .leading, spacing: 2) {
397 HStack(spacing: 8) {
398 Text(label)
399 .font(.system(.caption, design: .monospaced))
400 .fontWeight(.semibold)
401 .frame(width: 72, alignment: .leading)
402 Text(record.found ? "✓" : "✗")
403 .font(.system(.caption, design: .monospaced))
404 .foregroundStyle(record.found ? .green : .red)
405 if let value = record.value {
406 let isExpanded = expandedEmailField == label
407 let displayValue = isExpanded ? value : String(value.prefix(80))
408 Text(displayValue)
409 .font(.system(.caption2, design: .monospaced))
410 .foregroundStyle(.primary)
411 .textSelection(.enabled)
412 .lineLimit(isExpanded ? nil : 1)
413 .onTapGesture {
414 withAnimation {
415 expandedEmailField = isExpanded ? nil : label
416 }
417 }
418 if let selector = record.matchedSelector {
419 Text("(selector: \(selector))")
258 SectionTitleView(title: "Summary")
259 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
260 ForEach(fields) { field in
261 VStack(alignment: .leading, spacing: 4) {
262 Text(field.label)
420263 .font(.system(.caption2, design: .monospaced))
421264 .foregroundStyle(.secondary)
265 Text(field.value)
266 .font(.system(.caption, design: .monospaced))
267 .foregroundStyle(ResultColors.color(for: field.tone))
268 .lineLimit(2)
269 .textSelection(.enabled)
422270 }
423 } else {
424 Text("No record found")
425 .font(.system(.caption2, design: .monospaced))
426 .foregroundStyle(.secondary)
427 }
428 }
429 }
430 }
431
432 private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
433 VStack(alignment: .leading, spacing: 2) {
434 HStack(spacing: 8) {
435 Text(label)
436 .font(.system(.caption, design: .monospaced))
437 .fontWeight(.semibold)
438 .frame(width: 72, alignment: .leading)
439 Text(mtaSts?.txtFound == true ? "✓" : "✗")
440 .font(.system(.caption, design: .monospaced))
441 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
442 if let policyMode = mtaSts?.policyMode {
443 Text(policyMode)
444 .font(.system(.caption2, design: .monospaced))
445 .foregroundStyle(.primary)
446 .textSelection(.enabled)
447 } else {
448 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
449 .font(.system(.caption2, design: .monospaced))
450 .foregroundStyle(.secondary)
271 .frame(maxWidth: .infinity, alignment: .leading)
272 .padding(10)
273 .background(Color(.systemGray6).opacity(0.5))
274 .cornerRadius(6)
451275 }
452276 }
453277 }
454278 }
279}
455280
456 // MARK: - SSL Results
281struct DomainSectionView: View {
282 let rows: [InfoRowViewData]
457283
458 private var sslResultsSection: some View {
284 var body: some View {
459285 VStack(alignment: .leading, spacing: 12) {
460 sectionHeader("SSL / TLS Certificate")
461
462 if viewModel.sslLoading {
463 ProgressView("Checking certificate…")
464 .frame(maxWidth: .infinity, alignment: .center)
465 .padding()
466 } else if let error = viewModel.sslError {
467 errorLabel(error)
468 } else if let info = viewModel.sslInfo {
469 sslDetail(info, domain: viewModel.searchedDomain)
286 SectionTitleView(title: "Domain")
287 CardView {
288 ForEach(rows) { row in
289 LabeledValueRow(row: row)
290 }
470291 }
471292 }
472 .padding(.top, 16)
473293 }
294}
474295
475 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
476 horizontallyScrollableCard(spacing: 8) {
477 certRow("Common Name", info.commonName)
478 certRow("Issuer", info.issuer)
296struct DNSSectionView: View {
297 let dnssecLabel: String?
298 let sections: [DNSRecordSectionViewData]
299 let ptrMessage: SectionMessageViewData?
300 let loading: Bool
301 let sectionError: String?
479302
480 VStack(alignment: .leading, spacing: 2) {
481 Text("SANs")
482 .font(.system(.caption2, design: .monospaced))
483 .foregroundStyle(.secondary)
484 ForEach(info.subjectAltNames, id: \.self) { san in
485 Text(san)
486 .font(.system(.caption, design: .monospaced))
487 .textSelection(.enabled)
303 var body: some View {
304 VStack(alignment: .leading, spacing: 12) {
305 HStack(alignment: .top, spacing: 8) {
306 SectionTitleView(title: "DNS")
307 Spacer()
308 if let dnssecLabel {
309 Text(dnssecLabel)
310 .font(.system(.caption2, design: .monospaced))
311 .foregroundStyle(.secondary)
312 .multilineTextAlignment(.trailing)
488313 }
489314 }
490315
491 let formatter = DateFormatter.certDate
492 certRow("Valid From", formatter.string(from: info.validFrom))
493 certRow("Valid Until", formatter.string(from: info.validUntil))
316 if loading {
317 LoadingCardView(text: "Querying DNS…")
318 } else if let sectionError, sections.isEmpty {
319 MessageCardView(text: sectionError, isError: true)
320 } else {
321 ForEach(sections) { section in
322 CardView {
323 Text(section.title)
324 .font(.system(.subheadline, design: .monospaced))
325 .fontWeight(.semibold)
326 .foregroundStyle(.cyan)
327
328 if let message = section.message {
329 MessageRowView(text: message.text, isError: message.isError)
330 }
494331
495 VStack(alignment: .leading, spacing: 2) {
496 Text("Days Until Expiry")
497 .font(.system(.caption2, design: .monospaced))
498 .foregroundStyle(.secondary)
499 Text("\(info.daysUntilExpiry)")
500 .font(.system(.caption, design: .monospaced))
501 .fontWeight(.bold)
502 .foregroundStyle(expiryColor(info.daysUntilExpiry))
503 }
332 ForEach(section.rows) { row in
333 LabeledValueRow(row: row)
334 }
504335
505 certRow("Chain Depth", "\(info.chainDepth)")
506 if viewModel.hstsLoading {
507 hstsLoadingRow
508 } else if let hstsPreloaded = viewModel.hstsPreloaded {
509 hstsStatusRow(hstsPreloaded)
510 }
511 if let tlsVersion = info.tlsVersion {
512 certRow("TLS Version", tlsVersion)
513 }
514 if let cipherSuite = info.cipherSuite {
515 certRow("Cipher Suite", cipherSuite)
516 }
517 if !info.chain.isEmpty {
518 VStack(alignment: .leading, spacing: 6) {
519 Text("Certificate Chain")
520 .font(.system(.caption2, design: .monospaced))
521 .foregroundStyle(.secondary)
522 ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
523 DisclosureGroup {
524 Text(certificate.issuer)
336 if let wildcardTitle = section.wildcardTitle {
337 Text(wildcardTitle)
525338 .font(.system(.caption, design: .monospaced))
526339 .foregroundStyle(.secondary)
527 .textSelection(.enabled)
528 } label: {
529 Text(certificate.subject)
530 .font(.system(.caption, design: .monospaced))
531 .foregroundStyle(.primary)
532 .textSelection(.enabled)
340 .padding(.top, 4)
341 ForEach(section.wildcardRows) { row in
342 LabeledValueRow(row: row)
343 }
533344 }
534 .tint(index == 0 ? .cyan : .secondary)
345 }
346 }
347
348 if let ptrMessage {
349 CardView {
350 Text("PTR")
351 .font(.system(.subheadline, design: .monospaced))
352 .fontWeight(.semibold)
353 .foregroundStyle(.cyan)
354 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
535355 }
536356 }
537357 }
538 Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
539 .font(.system(.caption, design: .monospaced))
540 .foregroundStyle(.cyan)
541358 }
542359 }
360}
543361
544 // MARK: - HTTP Headers
362struct WebSectionView: View {
363 let certificateRows: [InfoRowViewData]
364 let sslInfo: SSLCertificateInfo?
365 let sslLoading: Bool
366 let sslError: String?
367 let responseRows: [InfoRowViewData]
368 let headers: [HTTPHeader]
369 let headersLoading: Bool
370 let headersError: String?
371 let redirects: [RedirectHopViewData]
372 let redirectLoading: Bool
373 let redirectError: String?
374 let finalURL: String?
545375
546 private var httpHeadersSection: some View {
376 var body: some View {
547377 VStack(alignment: .leading, spacing: 12) {
548 HStack(spacing: 8) {
549 sectionHeader("HTTP Headers")
550 if let grade = viewModel.httpSecurityGrade {
551 Text(grade)
552 .font(.system(.caption, design: .monospaced))
553 .foregroundStyle(httpSecurityGradeColor(for: grade))
554 .padding(.horizontal, 8)
555 .padding(.vertical, 2)
556 .background(httpSecurityGradeColor(for: grade).opacity(0.18))
557 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
378 SectionTitleView(title: "Web")
379
380 CardView {
381 Text("TLS")
382 .font(.system(.subheadline, design: .monospaced))
383 .fontWeight(.semibold)
384 .foregroundStyle(.cyan)
385 if sslLoading {
386 ProgressView("Checking certificate…")
387 } else if let sslError {
388 MessageRowView(text: sslError, isError: true)
389 } else {
390 ForEach(certificateRows) { row in
391 LabeledValueRow(row: row)
392 }
393 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
394 Text("SANs")
395 .font(.system(.caption2, design: .monospaced))
396 .foregroundStyle(.secondary)
397 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
398 Text(san)
399 .font(.system(.caption, design: .monospaced))
400 .textSelection(.enabled)
401 }
402 }
558403 }
559 Spacer()
560404 }
561405
562 if viewModel.httpHeadersLoading {
563 ProgressView("Fetching headers…")
564 .frame(maxWidth: .infinity, alignment: .center)
565 .padding()
566 } else if let error = viewModel.httpHeadersError {
567 errorLabel(error)
568 } else {
569 horizontallyScrollableCard {
570 if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil {
571 HStack(alignment: .top, spacing: 0) {
572 ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in
573 if index > 0 {
574 Text(" ")
575 .font(.system(.caption, design: .monospaced))
576 }
577 Text(part.text)
578 .font(.system(.caption, design: .monospaced))
579 .foregroundStyle(part.color)
580 }
581 if let http3AvailabilityNote {
582 Text(" ")
406 CardView {
407 Text("Headers")
408 .font(.system(.subheadline, design: .monospaced))
409 .fontWeight(.semibold)
410 .foregroundStyle(.cyan)
411 if headersLoading {
412 ProgressView("Fetching headers…")
413 } else if let headersError {
414 MessageRowView(text: headersError, isError: true)
415 } else {
416 ForEach(responseRows) { row in
417 LabeledValueRow(row: row)
418 }
419 if headers.isEmpty {
420 MessageRowView(text: "No HTTP headers returned", isError: false)
421 } else {
422 ForEach(headers) { header in
423 HStack(alignment: .top, spacing: 4) {
424 Text(header.name + ":")
583425 .font(.system(.caption, design: .monospaced))
584 Text(http3AvailabilityNote)
426 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
427 Text(header.value)
585428 .font(.system(.caption, design: .monospaced))
586 .foregroundStyle(.secondary)
429 .foregroundStyle(.primary)
430 .textSelection(.enabled)
587431 }
588432 }
589433 }
590 ForEach(viewModel.httpHeaders) { header in
591 HStack(alignment: .top, spacing: 4) {
592 Text(header.name + ":")
434 }
435 }
436
437 CardView {
438 Text("Redirects")
439 .font(.system(.subheadline, design: .monospaced))
440 .fontWeight(.semibold)
441 .foregroundStyle(.cyan)
442 if redirectLoading {
443 ProgressView("Tracing redirects…")
444 } else if let redirectError {
445 MessageRowView(text: redirectError, isError: true)
446 } else if redirects.isEmpty {
447 MessageRowView(text: "No redirect data available", isError: false)
448 } else {
449 if let finalURL {
450 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
451 }
452 ForEach(redirects) { redirect in
453 HStack(alignment: .top, spacing: 6) {
454 Text(redirect.stepLabel)
593455 .font(.system(.caption, design: .monospaced))
594 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
595 Text(header.value)
456 .foregroundStyle(.secondary)
457 .frame(width: 16, alignment: .trailing)
458 Text(redirect.statusCode)
459 .font(.system(.caption, design: .monospaced))
460 .foregroundStyle(.cyan)
461 .frame(width: 36, alignment: .leading)
462 Text(redirect.url)
596463 .font(.system(.caption, design: .monospaced))
597 .foregroundStyle(.primary)
598464 .textSelection(.enabled)
465 if redirect.isFinal {
466 Text("(final)")
467 .font(.system(.caption2, design: .monospaced))
468 .foregroundStyle(.secondary)
469 }
599470 }
600471 }
601472 }
602473 }
603474 }
604 .padding(.top, 16)
605475 }
476}
606477
607 // MARK: - IP Geolocation
478struct EmailSectionView: View {
479 let rows: [EmailRowViewData]
480 let loading: Bool
481 let error: String?
608482
609 private var ipGeolocationSection: some View {
483 var body: some View {
610484 VStack(alignment: .leading, spacing: 12) {
611 sectionHeader("IP Location")
612
613 if viewModel.ipGeolocationLoading {
614 ProgressView("Looking up location…")
615 .frame(maxWidth: .infinity, alignment: .center)
616 .padding()
617 } else if let geo = viewModel.ipGeolocation {
618 ipGeolocationDetail(geo)
619 } else if let error = viewModel.ipGeolocationError {
620 if error == "No A record available" {
621 Text("No location data available")
622 .font(.system(.caption, design: .monospaced))
623 .foregroundStyle(.secondary)
624 .padding(8)
485 SectionTitleView(title: "Email")
486 CardView {
487 if loading {
488 ProgressView("Checking email records…")
489 } else if let error {
490 MessageRowView(text: error, isError: true)
491 } else if rows.isEmpty {
492 MessageRowView(text: "No email security records found", isError: false)
625493 } else {
626 errorLabel(error)
627 }
628 }
629 }
630 .padding(.top, 16)
631 }
632
633 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
634 VStack(alignment: .leading, spacing: 6) {
635 horizontallyScrollableContent(spacing: 6) {
636 certRow("IP", geo.ip)
637 if let org = geo.org {
638 certRow("Org / ISP", org)
639 }
640 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
641 if !location.isEmpty {
642 certRow("Location", location)
643 }
644 }
645
646 if let lat = geo.latitude, let lon = geo.longitude {
647 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
648 Map(initialPosition: .region(MKCoordinateRegion(
649 center: coordinate,
650 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
651 ))) {
652 Marker(geo.ip, coordinate: coordinate)
494 ForEach(rows) { row in
495 VStack(alignment: .leading, spacing: 4) {
496 HStack(spacing: 8) {
497 Text(row.label)
498 .font(.system(.caption, design: .monospaced))
499 .foregroundStyle(.cyan)
500 .frame(width: 76, alignment: .leading)
501 Text(row.status)
502 .font(.system(.caption, design: .monospaced))
503 .foregroundStyle(ResultColors.color(for: row.statusTone))
504 }
505 Text(row.detail)
506 .font(.system(.caption2, design: .monospaced))
507 .foregroundStyle(.primary)
508 .textSelection(.enabled)
509 if let auxiliaryDetail = row.auxiliaryDetail {
510 Text(auxiliaryDetail)
511 .font(.system(.caption2, design: .monospaced))
512 .foregroundStyle(.secondary)
513 }
514 }
515 }
653516 }
654 .mapStyle(.standard)
655 .frame(maxWidth: .infinity)
656 .frame(height: 180)
657 .cornerRadius(8)
658517 }
659518 }
660 .padding(10)
661 .background(Color(.systemGray6).opacity(0.5))
662 .cornerRadius(6)
663519 }
520}
664521
665 // MARK: - Port Scan
522struct NetworkSectionView: View {
523 let reachabilityRows: [ReachabilityRowViewData]
524 let reachabilityLoading: Bool
525 let reachabilityError: String?
526 let locationRows: [InfoRowViewData]
527 let geolocation: IPGeolocation?
528 let geolocationLoading: Bool
529 let geolocationError: String?
530 let standardPortRows: [PortScanRowViewData]
531 let customPortRows: [PortScanRowViewData]
532 let portScanLoading: Bool
533 let portScanError: String?
534 let customPortScanLoading: Bool
535 let customPortScanError: String?
536 let isCloudflareProxied: Bool
537 @Binding var customPortsExpanded: Bool
538 @Binding var customPortInput: String
539 let onScanCustomPorts: () -> Void
666540
667 private var portScanSection: some View {
541 var body: some View {
668542 VStack(alignment: .leading, spacing: 12) {
669 sectionHeader("Open Ports")
670
671 if viewModel.portScanLoading {
672 ProgressView("Scanning ports…")
673 .frame(maxWidth: .infinity, alignment: .center)
674 .padding()
675 } else if let error = viewModel.portScanError {
676 errorLabel(error)
677 } else {
678 VStack(alignment: .leading, spacing: 12) {
679 if viewModel.isCloudflareProxied {
680 Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.")
681 .font(.system(.caption2, design: .monospaced))
682 .foregroundStyle(.orange)
683 .padding(8)
684 .background(Color.orange.opacity(0.1))
685 .cornerRadius(6)
686 }
687 portScanResultsCard(viewModel.portScanResults)
543 SectionTitleView(title: "Network")
688544
689 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
690 VStack(alignment: .leading, spacing: 10) {
691 TextField("8888, 9000, 27017", text: $customPortInput)
545 CardView {
546 Text("Reachability")
547 .font(.system(.subheadline, design: .monospaced))
548 .fontWeight(.semibold)
549 .foregroundStyle(.cyan)
550 if reachabilityLoading {
551 ProgressView("Checking ports…")
552 } else if let reachabilityError {
553 MessageRowView(text: reachabilityError, isError: true)
554 } else {
555 ForEach(reachabilityRows) { row in
556 HStack {
557 Text(row.portLabel)
692558 .font(.system(.caption, design: .monospaced))
693 .textInputAutocapitalization(.never)
694 .autocorrectionDisabled()
695 .keyboardType(.numberPad)
696 .padding(10)
697 .background(Color(.systemGray6).opacity(0.5))
698 .cornerRadius(6)
699
700 Button("Scan") {
701 let ports = parsedCustomPorts(from: customPortInput)
702 Task {
703 await viewModel.runCustomPortScan(ports: ports)
704 }
705 }
706 .buttonStyle(.borderedProminent)
707 .tint(.blue)
708 .disabled(viewModel.customPortScanLoading)
709
710 if viewModel.customPortScanLoading {
711 ProgressView("Scanning custom ports…")
712 .font(.system(.caption, design: .monospaced))
713 } else if let error = viewModel.customPortScanError {
714 errorLabel(error)
715 } else if !viewModel.customPortResults.isEmpty {
716 portScanResultsCard(viewModel.customPortResults)
717 }
559 Spacer()
560 Text(row.latencyLabel)
561 .font(.system(.caption2, design: .monospaced))
562 .foregroundStyle(.secondary)
563 Text(row.statusLabel)
564 .font(.system(.caption, design: .monospaced))
565 .foregroundStyle(ResultColors.color(for: row.statusTone))
718566 }
719 .padding(.top, 8)
720567 }
721 .font(.system(.caption, design: .monospaced))
722 .tint(.secondary)
723568 }
724 .padding(10)
725 .background(Color(.systemGray6).opacity(0.5))
726 .cornerRadius(6)
727569 }
728 }
729 .padding(.top, 16)
730 }
731570
732 // MARK: - Helpers
571 CardView {
572 Text("Location")
573 .font(.system(.subheadline, design: .monospaced))
574 .fontWeight(.semibold)
575 .foregroundStyle(.cyan)
576 if geolocationLoading {
577 ProgressView("Looking up location…")
578 } else if let geolocationError, geolocation == nil {
579 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
580 } else if let geolocation {
581 ForEach(locationRows) { row in
582 LabeledValueRow(row: row)
583 }
584 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
585 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
586 Map(initialPosition: .region(MKCoordinateRegion(
587 center: coordinate,
588 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
589 ))) {
590 Marker(geolocation.ip, coordinate: coordinate)
591 }
592 .mapStyle(.standard)
593 .frame(height: 180)
594 .cornerRadius(8)
595 }
596 } else {
597 MessageRowView(text: "No location data available", isError: false)
598 }
599 }
733600
734 private func sectionHeader(_ title: String) -> some View {
735 Text(title)
736 .font(.system(.headline, design: .default))
737 .foregroundStyle(.white)
738 }
601 CardView {
602 Text("Port Scan")
603 .font(.system(.subheadline, design: .monospaced))
604 .fontWeight(.semibold)
605 .foregroundStyle(.cyan)
739606
740 private var dnssecStatus: Bool? {
741 viewModel.dnsSections.compactMap(\.dnssecSigned).first
742 }
607 if isCloudflareProxied {
608 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
609 .font(.system(.caption2, design: .monospaced))
610 .foregroundStyle(.orange)
611 }
743612
744 private func certRow(_ label: String, _ value: String) -> some View {
745 VStack(alignment: .leading, spacing: 2) {
746 Text(label)
747 .font(.system(.caption2, design: .monospaced))
748 .foregroundStyle(.secondary)
749 Text(value)
750 .font(.system(.caption, design: .monospaced))
751 .textSelection(.enabled)
752 }
753 }
613 if portScanLoading {
614 ProgressView("Scanning ports…")
615 } else if let portScanError, standardPortRows.isEmpty {
616 MessageRowView(text: portScanError, isError: true)
617 } else {
618 Text("Standard Ports")
619 .font(.system(.caption, design: .monospaced))
620 .foregroundStyle(.secondary)
621 PortRowsView(rows: standardPortRows)
622 }
754623
755 private var hstsLoadingRow: some View {
756 VStack(alignment: .leading, spacing: 2) {
757 Text("HSTS Preload")
758 .font(.system(.caption2, design: .monospaced))
759 .foregroundStyle(.secondary)
760 ProgressView()
761 .controlSize(.small)
762 }
763 }
624 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
625 VStack(alignment: .leading, spacing: 10) {
626 TextField("8888, 9000, 27017", text: $customPortInput)
627 .font(.system(.caption, design: .monospaced))
628 .textInputAutocapitalization(.never)
629 .autocorrectionDisabled()
630 .keyboardType(.numberPad)
631 .padding(10)
632 .background(Color(.systemGray6).opacity(0.5))
633 .cornerRadius(6)
764634
765 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
766 VStack(alignment: .leading, spacing: 2) {
767 Text("HSTS Preload")
768 .font(.system(.caption2, design: .monospaced))
769 .foregroundStyle(.secondary)
770 Text(isPreloaded ? "Preloaded" : "Not preloaded")
635 Button("Scan") {
636 onScanCustomPorts()
637 }
638 .buttonStyle(.borderedProminent)
639 .disabled(customPortScanLoading)
640
641 if customPortScanLoading {
642 ProgressView("Scanning custom ports…")
643 } else if let customPortScanError {
644 MessageRowView(text: customPortScanError, isError: true)
645 } else {
646 PortRowsView(rows: customPortRows)
647 }
648 }
649 .padding(.top, 8)
650 }
771651 .font(.system(.caption, design: .monospaced))
772 .foregroundStyle(isPreloaded ? .green : .secondary)
773 }
774 }
775
776 private func horizontallyScrollableCard<Content: View>(
777 spacing: CGFloat = 4,
778 @ViewBuilder content: () -> Content
779 ) -> some View {
780 horizontallyScrollableContent(spacing: spacing) {
781 content()
782 }
783 .padding(10)
784 .background(Color(.systemGray6).opacity(0.5))
785 .cornerRadius(6)
786 }
787
788 private func horizontallyScrollableContent<Content: View>(
789 spacing: CGFloat = 4,
790 @ViewBuilder content: () -> Content
791 ) -> some View {
792 ScrollView(.horizontal) {
793 VStack(alignment: .leading, spacing: spacing) {
794 content()
652 .tint(.secondary)
795653 }
796 .scrollTargetLayout()
797654 }
798 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
799 .frame(maxWidth: .infinity, alignment: .leading)
800655 }
656}
801657
802 private func errorLabel(_ message: String) -> some View {
803 Label(message, systemImage: "exclamationmark.triangle.fill")
804 .font(.system(.caption, design: .monospaced))
805 .foregroundStyle(.red)
806 .padding(8)
807 }
658struct PortRowsView: View {
659 let rows: [PortScanRowViewData]
808660
809 private func portScanResultsCard(_ results: [PortScanResult]) -> some View {
810 VStack(alignment: .leading, spacing: 4) {
811 ForEach(results) { result in
661 var body: some View {
662 if rows.isEmpty {
663 MessageRowView(text: "No results", isError: false)
664 } else {
665 ForEach(rows) { row in
812666 VStack(alignment: .leading, spacing: 2) {
813 HStack(spacing: 8) {
814 Circle()
815 .fill(result.open ? Color.green : Color(.systemGray4))
816 .frame(width: 8, height: 8)
817 Text("\(result.port)")
667 HStack {
668 Text(row.portLabel)
818669 .font(.system(.caption, design: .monospaced))
819 .lineLimit(1)
820670 .frame(width: 52, alignment: .leading)
821 Text(result.service)
671 Text(row.service)
822672 .font(.system(.caption, design: .monospaced))
823 .foregroundStyle(result.open ? .primary : .secondary)
673 .foregroundStyle(.primary)
824674 Spacer()
825 if result.open {
826 Text("Open")
675 if let durationLabel = row.durationLabel {
676 Text(durationLabel)
827677 .font(.system(.caption2, design: .monospaced))
828 .foregroundStyle(.green)
678 .foregroundStyle(.secondary)
829679 }
680 Text(row.statusLabel)
681 .font(.system(.caption2, design: .monospaced))
682 .foregroundStyle(ResultColors.color(for: row.statusTone))
830683 }
831
832 if let banner = result.banner {
684 if let banner = row.banner {
833685 Text(banner)
834686 .font(.system(.caption2, design: .monospaced))
835687 .foregroundStyle(.secondary)
836 .lineLimit(1)
837 .padding(.leading, 16)
688 .padding(.leading, 8)
838689 }
839690 }
840691 }
841692 }
842693 }
694}
843695
844 private func parsedCustomPorts(from input: String) -> [UInt16] {
845 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
846 var seen = Set<UInt16>()
847 var ports: [UInt16] = []
848
849 for part in parts {
850 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
851 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
852 continue
853 }
854 ports.append(value)
855 if ports.count == 20 {
856 break
857 }
858 }
696struct SectionTitleView: View {
697 let title: String
859698
860 return ports
699 var body: some View {
700 Text(title)
701 .font(.system(.headline))
702 .foregroundStyle(.white)
861703 }
704}
862705
863 private var httpStatusSummaryParts: [(text: String, color: Color)] {
864 var parts: [(text: String, color: Color)] = []
706struct CardView<Content: View>: View {
707 let content: Content
865708
866 if let statusCode = viewModel.httpStatusCode {
867 parts.append(("HTTP \(statusCode)", .cyan))
868 }
869 if let responseTimeMs = viewModel.httpResponseTimeMs {
870 parts.append(("\(responseTimeMs)ms", .secondary))
871 }
872 if let httpProtocol = viewModel.httpProtocol {
873 parts.append((httpProtocol, .secondary))
874 }
709 init(@ViewBuilder content: () -> Content) {
710 self.content = content()
711 }
875712
876 return parts
713 var body: some View {
714 ScrollView(.horizontal) {
715 VStack(alignment: .leading, spacing: 6) {
716 content
717 }
718 .scrollTargetLayout()
719 }
720 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
721 .frame(maxWidth: .infinity, alignment: .leading)
722 .padding(10)
723 .background(Color(.systemGray6).opacity(0.5))
724 .cornerRadius(6)
877725 }
726}
878727
879 private var http3AvailabilityNote: String? {
880 guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else {
881 return nil
728struct LoadingCardView: View {
729 let text: String
730
731 var body: some View {
732 CardView {
733 ProgressView(text)
734 .frame(maxWidth: .infinity, alignment: .center)
882735 }
883 return "(HTTP/3 available)"
884736 }
737}
738
739struct MessageCardView: View {
740 let text: String
741 let isError: Bool
885742
886 private func httpSecurityGradeColor(for grade: String) -> Color {
887 switch grade {
888 case "A", "B":
889 .green
890 case "C":
891 .yellow
892 case "D", "F":
893 .red
894 default:
895 .secondary
743 var body: some View {
744 CardView {
745 MessageRowView(text: text, isError: isError)
896746 }
897747 }
748}
749
750struct MessageRowView: View {
751 let text: String
752 let isError: Bool
898753
899 private func expiryColor(_ days: Int) -> Color {
900 if days < 30 { return .red }
901 if days < 60 { return .yellow }
902 return .green
754 var body: some View {
755 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
756 .font(.system(.caption, design: .monospaced))
757 .foregroundStyle(isError ? .red : .secondary)
903758 }
759}
904760
905 private func shareResults() {
906 let text = viewModel.exportText()
907 let dateFmt = DateFormatter()
908 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
909 let timestamp = dateFmt.string(from: Date())
910 let filename = "\(timestamp)_domaindigresults.txt"
911 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
761struct LabeledValueRow: View {
762 let row: InfoRowViewData
912763
913 do {
914 try text.write(to: tempURL, atomically: true, encoding: .utf8)
915 } catch {
916 return
764 var body: some View {
765 VStack(alignment: .leading, spacing: 2) {
766 Text(row.label)
767 .font(.system(.caption2, design: .monospaced))
768 .foregroundStyle(.secondary)
769 Text(row.value)
770 .font(.system(.caption, design: .monospaced))
771 .foregroundStyle(ResultColors.color(for: row.tone))
772 .textSelection(.enabled)
917773 }
774 }
775}
918776
919 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
920 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
921 let rootVC = windowScene.keyWindow?.rootViewController else { return }
922 var presenter = rootVC
923 while let presented = presenter.presentedViewController {
924 presenter = presented
777enum ResultColors {
778 static func color(for tone: ResultTone) -> Color {
779 switch tone {
780 case .primary:
781 return .primary
782 case .secondary:
783 return .secondary
784 case .success:
785 return .green
786 case .warning:
787 return .yellow
788 case .failure:
789 return .red
925790 }
926 activityVC.popoverPresentationController?.sourceView = presenter.view
927 presenter.present(activityVC, animated: true)
928791 }
929792}
930793
931794extension DateFormatter {
932795 static let certDate: DateFormatter = {
933 let f = DateFormatter()
934 f.dateStyle = .medium
935 f.timeStyle = .short
936 return f
796 let formatter = DateFormatter()
797 formatter.dateStyle = .medium
798 formatter.timeStyle = .short
799 return formatter
937800 }()
938801}
939802
@@ -948,10 +811,7 @@ private struct SettingsView: View {
948811 guard resolverOption == .custom else {
949812 return nil
950813 }
951
952 return DNSResolverOption.isValidCustomURL(customResolverURL)
953 ? nil
954 : "Resolver URL must start with https://"
814 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
955815 }
956816
957817 var body: some View {
@@ -981,9 +841,7 @@ private struct SettingsView: View {
981841 .onAppear {
982842 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
983843 resolverOption = DNSResolverOption.option(for: currentResolverURL)
984 customResolverURL = resolverOption == .custom
985 ? currentResolverURL
986 : DNSResolverOption.defaultURLString
844 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
987845 }
988846 .onChange(of: resolverOption) { _, newValue in
989847 guard let presetURL = newValue.urlString else {
@@ -993,9 +851,7 @@ private struct SettingsView: View {
993851 storedResolverURL = presetURL
994852 }
995853 .onChange(of: customResolverURL) { _, newValue in
996 guard resolverOption == .custom else {
997 return
998 }
854 guard resolverOption == .custom else { return }
999855 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1000856 }
1001857 }
DomainDig/DNSLookupService.swift +18 −5
@@ -57,8 +57,6 @@ enum DNSResolverOption: String, CaseIterable, Identifiable {
5757}
5858
5959struct DNSLookupService {
60 private static let rrsigQueryType = 46
61 private static let dnskeyQueryType = 48
6260 private static let internetClass = 1
6361
6462 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
@@ -93,7 +91,7 @@ struct DNSLookupService {
9391 }
9492 }
9593
96 static func lookupAll(domain: String) async -> [DNSSection] {
94 static func lookupAll(domain: String) async -> ServiceResult<[DNSSection]> {
9795 typealias Result = (
9896 type: DNSRecordType,
9997 records: [DNSRecord],
@@ -109,8 +107,11 @@ struct DNSLookupService {
109107 resolverURLString: resolverURLString
110108 )
111109
112 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
110 let sections = await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
113111 for recordType in DNSRecordType.allCases {
112 guard recordType != .PTR else {
113 continue
114 }
114115 let shouldQueryWildcard = wildcardTypes.contains(recordType)
115116 group.addTask {
116117 var apexRecords: [DNSRecord] = []
@@ -159,6 +160,12 @@ struct DNSLookupService {
159160 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
160161 }
161162 }
163
164 if sections.contains(where: { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty || $0.error != nil }) {
165 return .success(sections)
166 }
167
168 return .empty("No DNS records found")
162169 }
163170
164171 private static func lookupResponse(
@@ -218,11 +225,17 @@ struct DNSLookupService {
218225 return response.authenticatedData
219226 }
220227
221 private static func currentResolverURLString() -> String {
228 static func currentResolverURLString() -> String {
222229 let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
223230 return DNSResolverOption.resolvedURLString(from: storedValue)
224231 }
225232
233 static func currentResolverDisplayName() -> String {
234 let resolverURLString = currentResolverURLString()
235 let option = DNSResolverOption.option(for: resolverURLString)
236 return option == .custom ? resolverURLString : option.title
237 }
238
226239 private static func validatedResolverURL(from urlString: String) throws -> URL {
227240 guard let url = URL(string: urlString) else {
228241 throw URLError(.badURL)
DomainDig/DomainViewModel.swift +965 −476
@@ -1,24 +1,162 @@
11import Foundation
22import SwiftUI
33
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 LookupSnapshot {
76 let domain: String
77 let timestamp: Date
78 let resolverDisplayName: String
79 let resolverURLString: String
80 let totalLookupDurationMs: Int?
81 let dnsSections: [DNSSection]
82 let dnsError: String?
83 let sslInfo: SSLCertificateInfo?
84 let sslError: String?
85 let hstsPreloaded: Bool?
86 let httpHeaders: [HTTPHeader]
87 let httpSecurityGrade: String?
88 let httpStatusCode: Int?
89 let httpResponseTimeMs: Int?
90 let httpProtocol: String?
91 let http3Advertised: Bool
92 let httpHeadersError: String?
93 let reachabilityResults: [PortReachability]
94 let reachabilityError: String?
95 let ipGeolocation: IPGeolocation?
96 let ipGeolocationError: String?
97 let emailSecurity: EmailSecurityResult?
98 let emailSecurityError: String?
99 let ptrRecord: String?
100 let ptrError: String?
101 let redirectChain: [RedirectHop]
102 let redirectChainError: String?
103 let portScanResults: [PortScanResult]
104 let portScanError: String?
105 let isLive: Bool
106}
107
108extension HistoryEntry {
109 var snapshot: LookupSnapshot {
110 LookupSnapshot(
111 domain: domain,
112 timestamp: timestamp,
113 resolverDisplayName: resolverDisplayName,
114 resolverURLString: resolverURLString,
115 totalLookupDurationMs: totalLookupDurationMs,
116 dnsSections: dnsSections,
117 dnsError: nil,
118 sslInfo: sslInfo,
119 sslError: sslError,
120 hstsPreloaded: hstsPreloaded,
121 httpHeaders: httpHeaders,
122 httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
123 httpStatusCode: nil,
124 httpResponseTimeMs: nil,
125 httpProtocol: nil,
126 http3Advertised: false,
127 httpHeadersError: httpHeadersError,
128 reachabilityResults: reachabilityResults,
129 reachabilityError: reachabilityError,
130 ipGeolocation: ipGeolocation,
131 ipGeolocationError: ipGeolocationError,
132 emailSecurity: emailSecurity,
133 emailSecurityError: emailSecurityError,
134 ptrRecord: ptrRecord,
135 ptrError: ptrError,
136 redirectChain: redirectChain,
137 redirectChainError: redirectChainError,
138 portScanResults: portScanResults,
139 portScanError: portScanError,
140 isLive: false
141 )
142 }
143}
144
4145@MainActor
5146@Observable
6147final class DomainViewModel {
7148 var domain: String = ""
8149
9 // DNS
10150 var dnsSections: [DNSSection] = []
11151 var dnsLoading = false
12152 var dnsError: String?
13153
14 // SSL
15154 var sslInfo: SSLCertificateInfo?
16155 var sslLoading = false
17156 var sslError: String?
18157 var hstsPreloaded: Bool?
19158 var hstsLoading = false
20159
21 // HTTP Headers
22160 var httpHeaders: [HTTPHeader] = []
23161 var httpSecurityGrade: String?
24162 var httpStatusCode: Int?
@@ -28,32 +166,26 @@ final class DomainViewModel {
28166 var httpHeadersLoading = false
29167 var httpHeadersError: String?
30168
31 // Reachability
32169 var reachabilityResults: [PortReachability] = []
33170 var reachabilityLoading = false
34171 var reachabilityError: String?
35172
36 // IP Geolocation
37173 var ipGeolocation: IPGeolocation?
38174 var ipGeolocationLoading = false
39175 var ipGeolocationError: String?
40176
41 // Email Security
42177 var emailSecurity: EmailSecurityResult?
43178 var emailSecurityLoading = false
44179 var emailSecurityError: String?
45180
46 // PTR / Reverse DNS
47181 var ptrRecord: String?
48182 var ptrLoading = false
49183 var ptrError: String?
50184
51 // Redirect Chain
52185 var redirectChain: [RedirectHop] = []
53186 var redirectChainLoading = false
54187 var redirectChainError: String?
55188
56 // Port Scan
57189 var portScanResults: [PortScanResult] = []
58190 var portScanLoading = false
59191 var portScanError: String?
@@ -63,368 +195,494 @@ final class DomainViewModel {
63195
64196 var hasRun = false
65197 private(set) var searchedDomain: String = ""
198 private(set) var lastLookupDurationMs: Int?
66199
67 // MARK: - Recent Searches
200 private var lookupTask: Task<Void, Never>?
201 private var customPortScanTask: Task<Void, Never>?
202 private var activeLookupID = UUID()
203 private var lookupStartedAt: Date?
68204
69205 private static let recentSearchesKey = "recentSearches"
70206 private static let maxRecent = 20
71
72207 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
73208
74 // MARK: - Saved Domains
75
76209 private static let savedDomainsKey = "savedDomains"
77
78210 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
79211
80 var isCurrentDomainSaved: Bool {
81 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
212 private static let historyKey = "lookupHistory"
213 private static let maxHistory = 50
214 var history: [HistoryEntry] = {
215 guard let data = UserDefaults.standard.data(forKey: historyKey),
216 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
217 return []
218 }
219 return entries
220 }()
221
222 var trimmedDomain: String {
223 domain
224 .trimmingCharacters(in: .whitespacesAndNewlines)
225 .replacingOccurrences(of: "https://", with: "")
226 .replacingOccurrences(of: "http://", with: "")
227 .components(separatedBy: "/").first ?? ""
82228 }
83229
84 func toggleSavedDomain() {
85 if isCurrentDomainSaved {
86 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
87 } else {
88 savedDomains.append(searchedDomain)
89 }
90 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
230 var resultsLoaded: Bool {
231 hasRun &&
232 !dnsLoading &&
233 !sslLoading &&
234 !hstsLoading &&
235 !httpHeadersLoading &&
236 !reachabilityLoading &&
237 !ipGeolocationLoading &&
238 !emailSecurityLoading &&
239 !ptrLoading &&
240 !redirectChainLoading &&
241 !portScanLoading &&
242 !customPortScanLoading
91243 }
92244
93 func removeSavedDomain(_ domain: String) {
94 savedDomains.removeAll { $0 == domain }
95 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
245 var isCloudflareProxied: Bool {
246 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
96247 }
97248
98 func removeSavedDomains(at offsets: IndexSet) {
99 savedDomains.remove(atOffsets: offsets)
100 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
249 var isCurrentDomainSaved: Bool {
250 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
101251 }
102252
103 // MARK: - History
253 var resolverDisplayName: String {
254 DNSLookupService.currentResolverDisplayName()
255 }
104256
105 private static let historyKey = "lookupHistory"
106 private static let maxHistory = 50
257 var resolverURLString: String {
258 DNSLookupService.currentResolverURLString()
259 }
107260
108 var history: [HistoryEntry] = {
109 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
110 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
111 return []
261 var allPortScanResults: [PortScanResult] {
262 (portScanResults + customPortResults).sorted {
263 if $0.kind == $1.kind {
264 return $0.port < $1.port
265 }
266 return $0.kind == .standard
112267 }
113 return entries
114 }()
268 }
115269
116 private func saveHistoryEntry() {
117 let entry = HistoryEntry(
270 var currentSnapshot: LookupSnapshot {
271 LookupSnapshot(
118272 domain: searchedDomain,
119273 timestamp: Date(),
274 resolverDisplayName: resolverDisplayName,
275 resolverURLString: resolverURLString,
276 totalLookupDurationMs: lastLookupDurationMs,
120277 dnsSections: dnsSections,
278 dnsError: dnsError,
121279 sslInfo: sslInfo,
280 sslError: sslError,
281 hstsPreloaded: hstsPreloaded,
122282 httpHeaders: httpHeaders,
283 httpSecurityGrade: httpSecurityGrade,
284 httpStatusCode: httpStatusCode,
285 httpResponseTimeMs: httpResponseTimeMs,
286 httpProtocol: httpProtocol,
287 http3Advertised: http3Advertised,
288 httpHeadersError: httpHeadersError,
123289 reachabilityResults: reachabilityResults,
290 reachabilityError: reachabilityError,
124291 ipGeolocation: ipGeolocation,
292 ipGeolocationError: ipGeolocationError,
125293 emailSecurity: emailSecurity,
126 mtaSts: emailSecurity?.mtaSts,
294 emailSecurityError: emailSecurityError,
127295 ptrRecord: ptrRecord,
296 ptrError: ptrError,
128297 redirectChain: redirectChain,
129 portScanResults: portScanResults,
130 hstsPreloaded: hstsPreloaded
298 redirectChainError: redirectChainError,
299 portScanResults: allPortScanResults,
300 portScanError: combinedPortScanError,
301 isLive: true
131302 )
132 history.insert(entry, at: 0)
133 if history.count > Self.maxHistory {
134 history = Array(history.prefix(Self.maxHistory))
135 }
136 if let data = try? JSONEncoder().encode(history) {
137 UserDefaults.standard.set(data, forKey: Self.historyKey)
138 }
139303 }
140304
141 func removeHistoryEntries(at offsets: IndexSet) {
142 history.remove(atOffsets: offsets)
143 if let data = try? JSONEncoder().encode(history) {
144 UserDefaults.standard.set(data, forKey: Self.historyKey)
145 }
305 var summaryFields: [SummaryFieldViewData] {
306 Self.summaryFields(from: currentSnapshot)
146307 }
147308
148 // MARK: - Computed
309 var domainRows: [InfoRowViewData] {
310 Self.domainRows(from: currentSnapshot)
311 }
149312
150 var trimmedDomain: String {
151 domain
152 .trimmingCharacters(in: .whitespacesAndNewlines)
153 .replacingOccurrences(of: "https://", with: "")
154 .replacingOccurrences(of: "http://", with: "")
155 .components(separatedBy: "/").first ?? ""
313 var dnsRows: [DNSRecordSectionViewData] {
314 Self.dnsRows(from: currentSnapshot)
156315 }
157316
158 /// True when all lookups have finished (regardless of success/failure).
159 var resultsLoaded: Bool {
160 hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
161 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
162 && !redirectChainLoading && !portScanLoading
317 var dnssecLabel: String? {
318 Self.dnssecLabel(from: currentSnapshot)
163319 }
164320
165 /// True when response headers indicate the domain is behind Cloudflare's proxy.
166 /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud
167 /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge.
168 var isCloudflareProxied: Bool {
169 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
321 var ptrMessage: SectionMessageViewData? {
322 Self.ptrMessage(from: currentSnapshot)
323 }
324
325 var webCertificateRows: [InfoRowViewData] {
326 Self.webCertificateRows(from: currentSnapshot)
327 }
328
329 var webResponseRows: [InfoRowViewData] {
330 Self.webResponseRows(from: currentSnapshot)
170331 }
171332
172 // MARK: - Reset
333 var redirectRows: [RedirectHopViewData] {
334 Self.redirectRows(from: currentSnapshot)
335 }
336
337 var emailRows: [EmailRowViewData] {
338 Self.emailRows(from: currentSnapshot)
339 }
340
341 var reachabilityRows: [ReachabilityRowViewData] {
342 Self.reachabilityRows(from: currentSnapshot)
343 }
344
345 var locationRows: [InfoRowViewData] {
346 Self.locationRows(from: currentSnapshot)
347 }
348
349 var standardPortRows: [PortScanRowViewData] {
350 Self.portRows(from: currentSnapshot, kind: .standard)
351 }
352
353 var customPortRows: [PortScanRowViewData] {
354 Self.portRows(from: currentSnapshot, kind: .custom)
355 }
356
357 var combinedPortScanError: String? {
358 [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty
359 }
360
361 func toggleSavedDomain() {
362 if isCurrentDomainSaved {
363 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
364 } else {
365 savedDomains.append(searchedDomain)
366 }
367 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
368 }
369
370 func removeSavedDomains(at offsets: IndexSet) {
371 savedDomains.remove(atOffsets: offsets)
372 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
373 }
374
375 func removeHistoryEntries(at offsets: IndexSet) {
376 history.remove(atOffsets: offsets)
377 persistHistory()
378 }
379
380 func clearRecentSearches() {
381 recentSearches.removeAll()
382 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
383 }
384
385 func rerunLookup(from entry: HistoryEntry) {
386 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
387 domain = entry.domain
388 run()
389 }
173390
174391 func reset() {
392 lookupTask?.cancel()
393 customPortScanTask?.cancel()
175394 hasRun = false
176395 searchedDomain = ""
177 dnsSections = []
178 dnsError = nil
179 dnsLoading = false
180 sslInfo = nil
181 sslError = nil
182 sslLoading = false
183 hstsPreloaded = nil
184 hstsLoading = false
185 httpHeaders = []
186 httpSecurityGrade = nil
187 httpStatusCode = nil
188 httpResponseTimeMs = nil
189 httpProtocol = nil
190 http3Advertised = false
191 httpHeadersError = nil
192 httpHeadersLoading = false
193 reachabilityResults = []
194 reachabilityError = nil
195 reachabilityLoading = false
196 ipGeolocation = nil
197 ipGeolocationError = nil
198 ipGeolocationLoading = false
199 emailSecurity = nil
200 emailSecurityError = nil
201 emailSecurityLoading = false
202 ptrRecord = nil
203 ptrError = nil
204 ptrLoading = false
205 redirectChain = []
206 redirectChainError = nil
207 redirectChainLoading = false
208 portScanResults = []
209 portScanError = nil
210 portScanLoading = false
211 customPortResults = []
212 customPortScanError = nil
213 customPortScanLoading = false
396 lastLookupDurationMs = nil
397 clearLookupState()
214398 }
215399
216 // MARK: - Run
217
218400 func run() {
219401 let target = trimmedDomain
220402 guard !target.isEmpty else { return }
221403
404 lookupTask?.cancel()
405 customPortScanTask?.cancel()
406
407 let lookupID = UUID()
408 activeLookupID = lookupID
409 lookupStartedAt = Date()
410 lastLookupDurationMs = nil
222411 addRecentSearch(target)
223412 searchedDomain = target
224413 hasRun = true
414 clearLookupState()
415 setAllLoadingStates(true)
416 customPortScanLoading = false
225417
226 // Reset all state
227 dnsSections = []
228 dnsError = nil
229 dnsLoading = true
230 sslInfo = nil
231 sslError = nil
232 sslLoading = true
233 hstsPreloaded = nil
234 hstsLoading = true
235 httpHeaders = []
236 httpSecurityGrade = nil
237 httpStatusCode = nil
238 httpResponseTimeMs = nil
239 httpProtocol = nil
240 http3Advertised = false
241 httpHeadersError = nil
242 httpHeadersLoading = true
243 reachabilityResults = []
244 reachabilityError = nil
245 reachabilityLoading = true
246 ipGeolocation = nil
247 ipGeolocationError = nil
248 ipGeolocationLoading = true
249 emailSecurity = nil
250 emailSecurityError = nil
251 emailSecurityLoading = true
252 ptrRecord = nil
253 ptrError = nil
254 ptrLoading = true
255 redirectChain = []
256 redirectChainError = nil
257 redirectChainLoading = true
258 portScanResults = []
259 portScanError = nil
260 portScanLoading = true
261 customPortResults = []
418 lookupTask = Task { [weak self] in
419 guard let self else { return }
420 await self.performLookup(domain: target, lookupID: lookupID)
421 }
422 }
423
424 func runCustomPortScan(ports: [UInt16]) async {
425 guard !searchedDomain.isEmpty else {
426 customPortScanError = "Run a domain lookup first"
427 return
428 }
429
430 guard !ports.isEmpty else {
431 customPortScanError = "Enter at least one valid port"
432 customPortResults = []
433 return
434 }
435
436 customPortScanTask?.cancel()
437 let domain = searchedDomain
438 let lookupID = activeLookupID
439
440 customPortScanLoading = true
262441 customPortScanError = nil
263 customPortScanLoading = false
442 customPortResults = []
264443
265 Task {
266 await withTaskGroup(of: Void.self) { group in
267 // DNS → chained: email security, PTR, geolocation
268 group.addTask { @MainActor in
269 await self.runDNS(domain: target)
270 // These depend on DNS results and run in parallel after DNS
271 await withTaskGroup(of: Void.self) { postDNS in
272 postDNS.addTask { @MainActor in
273 await self.runEmailSecurity(domain: target)
274 }
275 postDNS.addTask { @MainActor in
276 await self.runReverseDNS()
277 }
278 postDNS.addTask { @MainActor in
279 await self.runIPGeolocation()
280 }
281 }
282 }
283 group.addTask { @MainActor in
284 await self.runSSL(domain: target)
285 }
286 group.addTask { @MainActor in
287 await self.runHSTSPreload(domain: target)
288 }
289 group.addTask { @MainActor in
290 await self.runHTTPHeaders(domain: target)
291 }
292 group.addTask { @MainActor in
293 await self.runReachability(domain: target)
294 }
295 group.addTask { @MainActor in
296 await self.runRedirectChain(domain: target)
297 }
298 group.addTask { @MainActor in
299 await self.runPortScan(domain: target)
300 }
301 }
302 // Save history after all lookups complete so the snapshot is complete
303 saveHistoryEntry()
444 customPortScanTask = Task { [weak self] in
445 guard let self else { return }
446 let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
447 guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
448 self.applyCustomPortResult(result)
304449 }
305450 }
306451
307 // MARK: - Lookup Methods
452 func exportText() -> String {
453 Self.formatExportText(from: currentSnapshot)
454 }
455
456 private func performLookup(domain: String, lookupID: UUID) async {
457 await withTaskGroup(of: Void.self) { group in
458 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
459 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
460 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
461 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
462 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
463 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
464 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
465 }
466
467 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
468
469 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
470 let primaryIP = primaryIPAddress(from: dnsSections)
471
472 await withTaskGroup(of: Void.self) { group in
473 group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
474 if let primaryIP {
475 group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
476 group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
477 } else {
478 group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
479 }
480 }
481
482 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
483 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
484 saveHistoryEntry(replaceLatest: false)
485 }
308486
309 private func runDNS(domain: String) async {
310 do {
311 let sections = await DNSLookupService.lookupAll(domain: domain)
487 private func runDNS(domain: String, lookupID: UUID) async {
488 let result = await DNSLookupService.lookupAll(domain: domain)
489 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
490 switch result {
491 case let .success(sections):
312492 dnsSections = sections
493 dnsError = nil
494 case let .empty(message):
495 dnsSections = []
496 dnsError = message
497 case let .error(message):
498 dnsSections = []
499 dnsError = message
313500 }
314501 dnsLoading = false
315502 }
316503
317 private func runSSL(domain: String) async {
318 do {
319 let info = try await SSLCheckService.check(domain: domain)
504 private func runSSL(domain: String, lookupID: UUID) async {
505 let result = await SSLCheckService.check(domain: domain)
506 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
507 switch result {
508 case let .success(info):
320509 sslInfo = info
321 } catch {
322 sslError = error.localizedDescription
510 sslError = nil
511 case let .empty(message):
512 sslInfo = nil
513 sslError = message
514 case let .error(message):
515 sslInfo = nil
516 sslError = message
323517 }
324518 sslLoading = false
325519 }
326520
327 private func runHSTSPreload(domain: String) async {
328 hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
521 private func runHSTSPreload(domain: String, lookupID: UUID) async {
522 let result = await SSLCheckService.checkHSTSPreload(domain: domain)
523 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
524 hstsPreloaded = result
329525 hstsLoading = false
330526 }
331527
332 private func runHTTPHeaders(domain: String) async {
333 do {
334 let result = try await HTTPHeadersService.fetch(domain: domain)
335 httpHeaders = result.headers
336 httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
337 httpStatusCode = result.statusCode
338 httpResponseTimeMs = result.responseTimeMs
339 httpProtocol = result.httpProtocol
340 http3Advertised = result.http3Advertised
341 } catch {
342 httpHeadersError = error.localizedDescription
528 private func runHTTPHeaders(domain: String, lookupID: UUID) async {
529 let result = await HTTPHeadersService.fetch(domain: domain)
530 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
531 switch result {
532 case let .success(headersResult):
533 httpHeaders = headersResult.headers
534 httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
535 httpStatusCode = headersResult.statusCode
536 httpResponseTimeMs = headersResult.responseTimeMs
537 httpProtocol = headersResult.httpProtocol
538 http3Advertised = headersResult.http3Advertised
539 httpHeadersError = nil
540 case let .empty(message):
541 httpHeaders = []
542 httpSecurityGrade = nil
543 httpStatusCode = nil
544 httpResponseTimeMs = nil
545 httpProtocol = nil
546 http3Advertised = false
547 httpHeadersError = message
548 case let .error(message):
549 httpHeaders = []
550 httpSecurityGrade = nil
551 httpStatusCode = nil
552 httpResponseTimeMs = nil
553 httpProtocol = nil
554 http3Advertised = false
555 httpHeadersError = message
343556 }
344557 httpHeadersLoading = false
345558 }
346559
347 private func runReachability(domain: String) async {
348 let results = await ReachabilityService.checkAll(domain: domain)
349 reachabilityResults = results
350 reachabilityLoading = false
351 }
352
353 private func runIPGeolocation() async {
354 // Find the first A record IP
355 guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
356 let firstIP = aSection.records.first?.value else {
357 ipGeolocationError = "No A record available"
358 ipGeolocationLoading = false
359 return
560 private func runReachability(domain: String, lookupID: UUID) async {
561 let result = await ReachabilityService.checkAll(domain: domain)
562 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
563 switch result {
564 case let .success(results):
565 reachabilityResults = results
566 reachabilityError = nil
567 case let .empty(message):
568 reachabilityResults = []
569 reachabilityError = message
570 case let .error(message):
571 reachabilityResults = []
572 reachabilityError = message
360573 }
361 do {
362 let geo = try await IPGeolocationService.lookup(ip: firstIP)
363 ipGeolocation = geo
364 } catch {
365 ipGeolocationError = error.localizedDescription
366 }
367 ipGeolocationLoading = false
574 reachabilityLoading = false
368575 }
369576
370 private func runEmailSecurity(domain: String) async {
371 // Extract TXT records from already-fetched DNS sections
372 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
577 private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
373578 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
374 emailSecurity = result
579 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
580 switch result {
581 case let .success(emailResult):
582 emailSecurity = emailResult
583 emailSecurityError = nil
584 case let .empty(message):
585 emailSecurity = nil
586 emailSecurityError = message
587 case let .error(message):
588 emailSecurity = nil
589 emailSecurityError = message
590 }
375591 emailSecurityLoading = false
376592 }
377593
378 private func runReverseDNS() async {
379 guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
380 let firstIP = aSection.records.first?.value else {
381 ptrError = "No A record available"
382 ptrLoading = false
383 return
384 }
385 let result = await ReverseDNSService.lookup(ip: firstIP)
386 ptrRecord = result
387 if result == nil {
388 ptrError = "No PTR record found"
594 private func runReverseDNS(ip: String, lookupID: UUID) async {
595 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
596 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
597 switch result {
598 case let .success(record):
599 ptrRecord = record
600 ptrError = nil
601 case let .empty(message):
602 ptrRecord = nil
603 ptrError = message
604 case let .error(message):
605 ptrRecord = nil
606 ptrError = message
389607 }
390608 ptrLoading = false
391609 }
392610
393 private func runRedirectChain(domain: String) async {
394 do {
395 let hops = try await RedirectChainService.trace(domain: domain)
611 private func runRedirectChain(domain: String, lookupID: UUID) async {
612 let result = await RedirectChainService.trace(domain: domain)
613 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
614 switch result {
615 case let .success(hops):
396616 redirectChain = hops
397 } catch {
398 redirectChainError = error.localizedDescription
617 redirectChainError = nil
618 case let .empty(message):
619 redirectChain = []
620 redirectChainError = message
621 case let .error(message):
622 redirectChain = []
623 redirectChainError = message
399624 }
400625 redirectChainLoading = false
401626 }
402627
403 private func runPortScan(domain: String) async {
404 let results = await PortScanService.scanAll(domain: domain)
405 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
406 portScanResults = enrichedResults
628 private func runPortScan(domain: String, lookupID: UUID) async {
629 let result = await PortScanService.scanAll(domain: domain)
630 switch result {
631 case let .success(results):
632 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain)
633 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
634 portScanResults = enrichedResults
635 portScanError = nil
636 case let .empty(message):
637 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
638 portScanResults = []
639 portScanError = message
640 case let .error(message):
641 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
642 portScanResults = []
643 portScanError = message
644 }
407645 portScanLoading = false
408646 }
409647
410 func runCustomPortScan(ports: [UInt16]) async {
411 guard !searchedDomain.isEmpty else {
412 customPortScanError = "Run a domain lookup first"
413 return
648 private func runIPGeolocation(ip: String, lookupID: UUID) async {
649 let result = await IPGeolocationService.lookup(ip: ip)
650 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
651 switch result {
652 case let .success(geolocation):
653 ipGeolocation = geolocation
654 ipGeolocationError = nil
655 case let .empty(message):
656 ipGeolocation = nil
657 ipGeolocationError = message
658 case let .error(message):
659 ipGeolocation = nil
660 ipGeolocationError = message
414661 }
662 ipGeolocationLoading = false
663 }
415664
416 guard !ports.isEmpty else {
417 customPortScanError = "Enter at least one valid port"
665 private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
666 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
667 ptrLoading = false
668 ptrError = "No A record available"
669 ipGeolocationLoading = false
670 ipGeolocationError = "No A record available"
671 }
672
673 private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
674 switch result {
675 case let .success(results):
676 customPortResults = results
677 customPortScanError = nil
678 saveHistoryEntry(replaceLatest: true)
679 case let .empty(message):
418680 customPortResults = []
419 return
681 customPortScanError = message
682 case let .error(message):
683 customPortResults = []
684 customPortScanError = message
420685 }
421
422 customPortScanLoading = true
423 customPortScanError = nil
424 customPortResults = []
425
426 let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0)
427 customPortResults = results
428686 customPortScanLoading = false
429687 }
430688
@@ -453,271 +711,502 @@ final class DomainViewModel {
453711 }
454712 }
455713
456 // MARK: - Export
457
458 func exportText() -> String {
459 return Self.formatExportText(
714 private func saveHistoryEntry(replaceLatest: Bool) {
715 guard !searchedDomain.isEmpty else { return }
716 let entry = HistoryEntry(
460717 domain: searchedDomain,
461 date: Date(),
718 timestamp: Date(),
462719 dnsSections: dnsSections,
463720 sslInfo: sslInfo,
464 sslError: sslError,
465 hstsPreloaded: hstsPreloaded,
466721 httpHeaders: httpHeaders,
467 httpSecurityGrade: httpSecurityGrade,
468 httpStatusCode: httpStatusCode,
469 httpResponseTimeMs: httpResponseTimeMs,
470 httpProtocol: httpProtocol,
471 http3Advertised: http3Advertised,
472 httpHeadersError: httpHeadersError,
473722 reachabilityResults: reachabilityResults,
474723 ipGeolocation: ipGeolocation,
475 ipGeolocationError: ipGeolocationError,
476724 emailSecurity: emailSecurity,
725 mtaSts: emailSecurity?.mtaSts,
477726 ptrRecord: ptrRecord,
478727 redirectChain: redirectChain,
479 portScanResults: portScanResults
728 portScanResults: allPortScanResults,
729 hstsPreloaded: hstsPreloaded,
730 resolverDisplayName: resolverDisplayName,
731 resolverURLString: resolverURLString,
732 totalLookupDurationMs: lastLookupDurationMs,
733 sslError: sslError,
734 httpHeadersError: httpHeadersError,
735 reachabilityError: reachabilityError,
736 ipGeolocationError: ipGeolocationError,
737 emailSecurityError: emailSecurityError,
738 ptrError: ptrError,
739 redirectChainError: redirectChainError,
740 portScanError: combinedPortScanError
480741 )
742
743 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
744 history[0] = entry
745 } else {
746 history.insert(entry, at: 0)
747 if history.count > Self.maxHistory {
748 history = Array(history.prefix(Self.maxHistory))
749 }
750 }
751 persistHistory()
752 }
753
754 private func persistHistory() {
755 if let data = try? JSONEncoder().encode(history) {
756 UserDefaults.standard.set(data, forKey: Self.historyKey)
757 }
758 }
759
760 private func addRecentSearch(_ domain: String) {
761 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
762 recentSearches.insert(domain, at: 0)
763 if recentSearches.count > Self.maxRecent {
764 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
765 }
766 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
767 }
768
769 private func clearLookupState() {
770 dnsSections = []
771 dnsError = nil
772 dnsLoading = false
773 sslInfo = nil
774 sslError = nil
775 sslLoading = false
776 hstsPreloaded = nil
777 hstsLoading = false
778 httpHeaders = []
779 httpSecurityGrade = nil
780 httpStatusCode = nil
781 httpResponseTimeMs = nil
782 httpProtocol = nil
783 http3Advertised = false
784 httpHeadersError = nil
785 httpHeadersLoading = false
786 reachabilityResults = []
787 reachabilityError = nil
788 reachabilityLoading = false
789 ipGeolocation = nil
790 ipGeolocationError = nil
791 ipGeolocationLoading = false
792 emailSecurity = nil
793 emailSecurityError = nil
794 emailSecurityLoading = false
795 ptrRecord = nil
796 ptrError = nil
797 ptrLoading = false
798 redirectChain = []
799 redirectChainError = nil
800 redirectChainLoading = false
801 portScanResults = []
802 portScanError = nil
803 portScanLoading = false
804 customPortResults = []
805 customPortScanError = nil
806 customPortScanLoading = false
807 }
808
809 private func setAllLoadingStates(_ loading: Bool) {
810 dnsLoading = loading
811 sslLoading = loading
812 hstsLoading = loading
813 httpHeadersLoading = loading
814 reachabilityLoading = loading
815 ipGeolocationLoading = loading
816 emailSecurityLoading = loading
817 ptrLoading = loading
818 redirectChainLoading = loading
819 portScanLoading = loading
820 }
821
822 private func primaryIPAddress(from sections: [DNSSection]) -> String? {
823 sections.first(where: { $0.recordType == .A })?.records.first?.value
824 }
825
826 private func isCurrentLookup(_ lookupID: UUID) -> Bool {
827 activeLookupID == lookupID
828 }
829
830 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
831 [
832 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
833 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
834 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
835 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
836 SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
837 ]
838 }
839
840 static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
841 [
842 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
843 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
844 InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
845 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
846 ]
847 }
848
849 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
850 snapshot.dnsSections.map { section in
851 DNSRecordSectionViewData(
852 title: section.recordType.rawValue,
853 rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
854 wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) },
855 wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)",
856 message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ??
857 ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil)
858 )
859 }
860 }
861
862 static func dnssecLabel(from snapshot: LookupSnapshot) -> String? {
863 guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil }
864 return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")"
865 }
866
867 static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? {
868 if let ptrRecord = snapshot.ptrRecord {
869 return SectionMessageViewData(text: ptrRecord, isError: false)
870 }
871 if let ptrError = snapshot.ptrError {
872 return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found")
873 }
874 return nil
875 }
876
877 static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
878 guard let sslInfo = snapshot.sslInfo else { return [] }
879 var rows = [
880 InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
881 InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
882 InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary),
883 InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary),
884 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
885 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
886 ]
887 if let tlsVersion = sslInfo.tlsVersion {
888 rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary))
889 }
890 if let cipherSuite = sslInfo.cipherSuite {
891 rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary))
892 }
893 if let hstsPreloaded = snapshot.hstsPreloaded {
894 rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary))
895 }
896 return rows
897 }
898
899 static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
900 var rows: [InfoRowViewData] = []
901 if let httpStatusCode = snapshot.httpStatusCode {
902 rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary))
903 }
904 if let httpResponseTimeMs = snapshot.httpResponseTimeMs {
905 rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary))
906 }
907 if let httpProtocol = snapshot.httpProtocol {
908 rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary))
909 }
910 if let httpSecurityGrade = snapshot.httpSecurityGrade {
911 rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade)))
912 }
913 if snapshot.http3Advertised {
914 rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary))
915 }
916 return rows
917 }
918
919 static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] {
920 snapshot.redirectChain.map {
921 RedirectHopViewData(
922 stepLabel: "\($0.stepNumber)",
923 statusCode: "\($0.statusCode)",
924 url: $0.url,
925 isFinal: $0.isFinal
926 )
927 }
481928 }
482929
483 static func formatExportText(
484 domain: String,
485 date: Date,
486 dnsSections: [DNSSection],
487 sslInfo: SSLCertificateInfo?,
488 sslError: String? = nil,
489 hstsPreloaded: Bool? = nil,
490 httpHeaders: [HTTPHeader],
491 httpSecurityGrade: String? = nil,
492 httpStatusCode: Int? = nil,
493 httpResponseTimeMs: Int? = nil,
494 httpProtocol: String? = nil,
495 http3Advertised: Bool = false,
496 httpHeadersError: String? = nil,
497 reachabilityResults: [PortReachability],
498 ipGeolocation: IPGeolocation?,
499 ipGeolocationError: String? = nil,
500 emailSecurity: EmailSecurityResult? = nil,
501 ptrRecord: String? = nil,
502 redirectChain: [RedirectHop] = [],
503 portScanResults: [PortScanResult] = []
504 ) -> String {
505 let dateFmt = DateFormatter()
506 dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
930 static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
931 guard let emailSecurity = snapshot.emailSecurity else { return [] }
932 return [
933 EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil),
934 EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil),
935 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)" }),
936 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),
937 EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil)
938 ]
939 }
940
941 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
942 snapshot.reachabilityResults.map {
943 ReachabilityRowViewData(
944 portLabel: "Port \($0.port)",
945 latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
946 statusLabel: $0.reachable ? "Reachable" : "Unreachable",
947 statusTone: $0.reachable ? .success : .failure
948 )
949 }
950 }
951
952 static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
953 guard let ipGeolocation = snapshot.ipGeolocation else { return [] }
954 var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)]
955 if let org = ipGeolocation.org {
956 rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary))
957 }
958 let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ")
959 if !location.isEmpty {
960 rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary))
961 }
962 if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude {
963 rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary))
964 }
965 return rows
966 }
967
968 static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] {
969 snapshot.portScanResults
970 .filter { $0.kind == kind }
971 .map {
972 PortScanRowViewData(
973 portLabel: "\($0.port)",
974 service: $0.service,
975 statusLabel: $0.open ? "Open" : "Closed",
976 statusTone: $0.open ? .success : .secondary,
977 banner: $0.banner,
978 durationLabel: $0.durationMs.map { "\($0) ms" }
979 )
980 }
981 }
982
983 static func formatExportText(from snapshot: LookupSnapshot) -> String {
984 let exportDateFormatter = DateFormatter()
985 exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
507986
508987 var lines: [String] = [
509988 "DomainDig Export",
510 "Domain: \(domain)",
511 "Date: \(dateFmt.string(from: date))",
989 "Domain: \(snapshot.domain)",
990 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
991 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
992 "Resolver: \(snapshot.resolverDisplayName)",
993 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
512994 ]
513995
514 // Reachability
515 if !reachabilityResults.isEmpty {
996 func appendSection(_ title: String, body: () -> Void) {
516997 lines.append("")
517 lines.append("Reachability")
518 lines.append("------------")
519 for result in reachabilityResults {
520 if result.reachable, let ms = result.latencyMs {
521 lines.append(" Port \(result.port) \(ms)ms Reachable")
522 } else {
523 lines.append(" Port \(result.port) — Unreachable")
524 }
998 lines.append(title)
999 lines.append(String(repeating: "-", count: title.count))
1000 body()
1001 }
1002
1003 appendSection("Summary") {
1004 for item in summaryFields(from: snapshot) {
1005 lines.append(" \(item.label): \(item.value)")
5251006 }
5261007 }
5271008
528 // Redirect Chain
529 if !redirectChain.isEmpty {
530 lines.append("")
531 lines.append("Redirect Chain")
532 lines.append("--------------")
533 if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
534 lines.append(" No redirects — direct connection")
535 } else {
536 for hop in redirectChain {
537 let final = hop.isFinal ? " (final)" : ""
538 lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)")
539 }
1009 appendSection("Domain") {
1010 for row in domainRows(from: snapshot) {
1011 lines.append(" \(row.label): \(row.value)")
5401012 }
5411013 }
5421014
543 // DNS
544 lines.append("")
545 lines.append("DNS Records")
546 lines.append("-----------")
547 for section in dnsSections {
548 lines.append(section.recordType.rawValue)
549 if let error = section.error {
550 lines.append(" Error: \(error)")
551 } else if section.records.isEmpty {
552 lines.append(" No records found")
553 } else {
554 for record in section.records {
555 lines.append(" \(record.value) TTL \(record.ttl)")
556 }
1015 appendSection("DNS") {
1016 if let dnsError = snapshot.dnsError {
1017 lines.append(" Error: \(dnsError)")
1018 }
1019 if let dnssecLabel = dnssecLabel(from: snapshot) {
1020 lines.append(" \(dnssecLabel)")
5571021 }
558 if !section.wildcardRecords.isEmpty {
559 lines.append("*.\(domain)")
560 for record in section.wildcardRecords {
561 lines.append(" \(record.value) TTL \(record.ttl)")
1022 for section in dnsRows(from: snapshot) {
1023 lines.append(" \(section.title)")
1024 if let message = section.message {
1025 lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
5621026 }
1027 for row in section.rows {
1028 lines.append(" \(row.value) (\(row.label))")
1029 }
1030 if let wildcardTitle = section.wildcardTitle {
1031 lines.append(" \(wildcardTitle)")
1032 for row in section.wildcardRows {
1033 lines.append(" \(row.value) (\(row.label))")
1034 }
1035 }
1036 }
1037 if let ptrRecord = snapshot.ptrRecord {
1038 lines.append(" PTR: \(ptrRecord)")
1039 } else if let ptrError = snapshot.ptrError {
1040 lines.append(" PTR Error: \(ptrError)")
5631041 }
5641042 }
5651043
566 // PTR
567 if let ptr = ptrRecord {
568 lines.append("PTR (Reverse DNS)")
569 lines.append(" \(ptr)")
570 }
1044 appendSection("Web") {
1045 if let sslError = snapshot.sslError {
1046 lines.append(" TLS Error: \(sslError)")
1047 } else {
1048 for row in webCertificateRows(from: snapshot) {
1049 lines.append(" \(row.label): \(row.value)")
1050 }
1051 }
5711052
572 // Email Security
573 if let email = emailSecurity {
574 lines.append("")
575 lines.append("Email Security")
576 lines.append("--------------")
577 lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
578 lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
579 let dkimValue = if let selector = email.dkim.matchedSelector,
580 let value = email.dkim.value {
581 "\(value) (selector: \(selector))"
1053 if let httpHeadersError = snapshot.httpHeadersError {
1054 lines.append(" Headers Error: \(httpHeadersError)")
5821055 } else {
583 email.dkim.value ?? "No record found"
1056 for row in webResponseRows(from: snapshot) {
1057 lines.append(" \(row.label): \(row.value)")
1058 }
1059 if snapshot.httpHeaders.isEmpty {
1060 lines.append(" Headers: No headers returned")
1061 } else {
1062 lines.append(" Headers:")
1063 for header in snapshot.httpHeaders {
1064 lines.append(" \(header.name): \(header.value)")
1065 }
1066 }
5841067 }
585 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)")
586 let mtaDescription = if let mode = email.mtaSts?.policyMode {
587 "mode: \(mode)"
588 } else if email.mtaSts?.txtFound == true {
589 "Policy unavailable"
1068
1069 if let redirectChainError = snapshot.redirectChainError {
1070 lines.append(" Redirect Error: \(redirectChainError)")
1071 } else if snapshot.redirectChain.isEmpty {
1072 lines.append(" Redirects: No redirect data available")
5901073 } else {
591 "No record found"
1074 lines.append(" Redirects:")
1075 for hop in redirectRows(from: snapshot) {
1076 lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
1077 }
5921078 }
593 lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)")
594 lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")")
5951079 }
5961080
597 // SSL
598 if let info = sslInfo {
599 let certDateFmt = DateFormatter()
600 certDateFmt.dateStyle = .medium
601 certDateFmt.timeStyle = .none
602
603 lines.append("")
604 lines.append("SSL / TLS Certificate")
605 lines.append("---------------------")
606 lines.append("Common Name: \(info.commonName)")
607 lines.append("Issuer: \(info.issuer)")
608 lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))")
609 lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))")
610 lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
611 lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
612 lines.append("Chain Depth: \(info.chainDepth)")
613 if let tlsVersion = info.tlsVersion {
614 lines.append("TLS Version: \(tlsVersion)")
615 }
616 if let cipherSuite = info.cipherSuite {
617 lines.append("Cipher Suite: \(cipherSuite)")
618 }
619 if let hstsPreloaded {
620 lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")")
621 }
622 if !info.chain.isEmpty {
623 lines.append("Certificate Chain:")
624 for certificate in info.chain {
625 lines.append(" Subject: \(certificate.subject)")
626 lines.append(" Issuer: \(certificate.issuer)")
1081 appendSection("Email") {
1082 if let emailSecurityError = snapshot.emailSecurityError {
1083 lines.append(" Error: \(emailSecurityError)")
1084 } else if emailRows(from: snapshot).isEmpty {
1085 lines.append(" No email security records found")
1086 } else {
1087 for row in emailRows(from: snapshot) {
1088 lines.append(" \(row.label): \(row.status)")
1089 lines.append(" \(row.detail)")
1090 if let auxiliaryDetail = row.auxiliaryDetail {
1091 lines.append(" \(auxiliaryDetail)")
1092 }
6271093 }
6281094 }
629 } else if let error = sslError {
630 lines.append("")
631 lines.append("SSL / TLS Certificate")
632 lines.append("---------------------")
633 lines.append("Error: \(error)")
6341095 }
6351096
636 // HTTP Headers
637 if !httpHeaders.isEmpty {
638 lines.append("")
639 lines.append("HTTP Headers")
640 lines.append("------------")
641 for header in httpHeaders {
642 lines.append(" \(header.name): \(header.value)")
643 }
644 if let httpSecurityGrade {
645 lines.append("Grade: \(httpSecurityGrade)")
646 }
647 if let httpStatusCode {
648 lines.append("Status: \(httpStatusCode)")
649 }
650 if let httpResponseTimeMs {
651 lines.append("Response Time: \(httpResponseTimeMs)ms")
652 }
653 if let httpProtocol {
654 lines.append("Protocol: \(httpProtocol)")
1097 appendSection("Network") {
1098 if let reachabilityError = snapshot.reachabilityError {
1099 lines.append(" Reachability Error: \(reachabilityError)")
1100 } else if reachabilityRows(from: snapshot).isEmpty {
1101 lines.append(" Reachability: No results")
1102 } else {
1103 lines.append(" Reachability:")
1104 for row in reachabilityRows(from: snapshot) {
1105 lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
1106 }
6551107 }
656 if http3Advertised {
657 lines.append("HTTP/3 Advertised: Yes")
1108
1109 if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil {
1110 lines.append(" Location Error: \(ipGeolocationError)")
1111 } else if locationRows(from: snapshot).isEmpty {
1112 lines.append(" Location: No data")
1113 } else {
1114 lines.append(" Location:")
1115 for row in locationRows(from: snapshot) {
1116 lines.append(" \(row.label): \(row.value)")
1117 }
6581118 }
659 } else if let error = httpHeadersError {
660 lines.append("")
661 lines.append("HTTP Headers")
662 lines.append("------------")
663 lines.append("Error: \(error)")
664 }
6651119
666 // IP Geolocation
667 if let geo = ipGeolocation {
668 lines.append("")
669 lines.append("IP Location")
670 lines.append("-----------")
671 lines.append("IP: \(geo.ip)")
672 if let org = geo.org { lines.append("Org: \(org)") }
673 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
674 if !location.isEmpty { lines.append("Location: \(location)") }
675 if let lat = geo.latitude, let lon = geo.longitude {
676 lines.append("Coordinates: \(lat), \(lon)")
1120 if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
1121 lines.append(" Port Scan Error: \(portScanError)")
6771122 }
678 } else if let error = ipGeolocationError, error != "No A record available" {
679 lines.append("")
680 lines.append("IP Location")
681 lines.append("-----------")
682 lines.append("Error: \(error)")
683 }
6841123
685 // Open Ports
686 if !portScanResults.isEmpty {
687 lines.append("")
688 lines.append("Open Ports")
689 lines.append("----------")
690 let openPorts = portScanResults.filter { $0.open }
691 if openPorts.isEmpty {
692 lines.append(" No open ports detected")
1124 lines.append(" Standard Ports:")
1125 let standardRows = portRows(from: snapshot, kind: .standard)
1126 if standardRows.isEmpty {
1127 lines.append(" No results")
6931128 } else {
694 for port in openPorts {
695 let bannerSuffix = port.banner.map { " \($0)" } ?? ""
696 lines.append(" \(port.port) \(port.service)\(bannerSuffix)")
1129 for row in standardRows {
1130 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1131 if let banner = row.banner {
1132 lines.append(" Banner: \(banner)")
1133 }
6971134 }
6981135 }
699 let closedPorts = portScanResults.filter { !$0.open }
700 if !closedPorts.isEmpty {
701 lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
1136
1137 lines.append(" Custom Ports:")
1138 let customRows = portRows(from: snapshot, kind: .custom)
1139 if customRows.isEmpty {
1140 lines.append(" No results")
1141 } else {
1142 for row in customRows {
1143 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
1144 if let banner = row.banner {
1145 lines.append(" Banner: \(banner)")
1146 }
1147 }
7021148 }
7031149 }
7041150
7051151 return lines.joined(separator: "\n")
7061152 }
7071153
708 // MARK: - Recent Searches
1154 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
1155 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1156 }
7091157
710 private func addRecentSearch(_ domain: String) {
711 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
712 recentSearches.insert(domain, at: 0)
713 if recentSearches.count > Self.maxRecent {
714 recentSearches = Array(recentSearches.prefix(Self.maxRecent))
1158 private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
1159 snapshot.redirectChain.last?.url
1160 }
1161
1162 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
1163 if snapshot.sslInfo != nil {
1164 return "Valid"
7151165 }
716 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
1166 if let sslError = snapshot.sslError {
1167 return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed"
1168 }
1169 return "Unavailable"
7171170 }
7181171
719 func clearRecentSearches() {
720 recentSearches.removeAll()
721 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
1172 private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
1173 if snapshot.sslInfo != nil {
1174 return .success
1175 }
1176 return snapshot.sslError == nil ? .secondary : .failure
1177 }
1178
1179 private static func emailSummary(from snapshot: LookupSnapshot) -> String {
1180 guard let emailSecurity = snapshot.emailSecurity else {
1181 return snapshot.emailSecurityError ?? "Unavailable"
1182 }
1183 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
1184 }
1185
1186 private static func securityGradeTone(_ grade: String) -> ResultTone {
1187 switch grade {
1188 case "A", "B":
1189 return .success
1190 case "C":
1191 return .warning
1192 case "D", "F":
1193 return .failure
1194 default:
1195 return .secondary
1196 }
1197 }
1198
1199 private static func durationLabel(_ durationMs: Int?) -> String {
1200 durationMs.map { "\($0) ms" } ?? "Unavailable"
1201 }
1202}
1203
1204private extension String {
1205 var nonEmpty: String? {
1206 isEmpty ? nil : self
1207 }
1208
1209 var nilIfEmpty: String? {
1210 isEmpty ? nil : self
7221211 }
7231212}
DomainDig/EmailSecurityService.swift +5 −2
@@ -8,7 +8,7 @@ struct EmailSecurityService {
88
99 /// Analyze email security records. SPF is parsed from existing TXT records;
1010 /// DMARC and DKIM require additional DoH queries.
11 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
11 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> ServiceResult<EmailSecurityResult> {
1212 // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup
1313 // in case the earlier DNS section missed or normalized the record differently.
1414 let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value
@@ -49,13 +49,16 @@ struct EmailSecurityService {
4949 value: bimiValue
5050 )
5151
52 return EmailSecurityResult(
52 let result = EmailSecurityResult(
5353 spf: spf,
5454 dmarc: dmarc,
5555 dkim: dkim,
5656 bimi: bimi,
5757 mtaSts: mtaSts
5858 )
59
60 let hasAnyRecord = result.spf.found || result.dmarc.found || result.dkim.found || result.bimi.found || result.mtaSts?.txtFound == true
61 return hasAnyRecord ? .success(result) : .empty("No email security records found")
5962 }
6063
6164 /// Query a TXT record for the given subdomain via DoH.
DomainDig/HTTPHeadersService.swift +33 −28
@@ -35,41 +35,46 @@ struct HTTPHeadersResult {
3535}
3636
3737struct HTTPHeadersService {
38 static func fetch(domain: String) async throws -> HTTPHeadersResult {
38 static func fetch(domain: String) async -> ServiceResult<HTTPHeadersResult> {
3939 let url = URL(string: "https://\(domain)")!
4040 var request = URLRequest(url: url, timeoutInterval: 10)
4141 request.httpMethod = "HEAD"
4242 let metricsDelegate = TaskMetricsDelegate()
4343 let startTime = Date()
4444
45 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
46 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))
47
48 guard let httpResponse = response as? HTTPURLResponse else {
49 throw URLError(.badServerResponse)
50 }
51
52 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
53 guard let name = entry.key as? String,
54 let value = entry.value as? String else { return nil }
55 return HTTPHeader(name: name, value: value)
45 do {
46 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
47 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))
48
49 guard let httpResponse = response as? HTTPURLResponse else {
50 return .error(URLError(.badServerResponse).localizedDescription)
51 }
52
53 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
54 guard let name = entry.key as? String,
55 let value = entry.value as? String else { return nil }
56 return HTTPHeader(name: name, value: value)
57 }
58 .sorted { $0.name.lowercased() < $1.name.lowercased() }
59
60 let networkProtocolName = metricsDelegate.metrics?.transactionMetrics
61 .compactMap { $0.networkProtocolName }
62 .last
63 let detectedProtocol = protocolLabel(for: networkProtocolName)
64 let altSvcValue = headerValue(named: "alt-svc", in: httpResponse)
65 let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true
66 let result = HTTPHeadersResult(
67 headers: headers,
68 statusCode: httpResponse.statusCode,
69 responseTimeMs: responseTimeMs,
70 httpProtocol: detectedProtocol,
71 http3Advertised: http3Advertised
72 )
73
74 return headers.isEmpty ? .empty("No HTTP headers returned") : .success(result)
75 } catch {
76 return .error(error.localizedDescription)
5677 }
57 .sorted { $0.name.lowercased() < $1.name.lowercased() }
58
59 let networkProtocolName = metricsDelegate.metrics?.transactionMetrics
60 .compactMap { $0.networkProtocolName }
61 .last
62 let detectedProtocol = protocolLabel(for: networkProtocolName)
63 let altSvcValue = headerValue(named: "alt-svc", in: httpResponse)
64 let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true
65
66 return HTTPHeadersResult(
67 headers: headers,
68 statusCode: httpResponse.statusCode,
69 responseTimeMs: responseTimeMs,
70 httpProtocol: detectedProtocol,
71 http3Advertised: http3Advertised
72 )
7378 }
7479
7580 private static func protocolLabel(for networkProtocolName: String?) -> String? {
DomainDig/HistoryView.swift +93 −469
@@ -1,15 +1,13 @@
11import SwiftUI
2import MapKit
32
43struct HistoryView: View {
54 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
75
8 private let dateFmt: DateFormatter = {
9 let f = DateFormatter()
10 f.dateStyle = .medium
11 f.timeStyle = .short
12 return f
6 private let dateFormatter: DateFormatter = {
7 let formatter = DateFormatter()
8 formatter.dateStyle = .medium
9 formatter.timeStyle = .short
10 return formatter
1311 }()
1412
1513 var body: some View {
@@ -22,15 +20,22 @@ struct HistoryView: View {
2220 } else {
2321 ForEach(viewModel.history) { entry in
2422 NavigationLink {
25 HistoryDetailView(entry: entry)
23 HistoryDetailView(viewModel: viewModel, entry: entry)
2624 } label: {
27 VStack(alignment: .leading, spacing: 2) {
25 VStack(alignment: .leading, spacing: 4) {
2826 Text(entry.domain)
2927 .font(.system(.callout, design: .monospaced))
3028 .foregroundStyle(.primary)
31 Text(dateFmt.string(from: entry.timestamp))
32 .font(.system(.caption2, design: .monospaced))
33 .foregroundStyle(.secondary)
29 HStack(spacing: 8) {
30 Text(dateFormatter.string(from: entry.timestamp))
31 Text("Snapshot")
32 Text(entry.resolverDisplayName)
33 if let totalLookupDurationMs = entry.totalLookupDurationMs {
34 Text("\(totalLookupDurationMs) ms")
35 }
36 }
37 .font(.system(.caption2, design: .monospaced))
38 .foregroundStyle(.secondary)
3439 }
3540 }
3641 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -52,47 +57,102 @@ struct HistoryView: View {
5257 }
5358}
5459
55// MARK: - History Detail View (Read-Only Cached Results)
56
5760struct HistoryDetailView: View {
61 @Bindable var viewModel: DomainViewModel
5862 let entry: HistoryEntry
5963
60 private let dateFmt: DateFormatter = {
61 let f = DateFormatter()
62 f.dateStyle = .medium
63 f.timeStyle = .short
64 return f
64 private let dateFormatter: DateFormatter = {
65 let formatter = DateFormatter()
66 formatter.dateStyle = .medium
67 formatter.timeStyle = .short
68 return formatter
6569 }()
6670
71 private var snapshot: LookupSnapshot {
72 entry.snapshot
73 }
74
6775 var body: some View {
6876 ScrollView(.vertical) {
6977 VStack(alignment: .leading, spacing: 0) {
70 cachedBanner
71 reachabilitySection
72 redirectChainSection
73 dnsSection
74 emailSecuritySection
75 sslSection
76 httpHeadersSection
77 ipGeolocationSection
78 portScanSection
78 snapshotBanner
79 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
80 .padding(.top, 8)
81 DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot))
82 .padding(.top, 16)
83 DNSSectionView(
84 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
85 sections: DomainViewModel.dnsRows(from: snapshot),
86 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
87 loading: false,
88 sectionError: snapshot.dnsError
89 )
90 .padding(.top, 16)
91 WebSectionView(
92 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
93 sslInfo: snapshot.sslInfo,
94 sslLoading: false,
95 sslError: snapshot.sslError,
96 responseRows: DomainViewModel.webResponseRows(from: snapshot),
97 headers: snapshot.httpHeaders,
98 headersLoading: false,
99 headersError: snapshot.httpHeadersError,
100 redirects: DomainViewModel.redirectRows(from: snapshot),
101 redirectLoading: false,
102 redirectError: snapshot.redirectChainError,
103 finalURL: snapshot.redirectChain.last?.url
104 )
105 .padding(.top, 16)
106 EmailSectionView(
107 rows: DomainViewModel.emailRows(from: snapshot),
108 loading: false,
109 error: snapshot.emailSecurityError
110 )
111 .padding(.top, 16)
112 NetworkSectionView(
113 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
114 reachabilityLoading: false,
115 reachabilityError: snapshot.reachabilityError,
116 locationRows: DomainViewModel.locationRows(from: snapshot),
117 geolocation: snapshot.ipGeolocation,
118 geolocationLoading: false,
119 geolocationError: snapshot.ipGeolocationError,
120 standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard),
121 customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom),
122 portScanLoading: false,
123 portScanError: snapshot.portScanError,
124 customPortScanLoading: false,
125 customPortScanError: nil,
126 isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }),
127 customPortsExpanded: .constant(false),
128 customPortInput: .constant(""),
129 onScanCustomPorts: {}
130 )
131 .padding(.top, 16)
79132 }
80133 .padding(.horizontal)
81134 .padding(.bottom, 32)
82135 }
83136 .background(Color.black)
84137 .navigationTitle(entry.domain)
138 .toolbar {
139 Button("Re-run") {
140 viewModel.rerunLookup(from: entry)
141 }
142 }
85143 .preferredColorScheme(.dark)
86144 }
87145
88 // MARK: - Cached Banner
89
90 private var cachedBanner: some View {
91 HStack(spacing: 6) {
146 private var snapshotBanner: some View {
147 HStack(spacing: 8) {
92148 Image(systemName: "archivebox")
93149 .font(.caption)
94 Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
150 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
95151 .font(.system(.caption, design: .monospaced))
152 Spacer()
153 Text("Live re-run available")
154 .font(.system(.caption2, design: .monospaced))
155 .foregroundStyle(.secondary)
96156 }
97157 .foregroundStyle(.secondary)
98158 .padding(8)
@@ -101,440 +161,4 @@ struct HistoryDetailView: View {
101161 .cornerRadius(6)
102162 .padding(.vertical, 12)
103163 }
104
105 // MARK: - Reachability
106
107 private var reachabilitySection: some View {
108 VStack(alignment: .leading, spacing: 12) {
109 if !entry.reachabilityResults.isEmpty {
110 sectionHeader("Reachability")
111 VStack(alignment: .leading, spacing: 4) {
112 ForEach(entry.reachabilityResults) { result in
113 HStack(spacing: 8) {
114 Circle()
115 .fill(result.reachable ? Color.green : Color.red)
116 .frame(width: 8, height: 8)
117 Text("Port \(result.port)")
118 .font(.system(.caption, design: .monospaced))
119 if result.reachable, let ms = result.latencyMs {
120 Text("\(ms)ms")
121 .font(.system(.caption, design: .monospaced))
122 .foregroundStyle(.secondary)
123 } else if !result.reachable {
124 Text("—")
125 .font(.system(.caption, design: .monospaced))
126 .foregroundStyle(.secondary)
127 }
128 Spacer()
129 Text(result.reachable ? "Reachable" : "Unreachable")
130 .font(.system(.caption, design: .monospaced))
131 .foregroundStyle(result.reachable ? .green : .red)
132 }
133 }
134 }
135 .padding(10)
136 .background(Color(.systemGray6).opacity(0.5))
137 .cornerRadius(6)
138 }
139 }
140 .padding(.top, 8)
141 }
142
143 // MARK: - Redirect Chain
144
145 private var redirectChainSection: some View {
146 VStack(alignment: .leading, spacing: 12) {
147 if !entry.redirectChain.isEmpty {
148 sectionHeader("Redirect Chain")
149 if entry.redirectChain.count == 1,
150 let only = entry.redirectChain.first,
151 only.isFinal, !(300...399).contains(only.statusCode) {
152 Text("No redirects — direct connection")
153 .font(.system(.caption, design: .monospaced))
154 .foregroundStyle(.secondary)
155 .padding(10)
156 .frame(maxWidth: .infinity, alignment: .leading)
157 .background(Color(.systemGray6).opacity(0.5))
158 .cornerRadius(6)
159 } else {
160 horizontallyScrollableCard {
161 ForEach(entry.redirectChain) { hop in
162 HStack(alignment: .top, spacing: 6) {
163 Text("\(hop.stepNumber)")
164 .font(.system(.caption, design: .monospaced))
165 .foregroundStyle(.secondary)
166 .frame(width: 16, alignment: .trailing)
167 Text("\(hop.statusCode)")
168 .font(.system(.caption, design: .monospaced))
169 .foregroundStyle(.cyan)
170 .frame(width: 30, alignment: .leading)
171 Text(hop.url)
172 .font(.system(.caption, design: .monospaced))
173 .foregroundStyle(.primary)
174 .textSelection(.enabled)
175 if hop.isFinal {
176 Text("(final)")
177 .font(.system(.caption2, design: .monospaced))
178 .foregroundStyle(.secondary)
179 }
180 }
181 }
182 }
183 }
184 }
185 }
186 .padding(.top, 16)
187 }
188
189 // MARK: - DNS
190
191 private var dnsSection: some View {
192 VStack(alignment: .leading, spacing: 12) {
193 sectionHeader("DNS Records")
194 ForEach(entry.dnsSections) { section in
195 horizontallyScrollableCard {
196 Text(section.recordType.rawValue)
197 .font(.system(.subheadline, design: .monospaced))
198 .fontWeight(.semibold)
199 .foregroundStyle(.cyan)
200
201 if let error = section.error {
202 errorLabel(error)
203 } else if section.records.isEmpty {
204 Text("No records found")
205 .font(.system(.caption, design: .monospaced))
206 .foregroundStyle(.secondary)
207 } else {
208 recordRows(section.records)
209 }
210
211 if !section.wildcardRecords.isEmpty {
212 Text("*.\(entry.domain)")
213 .font(.system(.caption, design: .monospaced))
214 .fontWeight(.medium)
215 .foregroundStyle(.cyan.opacity(0.7))
216 .padding(.top, 4)
217 recordRows(section.wildcardRecords)
218 }
219 }
220
221 if section.recordType == .A {
222 horizontallyScrollableCard {
223 Text("PTR (Reverse DNS)")
224 .font(.system(.subheadline, design: .monospaced))
225 .fontWeight(.semibold)
226 .foregroundStyle(.cyan)
227
228 if let ptr = entry.ptrRecord {
229 Text(ptr)
230 .font(.system(.caption, design: .monospaced))
231 .foregroundStyle(.primary)
232 .textSelection(.enabled)
233 } else {
234 Text("No PTR record found")
235 .font(.system(.caption, design: .monospaced))
236 .foregroundStyle(.secondary)
237 }
238 }
239 }
240 }
241 }
242 .padding(.top, 16)
243 }
244
245 // MARK: - Email Security
246
247 @State private var expandedEmailField: String?
248
249 private var emailSecuritySection: some View {
250 VStack(alignment: .leading, spacing: 12) {
251 if let email = entry.emailSecurity {
252 sectionHeader("Email Security")
253 horizontallyScrollableCard(spacing: 6) {
254 historyEmailRow("SPF", record: email.spf)
255 historyEmailRow("DMARC", record: email.dmarc)
256 historyEmailRow("DKIM", record: email.dkim)
257 historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts)
258 historyEmailRow("BIMI", record: email.bimi)
259 }
260 }
261 }
262 .frame(maxWidth: .infinity, alignment: .leading)
263 .padding(.top, 16)
264 }
265
266 private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
267 VStack(alignment: .leading, spacing: 2) {
268 HStack(spacing: 8) {
269 Text(label)
270 .font(.system(.caption, design: .monospaced))
271 .fontWeight(.semibold)
272 .frame(width: 72, alignment: .leading)
273 Text(record.found ? "✓" : "✗")
274 .font(.system(.caption, design: .monospaced))
275 .foregroundStyle(record.found ? .green : .red)
276 if let value = record.value {
277 let isExpanded = expandedEmailField == label
278 let displayValue = isExpanded ? value : String(value.prefix(80))
279 Text(displayValue)
280 .font(.system(.caption2, design: .monospaced))
281 .foregroundStyle(.primary)
282 .textSelection(.enabled)
283 .lineLimit(isExpanded ? nil : 1)
284 .onTapGesture {
285 withAnimation {
286 expandedEmailField = isExpanded ? nil : label
287 }
288 }
289 if let selector = record.matchedSelector {
290 Text("(selector: \(selector))")
291 .font(.system(.caption2, design: .monospaced))
292 .foregroundStyle(.secondary)
293 }
294 } else {
295 Text("No record found")
296 .font(.system(.caption2, design: .monospaced))
297 .foregroundStyle(.secondary)
298 }
299 }
300 }
301 }
302
303 private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
304 VStack(alignment: .leading, spacing: 2) {
305 HStack(spacing: 8) {
306 Text(label)
307 .font(.system(.caption, design: .monospaced))
308 .fontWeight(.semibold)
309 .frame(width: 72, alignment: .leading)
310 Text(mtaSts?.txtFound == true ? "✓" : "✗")
311 .font(.system(.caption, design: .monospaced))
312 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
313 if let policyMode = mtaSts?.policyMode {
314 Text(policyMode)
315 .font(.system(.caption2, design: .monospaced))
316 .foregroundStyle(.primary)
317 .textSelection(.enabled)
318 } else {
319 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
320 .font(.system(.caption2, design: .monospaced))
321 .foregroundStyle(.secondary)
322 }
323 }
324 }
325 }
326
327 // MARK: - SSL
328
329 private var sslSection: some View {
330 VStack(alignment: .leading, spacing: 12) {
331 if let info = entry.sslInfo {
332 sectionHeader("SSL / TLS Certificate")
333 horizontallyScrollableCard(spacing: 8) {
334 labelRow("Common Name", info.commonName)
335 labelRow("Issuer", info.issuer)
336
337 VStack(alignment: .leading, spacing: 2) {
338 Text("SANs")
339 .font(.system(.caption2, design: .monospaced))
340 .foregroundStyle(.secondary)
341 ForEach(info.subjectAltNames, id: \.self) { san in
342 Text(san)
343 .font(.system(.caption, design: .monospaced))
344 .textSelection(.enabled)
345 }
346 }
347
348 labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
349 labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
350
351 HStack {
352 Text("Days Until Expiry")
353 .font(.system(.caption2, design: .monospaced))
354 .foregroundStyle(.secondary)
355 Spacer()
356 Text("\(info.daysUntilExpiry)")
357 .font(.system(.caption, design: .monospaced))
358 .fontWeight(.bold)
359 .foregroundStyle(expiryColor(info.daysUntilExpiry))
360 }
361
362 labelRow("Chain Depth", "\(info.chainDepth)")
363 }
364 }
365 }
366 .padding(.top, 16)
367 }
368
369 // MARK: - HTTP Headers
370
371 private var httpHeadersSection: some View {
372 VStack(alignment: .leading, spacing: 12) {
373 if !entry.httpHeaders.isEmpty {
374 sectionHeader("HTTP Headers")
375 horizontallyScrollableCard {
376 ForEach(entry.httpHeaders) { header in
377 HStack(alignment: .top, spacing: 4) {
378 Text(header.name + ":")
379 .font(.system(.caption, design: .monospaced))
380 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
381 Text(header.value)
382 .font(.system(.caption, design: .monospaced))
383 .foregroundStyle(.primary)
384 .textSelection(.enabled)
385 }
386 }
387 }
388 }
389 }
390 .padding(.top, 16)
391 }
392
393 // MARK: - IP Geolocation
394
395 private var ipGeolocationSection: some View {
396 VStack(alignment: .leading, spacing: 12) {
397 if let geo = entry.ipGeolocation {
398 sectionHeader("IP Location")
399 VStack(alignment: .leading, spacing: 6) {
400 horizontallyScrollableContent(spacing: 6) {
401 labelRow("IP", geo.ip)
402 if let org = geo.org {
403 labelRow("Org / ISP", org)
404 }
405 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
406 if !location.isEmpty {
407 labelRow("Location", location)
408 }
409 }
410
411 if let lat = geo.latitude, let lon = geo.longitude {
412 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
413 Map(initialPosition: .region(MKCoordinateRegion(
414 center: coordinate,
415 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
416 ))) {
417 Marker(geo.ip, coordinate: coordinate)
418 }
419 .mapStyle(.standard)
420 .frame(maxWidth: .infinity)
421 .frame(height: 180)
422 .cornerRadius(8)
423 }
424 }
425 .padding(10)
426 .background(Color(.systemGray6).opacity(0.5))
427 .cornerRadius(6)
428 }
429 }
430 .padding(.top, 16)
431 }
432
433 // MARK: - Port Scan
434
435 private var portScanSection: some View {
436 VStack(alignment: .leading, spacing: 12) {
437 if !entry.portScanResults.isEmpty {
438 sectionHeader("Open Ports")
439 VStack(alignment: .leading, spacing: 4) {
440 ForEach(entry.portScanResults) { result in
441 HStack(spacing: 8) {
442 Circle()
443 .fill(result.open ? Color.green : Color(.systemGray4))
444 .frame(width: 8, height: 8)
445 Text("\(result.port)")
446 .font(.system(.caption, design: .monospaced))
447 .frame(width: 44, alignment: .leading)
448 Text(result.service)
449 .font(.system(.caption, design: .monospaced))
450 .foregroundStyle(result.open ? .primary : .secondary)
451 Spacer()
452 if result.open {
453 Text("Open")
454 .font(.system(.caption2, design: .monospaced))
455 .foregroundStyle(.green)
456 }
457 }
458 }
459 }
460 .padding(10)
461 .background(Color(.systemGray6).opacity(0.5))
462 .cornerRadius(6)
463 }
464 }
465 .padding(.top, 16)
466 }
467
468 // MARK: - Helpers
469
470 private func sectionHeader(_ title: String) -> some View {
471 Text(title)
472 .font(.system(.headline, design: .default))
473 .foregroundStyle(.white)
474 }
475
476 private func labelRow(_ label: String, _ value: String) -> some View {
477 VStack(alignment: .leading, spacing: 2) {
478 Text(label)
479 .font(.system(.caption2, design: .monospaced))
480 .foregroundStyle(.secondary)
481 Text(value)
482 .font(.system(.caption, design: .monospaced))
483 .textSelection(.enabled)
484 }
485 }
486
487 private func recordRows(_ records: [DNSRecord]) -> some View {
488 ForEach(records) { record in
489 HStack(alignment: .top) {
490 Text(record.value)
491 .font(.system(.caption, design: .monospaced))
492 .foregroundStyle(.primary)
493 .textSelection(.enabled)
494 Spacer()
495 Text("TTL \(record.ttl)")
496 .font(.system(.caption2, design: .monospaced))
497 .foregroundStyle(.secondary)
498 }
499 }
500 }
501
502 private func horizontallyScrollableCard<Content: View>(
503 spacing: CGFloat = 4,
504 @ViewBuilder content: () -> Content
505 ) -> some View {
506 horizontallyScrollableContent(spacing: spacing) {
507 content()
508 }
509 .padding(10)
510 .background(Color(.systemGray6).opacity(0.5))
511 .cornerRadius(6)
512 }
513
514 private func horizontallyScrollableContent<Content: View>(
515 spacing: CGFloat = 4,
516 @ViewBuilder content: () -> Content
517 ) -> some View {
518 ScrollView(.horizontal) {
519 VStack(alignment: .leading, spacing: spacing) {
520 content()
521 }
522 .scrollTargetLayout()
523 }
524 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
525 .frame(maxWidth: .infinity, alignment: .leading)
526 }
527
528 private func errorLabel(_ message: String) -> some View {
529 Label(message, systemImage: "exclamationmark.triangle.fill")
530 .font(.system(.caption, design: .monospaced))
531 .foregroundStyle(.red)
532 .padding(8)
533 }
534
535 private func expiryColor(_ days: Int) -> Color {
536 if days < 30 { return .red }
537 if days < 60 { return .yellow }
538 return .green
539 }
540164}
DomainDig/IPGeolocationService.swift +12 −7
@@ -1,17 +1,22 @@
11import Foundation
22
33struct IPGeolocationService {
4 static func lookup(ip: String) async throws -> IPGeolocation {
4 static func lookup(ip: String) async -> ServiceResult<IPGeolocation> {
55 let url = URL(string: "https://ipapi.co/\(ip)/json/")!
66 let request = URLRequest(url: url, timeoutInterval: 10)
77
8 let (data, response) = try await URLSession.shared.data(for: request)
8 do {
9 let (data, response) = try await URLSession.shared.data(for: request)
910
10 guard let httpResponse = response as? HTTPURLResponse,
11 httpResponse.statusCode == 200 else {
12 throw URLError(.badServerResponse)
13 }
11 guard let httpResponse = response as? HTTPURLResponse,
12 httpResponse.statusCode == 200 else {
13 return .error(URLError(.badServerResponse).localizedDescription)
14 }
1415
15 return try JSONDecoder().decode(IPGeolocation.self, from: data)
16 let geolocation = try JSONDecoder().decode(IPGeolocation.self, from: data)
17 return .success(geolocation)
18 } catch {
19 return .error(error.localizedDescription)
20 }
1621 }
1722}
DomainDig/Models.swift +66 −3
@@ -1,5 +1,11 @@
11import Foundation
22
3enum ServiceResult<Value> {
4 case success(Value)
5 case empty(String)
6 case error(String)
7}
8
39// MARK: - DNS Models
410
511enum DNSRecordType: String, CaseIterable, Codable {
@@ -13,6 +19,7 @@ enum DNSRecordType: String, CaseIterable, Codable {
1319 case SRV
1420 case CAA
1521 case DS
22 case PTR
1623
1724 var queryType: Int {
1825 switch self {
@@ -26,6 +33,7 @@ enum DNSRecordType: String, CaseIterable, Codable {
2633 case .SRV: return 33
2734 case .CAA: return 257
2835 case .DS: return 43
36 case .PTR: return 12
2937 }
3038 }
3139
@@ -223,18 +231,34 @@ struct RedirectHop: Identifiable, Codable {
223231
224232// MARK: - Port Scan Models
225233
234enum PortScanKind: String, Codable {
235 case standard
236 case custom
237}
238
226239struct PortScanResult: Identifiable, Codable {
227240 var id = UUID()
228241 let port: UInt16
229242 let service: String
230243 let open: Bool
231244 var banner: String?
232
233 nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) {
245 let kind: PortScanKind
246 let durationMs: Int?
247
248 nonisolated init(
249 port: UInt16,
250 service: String,
251 open: Bool,
252 banner: String? = nil,
253 kind: PortScanKind = .standard,
254 durationMs: Int? = nil
255 ) {
234256 self.port = port
235257 self.service = service
236258 self.open = open
237259 self.banner = banner
260 self.kind = kind
261 self.durationMs = durationMs
238262 }
239263
240264 init(from decoder: Decoder) throws {
@@ -244,6 +268,8 @@ struct PortScanResult: Identifiable, Codable {
244268 service = try container.decode(String.self, forKey: .service)
245269 open = try container.decode(Bool.self, forKey: .open)
246270 banner = try container.decodeIfPresent(String.self, forKey: .banner)
271 kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
272 durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
247273 }
248274}
249275
@@ -264,13 +290,28 @@ struct HistoryEntry: Identifiable, Codable {
264290 var redirectChain: [RedirectHop]
265291 var portScanResults: [PortScanResult]
266292 var hstsPreloaded: Bool?
293 var resolverDisplayName: String
294 var resolverURLString: String
295 var totalLookupDurationMs: Int?
296 var sslError: String?
297 var httpHeadersError: String?
298 var reachabilityError: String?
299 var ipGeolocationError: String?
300 var emailSecurityError: String?
301 var ptrError: String?
302 var redirectChainError: String?
303 var portScanError: String?
267304
268305 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
269306 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
270307 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
271308 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
272309 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
273 hstsPreloaded: Bool? = nil) {
310 hstsPreloaded: Bool? = nil, resolverDisplayName: String, resolverURLString: String,
311 totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
312 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
313 emailSecurityError: String? = nil, ptrError: String? = nil,
314 redirectChainError: String? = nil, portScanError: String? = nil) {
274315 self.domain = domain
275316 self.timestamp = timestamp
276317 self.dnsSections = dnsSections
@@ -284,6 +325,17 @@ struct HistoryEntry: Identifiable, Codable {
284325 self.redirectChain = redirectChain
285326 self.portScanResults = portScanResults
286327 self.hstsPreloaded = hstsPreloaded
328 self.resolverDisplayName = resolverDisplayName
329 self.resolverURLString = resolverURLString
330 self.totalLookupDurationMs = totalLookupDurationMs
331 self.sslError = sslError
332 self.httpHeadersError = httpHeadersError
333 self.reachabilityError = reachabilityError
334 self.ipGeolocationError = ipGeolocationError
335 self.emailSecurityError = emailSecurityError
336 self.ptrError = ptrError
337 self.redirectChainError = redirectChainError
338 self.portScanError = portScanError
287339 }
288340
289341 init(from decoder: Decoder) throws {
@@ -302,6 +354,17 @@ struct HistoryEntry: Identifiable, Codable {
302354 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
303355 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
304356 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
357 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
358 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
359 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
360 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
361 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
362 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
363 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
364 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
365 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
366 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
367 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
305368 }
306369}
307370
DomainDig/PortScanService.swift +41 −13
@@ -20,12 +20,18 @@ struct PortScanService {
2020 PortInfo(port: 8443, service: "HTTPS Alt"),
2121 ]
2222
23 static func scanAll(domain: String) async -> [PortScanResult] {
23 static func scanAll(domain: String) async -> ServiceResult<[PortScanResult]> {
2424 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
2525 for info in ports {
2626 group.addTask {
27 let open = await probe(domain: domain, port: info.port)
28 return PortScanResult(port: info.port, service: info.service, open: open)
27 let result = await probe(domain: domain, port: info.port)
28 return PortScanResult(
29 port: info.port,
30 service: info.service,
31 open: result.open,
32 kind: .standard,
33 durationMs: result.durationMs
34 )
2935 }
3036 }
3137
@@ -35,20 +41,24 @@ struct PortScanService {
3541 }
3642
3743 // Sort by port number
38 return results.sorted { $0.port < $1.port }
44 let sorted = results.sorted { $0.port < $1.port }
45 return sorted.isEmpty ? [] : sorted
3946 }
47 .pipe { $0.isEmpty ? .empty("No port scan results") : .success($0) }
4048 }
4149
42 static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> [PortScanResult] {
50 static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> ServiceResult<[PortScanResult]> {
4351 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
4452 for port in ports {
4553 let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom"
4654 group.addTask {
47 let open = await probe(domain: domain, port: port, timeout: timeout)
55 let result = await probe(domain: domain, port: port, timeout: timeout)
4856 return PortScanResult(
4957 port: port,
5058 service: service,
51 open: open
59 open: result.open,
60 kind: .custom,
61 durationMs: result.durationMs
5262 )
5363 }
5464 }
@@ -58,8 +68,10 @@ struct PortScanService {
5868 results.append(result)
5969 }
6070
61 return results.sorted { $0.port < $1.port }
71 let sorted = results.sorted { $0.port < $1.port }
72 return sorted.isEmpty ? [] : sorted
6273 }
74 .pipe { $0.isEmpty ? .empty("No custom port scan results") : .success($0) }
6375 }
6476
6577 static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? {
@@ -111,11 +123,11 @@ struct PortScanService {
111123 }
112124 }
113125
114 private static func probe(domain: String, port: UInt16) async -> Bool {
126 private static func probe(domain: String, port: UInt16) async -> PortProbeResult {
115127 await probe(domain: domain, port: port, timeout: 5)
116128 }
117129
118 private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> Bool {
130 private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult {
119131 await withCheckedContinuation { continuation in
120132 let host = NWEndpoint.Host(domain)
121133 let nwPort = NWEndpoint.Port(rawValue: port)!
@@ -143,13 +155,19 @@ struct PortScanService {
143155 }
144156}
145157
158private struct PortProbeResult: Sendable {
159 let open: Bool
160 let durationMs: Int?
161}
162
146163private final class ProbeContext: @unchecked Sendable {
147164 private let connection: NWConnection
148 private let continuation: CheckedContinuation<Bool, Never>
165 private let continuation: CheckedContinuation<PortProbeResult, Never>
166 private let start = CFAbsoluteTimeGetCurrent()
149167 private let lock = NSLock()
150168 private nonisolated(unsafe) var resumed = false
151169
152 init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) {
170 init(connection: NWConnection, continuation: CheckedContinuation<PortProbeResult, Never>) {
153171 self.connection = connection
154172 self.continuation = continuation
155173 }
@@ -164,7 +182,17 @@ private final class ProbeContext: @unchecked Sendable {
164182 lock.unlock()
165183
166184 connection.cancel()
167 continuation.resume(returning: open)
185 let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
186 continuation.resume(returning: PortProbeResult(
187 open: open,
188 durationMs: elapsedMs >= 0 ? elapsedMs : nil
189 ))
190 }
191}
192
193private extension Array {
194 func pipe<T>(_ transform: (Self) -> T) -> T {
195 transform(self)
168196 }
169197}
170198
DomainDig/ReachabilityService.swift +3 −2
@@ -29,10 +29,11 @@ struct ReachabilityService {
2929 }
3030 }
3131
32 static func checkAll(domain: String) async -> [PortReachability] {
32 static func checkAll(domain: String) async -> ServiceResult<[PortReachability]> {
3333 async let port443 = check(domain: domain, port: 443)
3434 async let port80 = check(domain: domain, port: 80)
35 return await [port443, port80]
35 let results = await [port443, port80]
36 return results.isEmpty ? .empty("No reachability results") : .success(results)
3637 }
3738}
3839
DomainDig/RedirectChainService.swift +9 −4
@@ -1,12 +1,17 @@
11import Foundation
22
33struct RedirectChainService {
4 static func trace(domain: String) async throws -> [RedirectHop] {
5 // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely
4 static func trace(domain: String) async -> ServiceResult<[RedirectHop]> {
65 do {
7 return try await followChain(startingURL: URL(string: "https://\(domain)")!)
6 let hops = try await followChain(startingURL: URL(string: "https://\(domain)")!)
7 return hops.isEmpty ? .empty("No redirect data available") : .success(hops)
88 } catch {
9 return try await followChain(startingURL: URL(string: "http://\(domain)")!)
9 do {
10 let hops = try await followChain(startingURL: URL(string: "http://\(domain)")!)
11 return hops.isEmpty ? .empty("No redirect data available") : .success(hops)
12 } catch {
13 return .error(error.localizedDescription)
14 }
1015 }
1116 }
1217
DomainDig/ReverseDNSService.swift +14 −35
@@ -1,48 +1,27 @@
11import Foundation
22
33struct ReverseDNSService {
4 /// Look up the PTR record for an IPv4 address via Cloudflare DoH.
5 static func lookup(ip: String) async -> String? {
4 static func lookup(ip: String, resolverURLString: String) async -> ServiceResult<String> {
65 let octets = ip.split(separator: ".")
7 guard octets.count == 4 else { return nil }
6 guard octets.count == 4 else {
7 return .error("Invalid IPv4 address")
8 }
89
910 let reversed = octets.reversed().joined(separator: ".")
1011 let ptrDomain = "\(reversed).in-addr.arpa"
1112
12 // PTR record type = 12
1313 do {
14 let records = try await lookupPTR(domain: ptrDomain)
15 return records.first
14 let records = try await DNSLookupService.lookup(
15 domain: ptrDomain,
16 recordType: .PTR,
17 resolverURLString: resolverURLString
18 )
19 if let record = records.first?.value {
20 return .success(record)
21 }
22 return .empty("No PTR record found")
1623 } catch {
17 return nil
24 return .error(error.localizedDescription)
1825 }
1926 }
20
21 private static func lookupPTR(domain: String) async throws -> [String] {
22 var components = URLComponents(string: "https://cloudflare-dns.com/dns-query")!
23 components.queryItems = [
24 URLQueryItem(name: "name", value: domain),
25 URLQueryItem(name: "type", value: "12") // PTR
26 ]
27
28 var request = URLRequest(url: components.url!)
29 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
30
31 let (data, response) = try await URLSession.shared.data(for: request)
32
33 guard let httpResponse = response as? HTTPURLResponse,
34 httpResponse.statusCode == 200 else {
35 throw URLError(.badServerResponse)
36 }
37
38 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
39
40 guard let answers = dnsResponse.Answer else {
41 return []
42 }
43
44 return answers
45 .filter { $0.type == 12 }
46 .map { $0.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
47 }
4827}
DomainDig/SSLCheckService.swift +10 −7
@@ -3,7 +3,7 @@ import Security
33
44struct SSLCheckService {
55
6 static func check(domain: String) async throws -> SSLCertificateInfo {
6 static func check(domain: String) async -> ServiceResult<SSLCertificateInfo> {
77 let delegate = SSLSessionDelegate()
88 let session = URLSession(
99 configuration: .ephemeral,
@@ -15,14 +15,17 @@ struct SSLCheckService {
1515 let url = URL(string: "https://\(domain)")!
1616 let request = URLRequest(url: url, timeoutInterval: 10)
1717
18 // We only need to establish the connection to grab the cert
19 _ = try await session.data(for: request)
18 do {
19 _ = try await session.data(for: request)
2020
21 guard let trust = delegate.serverTrust else {
22 throw SSLError.noCertificate
23 }
21 guard let trust = delegate.serverTrust else {
22 return .empty(SSLError.noCertificate.localizedDescription)
23 }
2424
25 return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)
25 return .success(try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata))
26 } catch {
27 return .error(error.localizedDescription)
28 }
2629 }
2730
2831 static func checkHSTSPreload(domain: String) async -> Bool? {