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
2import MapKit 1import MapKit
2import SwiftUI
3 3
4struct ContentView: View { 4struct ContentView: View {
5 @State private var viewModel = DomainViewModel() 5 @State private var viewModel = DomainViewModel()
@@ -14,14 +14,59 @@ struct ContentView: View {
14 inputSection 14 inputSection
15 if viewModel.hasRun { 15 if viewModel.hasRun {
16 actionButtons 16 actionButtons
17 reachabilitySection 17 SummaryView(fields: viewModel.summaryFields)
18 redirectChainSection 18 .padding(.top, 8)
19 dnsResultsSection 19 DomainSectionView(rows: viewModel.domainRows)
20 emailSecuritySection 20 .padding(.top, 16)
21 sslResultsSection 21 DNSSectionView(
22 httpHeadersSection 22 dnssecLabel: viewModel.dnssecLabel,
23 ipGeolocationSection 23 sections: viewModel.dnsRows,
24 portScanSection 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)
25 } else if !viewModel.recentSearches.isEmpty { 70 } else if !viewModel.recentSearches.isEmpty {
26 recentSearchesSection 71 recentSearchesSection
27 } 72 }
@@ -55,8 +100,6 @@ struct ContentView: View {
55 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") 100 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
56 .foregroundStyle(.secondary) 101 .foregroundStyle(.secondary)
57 } 102 }
58 }
59 ToolbarItem(placement: .topBarTrailing) {
60 NavigationLink { 103 NavigationLink {
61 SettingsView() 104 SettingsView()
62 } label: { 105 } label: {
@@ -71,8 +114,6 @@ struct ContentView: View {
71 } 114 }
72 } 115 }
73 116
74 // MARK: - Input
75
76 private var inputSection: some View { 117 private var inputSection: some View {
77 VStack(spacing: 12) { 118 VStack(spacing: 12) {
78 TextField("e.g. cleberg.net", text: $viewModel.domain) 119 TextField("e.g. cleberg.net", text: $viewModel.domain)
@@ -101,8 +142,6 @@ struct ContentView: View {
101 .padding(.vertical, 16) 142 .padding(.vertical, 16)
102 } 143 }
103 144
104 // MARK: - Action Buttons (Share + Bookmark)
105
106 private var actionButtons: some View { 145 private var actionButtons: some View {
107 HStack { 146 HStack {
108 Spacer() 147 Spacer()
@@ -123,11 +162,8 @@ struct ContentView: View {
123 } 162 }
124 } 163 }
125 } 164 }
126 .padding(.top, 8)
127 } 165 }
128 166
129 // MARK: - Recent Searches
130
131 private var recentSearchesSection: some View { 167 private var recentSearchesSection: some View {
132 VStack(alignment: .leading, spacing: 8) { 168 VStack(alignment: .leading, spacing: 8) {
133 HStack { 169 HStack {
@@ -162,778 +198,605 @@ struct ContentView: View {
162 .padding(.top, 8) 198 .padding(.top, 8)
163 } 199 }
164 200
165 // MARK: - Reachability 201 private func runCustomPortScan() {
166 202 let ports = parsedCustomPorts(from: customPortInput)
167 private var reachabilitySection: some View { 203 Task {
168 VStack(alignment: .leading, spacing: 12) { 204 await viewModel.runCustomPortScan(ports: ports)
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 }
206 } 205 }
207 .padding(.top, 8)
208 } 206 }
209 207
210 // MARK: - Redirect Chain 208 private func parsedCustomPorts(from input: String) -> [UInt16] {
211 209 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
212 private var redirectChainSection: some View { 210 var seen = Set<UInt16>()
213 VStack(alignment: .leading, spacing: 12) { 211 var ports: [UInt16] = []
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
267 212
268 private var dnsResultsSection: some View { 213 for part in parts {
269 VStack(alignment: .leading, spacing: 12) { 214 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
270 HStack(alignment: .top, spacing: 8) { 215 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
271 sectionHeader("DNS Records") 216 continue
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 }
279 } 217 }
280 218 ports.append(value)
281 if viewModel.dnsLoading { 219 if ports.count == 20 {
282 ProgressView("Querying DNS…") 220 break
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 }
294 } 221 }
295 } 222 }
296 .padding(.top, 16)
297 }
298 223
299 private var ptrRow: some View { 224 return ports
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 }
321 } 225 }
322 226
323 private func dnsRecordSection(_ section: DNSSection) -> some View { 227 private func shareResults() {
324 horizontallyScrollableCard { 228 let text = viewModel.exportText()
325 Text(section.recordType.rawValue) 229 let dateFmt = DateFormatter()
326 .font(.system(.subheadline, design: .monospaced)) 230 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
327 .fontWeight(.semibold) 231 let timestamp = dateFmt.string(from: Date())
328 .foregroundStyle(.cyan) 232 let filename = "\(timestamp)_domaindigresults.txt"
329 233 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
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)
346 234
347 dnsRecordRows(section.wildcardRecords) 235 do {
348 } 236 try text.write(to: tempURL, atomically: true, encoding: .utf8)
237 } catch {
238 return
349 } 239 }
350 }
351 240
352 private func dnsRecordRows(_ records: [DNSRecord]) -> some View { 241 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
353 ForEach(records) { record in 242 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
354 HStack(alignment: .top) { 243 let rootVC = windowScene.keyWindow?.rootViewController else { return }
355 Text(record.value) 244 var presenter = rootVC
356 .font(.system(.caption, design: .monospaced)) 245 while let presented = presenter.presentedViewController {
357 .foregroundStyle(.primary) 246 presenter = presented
358 .textSelection(.enabled)
359 Spacer()
360 Text("TTL \(record.ttl)")
361 .font(.system(.caption2, design: .monospaced))
362 .foregroundStyle(.secondary)
363 }
364 } 247 }
248 activityVC.popoverPresentationController?.sourceView = presenter.view
249 presenter.present(activityVC, animated: true)
365 } 250 }
251}
366 252
367 // MARK: - Email Security 253struct SummaryView: View {
368 254 let fields: [SummaryFieldViewData]
369 @State private var expandedEmailField: String?
370 255
371 private var emailSecuritySection: some View { 256 var body: some View {
372 VStack(alignment: .leading, spacing: 12) { 257 VStack(alignment: .leading, spacing: 12) {
373 sectionHeader("Email Security") 258 SectionTitleView(title: "Summary")
374 259 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
375 if viewModel.emailSecurityLoading { 260 ForEach(fields) { field in
376 ProgressView("Checking email records…") 261 VStack(alignment: .leading, spacing: 4) {
377 .frame(maxWidth: .infinity, alignment: .center) 262 Text(field.label)
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))")
420 .font(.system(.caption2, design: .monospaced)) 263 .font(.system(.caption2, design: .monospaced))
421 .foregroundStyle(.secondary) 264 .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)
422 } 270 }
423 } else { 271 .frame(maxWidth: .infinity, alignment: .leading)
424 Text("No record found") 272 .padding(10)
425 .font(.system(.caption2, design: .monospaced)) 273 .background(Color(.systemGray6).opacity(0.5))
426 .foregroundStyle(.secondary) 274 .cornerRadius(6)
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)
451 } 275 }
452 } 276 }
453 } 277 }
454 } 278 }
279}
455 280
456 // MARK: - SSL Results 281struct DomainSectionView: View {
282 let rows: [InfoRowViewData]
457 283
458 private var sslResultsSection: some View { 284 var body: some View {
459 VStack(alignment: .leading, spacing: 12) { 285 VStack(alignment: .leading, spacing: 12) {
460 sectionHeader("SSL / TLS Certificate") 286 SectionTitleView(title: "Domain")
461 287 CardView {
462 if viewModel.sslLoading { 288 ForEach(rows) { row in
463 ProgressView("Checking certificate…") 289 LabeledValueRow(row: row)
464 .frame(maxWidth: .infinity, alignment: .center) 290 }
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)
470 } 291 }
471 } 292 }
472 .padding(.top, 16)
473 } 293 }
294}
474 295
475 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View { 296struct DNSSectionView: View {
476 horizontallyScrollableCard(spacing: 8) { 297 let dnssecLabel: String?
477 certRow("Common Name", info.commonName) 298 let sections: [DNSRecordSectionViewData]
478 certRow("Issuer", info.issuer) 299 let ptrMessage: SectionMessageViewData?
300 let loading: Bool
301 let sectionError: String?
479 302
480 VStack(alignment: .leading, spacing: 2) { 303 var body: some View {
481 Text("SANs") 304 VStack(alignment: .leading, spacing: 12) {
482 .font(.system(.caption2, design: .monospaced)) 305 HStack(alignment: .top, spacing: 8) {
483 .foregroundStyle(.secondary) 306 SectionTitleView(title: "DNS")
484 ForEach(info.subjectAltNames, id: \.self) { san in 307 Spacer()
485 Text(san) 308 if let dnssecLabel {
486 .font(.system(.caption, design: .monospaced)) 309 Text(dnssecLabel)
487 .textSelection(.enabled) 310 .font(.system(.caption2, design: .monospaced))
311 .foregroundStyle(.secondary)
312 .multilineTextAlignment(.trailing)
488 } 313 }
489 } 314 }
490 315
491 let formatter = DateFormatter.certDate 316 if loading {
492 certRow("Valid From", formatter.string(from: info.validFrom)) 317 LoadingCardView(text: "Querying DNS…")
493 certRow("Valid Until", formatter.string(from: info.validUntil)) 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 }
494 331
495 VStack(alignment: .leading, spacing: 2) { 332 ForEach(section.rows) { row in
496 Text("Days Until Expiry") 333 LabeledValueRow(row: row)
497 .font(.system(.caption2, design: .monospaced)) 334 }
498 .foregroundStyle(.secondary)
499 Text("\(info.daysUntilExpiry)")
500 .font(.system(.caption, design: .monospaced))
501 .fontWeight(.bold)
502 .foregroundStyle(expiryColor(info.daysUntilExpiry))
503 }
504 335
505 certRow("Chain Depth", "\(info.chainDepth)") 336 if let wildcardTitle = section.wildcardTitle {
506 if viewModel.hstsLoading { 337 Text(wildcardTitle)
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)
525 .font(.system(.caption, design: .monospaced)) 338 .font(.system(.caption, design: .monospaced))
526 .foregroundStyle(.secondary) 339 .foregroundStyle(.secondary)
527 .textSelection(.enabled) 340 .padding(.top, 4)
528 } label: { 341 ForEach(section.wildcardRows) { row in
529 Text(certificate.subject) 342 LabeledValueRow(row: row)
530 .font(.system(.caption, design: .monospaced)) 343 }
531 .foregroundStyle(.primary)
532 .textSelection(.enabled)
533 } 344 }
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)
535 } 355 }
536 } 356 }
537 } 357 }
538 Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
539 .font(.system(.caption, design: .monospaced))
540 .foregroundStyle(.cyan)
541 } 358 }
542 } 359 }
360}
543 361
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?
545 375
546 private var httpHeadersSection: some View { 376 var body: some View {
547 VStack(alignment: .leading, spacing: 12) { 377 VStack(alignment: .leading, spacing: 12) {
548 HStack(spacing: 8) { 378 SectionTitleView(title: "Web")
549 sectionHeader("HTTP Headers") 379
550 if let grade = viewModel.httpSecurityGrade { 380 CardView {
551 Text(grade) 381 Text("TLS")
552 .font(.system(.caption, design: .monospaced)) 382 .font(.system(.subheadline, design: .monospaced))
553 .foregroundStyle(httpSecurityGradeColor(for: grade)) 383 .fontWeight(.semibold)
554 .padding(.horizontal, 8) 384 .foregroundStyle(.cyan)
555 .padding(.vertical, 2) 385 if sslLoading {
556 .background(httpSecurityGradeColor(for: grade).opacity(0.18)) 386 ProgressView("Checking certificate…")
557 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 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 }
558 } 403 }
559 Spacer()
560 } 404 }
561 405
562 if viewModel.httpHeadersLoading { 406 CardView {
563 ProgressView("Fetching headers…") 407 Text("Headers")
564 .frame(maxWidth: .infinity, alignment: .center) 408 .font(.system(.subheadline, design: .monospaced))
565 .padding() 409 .fontWeight(.semibold)
566 } else if let error = viewModel.httpHeadersError { 410 .foregroundStyle(.cyan)
567 errorLabel(error) 411 if headersLoading {
568 } else { 412 ProgressView("Fetching headers…")
569 horizontallyScrollableCard { 413 } else if let headersError {
570 if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil { 414 MessageRowView(text: headersError, isError: true)
571 HStack(alignment: .top, spacing: 0) { 415 } else {
572 ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in 416 ForEach(responseRows) { row in
573 if index > 0 { 417 LabeledValueRow(row: row)
574 Text(" ") 418 }
575 .font(.system(.caption, design: .monospaced)) 419 if headers.isEmpty {
576 } 420 MessageRowView(text: "No HTTP headers returned", isError: false)
577 Text(part.text) 421 } else {
578 .font(.system(.caption, design: .monospaced)) 422 ForEach(headers) { header in
579 .foregroundStyle(part.color) 423 HStack(alignment: .top, spacing: 4) {
580 } 424 Text(header.name + ":")
581 if let http3AvailabilityNote {
582 Text(" ")
583 .font(.system(.caption, design: .monospaced)) 425 .font(.system(.caption, design: .monospaced))
584 Text(http3AvailabilityNote) 426 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
427 Text(header.value)
585 .font(.system(.caption, design: .monospaced)) 428 .font(.system(.caption, design: .monospaced))
586 .foregroundStyle(.secondary) 429 .foregroundStyle(.primary)
430 .textSelection(.enabled)
587 } 431 }
588 } 432 }
589 } 433 }
590 ForEach(viewModel.httpHeaders) { header in 434 }
591 HStack(alignment: .top, spacing: 4) { 435 }
592 Text(header.name + ":") 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)
593 .font(.system(.caption, design: .monospaced)) 455 .font(.system(.caption, design: .monospaced))
594 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) 456 .foregroundStyle(.secondary)
595 Text(header.value) 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)
596 .font(.system(.caption, design: .monospaced)) 463 .font(.system(.caption, design: .monospaced))
597 .foregroundStyle(.primary)
598 .textSelection(.enabled) 464 .textSelection(.enabled)
465 if redirect.isFinal {
466 Text("(final)")
467 .font(.system(.caption2, design: .monospaced))
468 .foregroundStyle(.secondary)
469 }
599 } 470 }
600 } 471 }
601 } 472 }
602 } 473 }
603 } 474 }
604 .padding(.top, 16)
605 } 475 }
476}
606 477
607 // MARK: - IP Geolocation 478struct EmailSectionView: View {
479 let rows: [EmailRowViewData]
480 let loading: Bool
481 let error: String?
608 482
609 private var ipGeolocationSection: some View { 483 var body: some View {
610 VStack(alignment: .leading, spacing: 12) { 484 VStack(alignment: .leading, spacing: 12) {
611 sectionHeader("IP Location") 485 SectionTitleView(title: "Email")
612 486 CardView {
613 if viewModel.ipGeolocationLoading { 487 if loading {
614 ProgressView("Looking up location…") 488 ProgressView("Checking email records…")
615 .frame(maxWidth: .infinity, alignment: .center) 489 } else if let error {
616 .padding() 490 MessageRowView(text: error, isError: true)
617 } else if let geo = viewModel.ipGeolocation { 491 } else if rows.isEmpty {
618 ipGeolocationDetail(geo) 492 MessageRowView(text: "No email security records found", isError: false)
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)
625 } else { 493 } else {
626 errorLabel(error) 494 ForEach(rows) { row in
627 } 495 VStack(alignment: .leading, spacing: 4) {
628 } 496 HStack(spacing: 8) {
629 } 497 Text(row.label)
630 .padding(.top, 16) 498 .font(.system(.caption, design: .monospaced))
631 } 499 .foregroundStyle(.cyan)
632 500 .frame(width: 76, alignment: .leading)
633 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { 501 Text(row.status)
634 VStack(alignment: .leading, spacing: 6) { 502 .font(.system(.caption, design: .monospaced))
635 horizontallyScrollableContent(spacing: 6) { 503 .foregroundStyle(ResultColors.color(for: row.statusTone))
636 certRow("IP", geo.ip) 504 }
637 if let org = geo.org { 505 Text(row.detail)
638 certRow("Org / ISP", org) 506 .font(.system(.caption2, design: .monospaced))
639 } 507 .foregroundStyle(.primary)
640 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") 508 .textSelection(.enabled)
641 if !location.isEmpty { 509 if let auxiliaryDetail = row.auxiliaryDetail {
642 certRow("Location", location) 510 Text(auxiliaryDetail)
643 } 511 .font(.system(.caption2, design: .monospaced))
644 } 512 .foregroundStyle(.secondary)
645 513 }
646 if let lat = geo.latitude, let lon = geo.longitude { 514 }
647 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) 515 }
648 Map(initialPosition: .region(MKCoordinateRegion(
649 center: coordinate,
650 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
651 ))) {
652 Marker(geo.ip, coordinate: coordinate)
653 } 516 }
654 .mapStyle(.standard)
655 .frame(maxWidth: .infinity)
656 .frame(height: 180)
657 .cornerRadius(8)
658 } 517 }
659 } 518 }
660 .padding(10)
661 .background(Color(.systemGray6).opacity(0.5))
662 .cornerRadius(6)
663 } 519 }
520}
664 521
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
666 540
667 private var portScanSection: some View { 541 var body: some View {
668 VStack(alignment: .leading, spacing: 12) { 542 VStack(alignment: .leading, spacing: 12) {
669 sectionHeader("Open Ports") 543 SectionTitleView(title: "Network")
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)
688 544
689 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { 545 CardView {
690 VStack(alignment: .leading, spacing: 10) { 546 Text("Reachability")
691 TextField("8888, 9000, 27017", text: $customPortInput) 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)
692 .font(.system(.caption, design: .monospaced)) 558 .font(.system(.caption, design: .monospaced))
693 .textInputAutocapitalization(.never) 559 Spacer()
694 .autocorrectionDisabled() 560 Text(row.latencyLabel)
695 .keyboardType(.numberPad) 561 .font(.system(.caption2, design: .monospaced))
696 .padding(10) 562 .foregroundStyle(.secondary)
697 .background(Color(.systemGray6).opacity(0.5)) 563 Text(row.statusLabel)
698 .cornerRadius(6) 564 .font(.system(.caption, design: .monospaced))
699 565 .foregroundStyle(ResultColors.color(for: row.statusTone))
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 }
718 } 566 }
719 .padding(.top, 8)
720 } 567 }
721 .font(.system(.caption, design: .monospaced))
722 .tint(.secondary)
723 } 568 }
724 .padding(10)
725 .background(Color(.systemGray6).opacity(0.5))
726 .cornerRadius(6)
727 } 569 }
728 }
729 .padding(.top, 16)
730 }
731 570
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 }
733 600
734 private func sectionHeader(_ title: String) -> some View { 601 CardView {
735 Text(title) 602 Text("Port Scan")
736 .font(.system(.headline, design: .default)) 603 .font(.system(.subheadline, design: .monospaced))
737 .foregroundStyle(.white) 604 .fontWeight(.semibold)
738 } 605 .foregroundStyle(.cyan)
739 606
740 private var dnssecStatus: Bool? { 607 if isCloudflareProxied {
741 viewModel.dnsSections.compactMap(\.dnssecSigned).first 608 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
742 } 609 .font(.system(.caption2, design: .monospaced))
610 .foregroundStyle(.orange)
611 }
743 612
744 private func certRow(_ label: String, _ value: String) -> some View { 613 if portScanLoading {
745 VStack(alignment: .leading, spacing: 2) { 614 ProgressView("Scanning ports…")
746 Text(label) 615 } else if let portScanError, standardPortRows.isEmpty {
747 .font(.system(.caption2, design: .monospaced)) 616 MessageRowView(text: portScanError, isError: true)
748 .foregroundStyle(.secondary) 617 } else {
749 Text(value) 618 Text("Standard Ports")
750 .font(.system(.caption, design: .monospaced)) 619 .font(.system(.caption, design: .monospaced))
751 .textSelection(.enabled) 620 .foregroundStyle(.secondary)
752 } 621 PortRowsView(rows: standardPortRows)
753 } 622 }
754 623
755 private var hstsLoadingRow: some View { 624 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
756 VStack(alignment: .leading, spacing: 2) { 625 VStack(alignment: .leading, spacing: 10) {
757 Text("HSTS Preload") 626 TextField("8888, 9000, 27017", text: $customPortInput)
758 .font(.system(.caption2, design: .monospaced)) 627 .font(.system(.caption, design: .monospaced))
759 .foregroundStyle(.secondary) 628 .textInputAutocapitalization(.never)
760 ProgressView() 629 .autocorrectionDisabled()
761 .controlSize(.small) 630 .keyboardType(.numberPad)
762 } 631 .padding(10)
763 } 632 .background(Color(.systemGray6).opacity(0.5))
633 .cornerRadius(6)
764 634
765 private func hstsStatusRow(_ isPreloaded: Bool) -> some View { 635 Button("Scan") {
766 VStack(alignment: .leading, spacing: 2) { 636 onScanCustomPorts()
767 Text("HSTS Preload") 637 }
768 .font(.system(.caption2, design: .monospaced)) 638 .buttonStyle(.borderedProminent)
769 .foregroundStyle(.secondary) 639 .disabled(customPortScanLoading)
770 Text(isPreloaded ? "Preloaded" : "Not preloaded") 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 }
771 .font(.system(.caption, design: .monospaced)) 651 .font(.system(.caption, design: .monospaced))
772 .foregroundStyle(isPreloaded ? .green : .secondary) 652 .tint(.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()
795 } 653 }
796 .scrollTargetLayout()
797 } 654 }
798 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
799 .frame(maxWidth: .infinity, alignment: .leading)
800 } 655 }
656}
801 657
802 private func errorLabel(_ message: String) -> some View { 658struct PortRowsView: View {
803 Label(message, systemImage: "exclamationmark.triangle.fill") 659 let rows: [PortScanRowViewData]
804 .font(.system(.caption, design: .monospaced))
805 .foregroundStyle(.red)
806 .padding(8)
807 }
808 660
809 private func portScanResultsCard(_ results: [PortScanResult]) -> some View { 661 var body: some View {
810 VStack(alignment: .leading, spacing: 4) { 662 if rows.isEmpty {
811 ForEach(results) { result in 663 MessageRowView(text: "No results", isError: false)
664 } else {
665 ForEach(rows) { row in
812 VStack(alignment: .leading, spacing: 2) { 666 VStack(alignment: .leading, spacing: 2) {
813 HStack(spacing: 8) { 667 HStack {
814 Circle() 668 Text(row.portLabel)
815 .fill(result.open ? Color.green : Color(.systemGray4))
816 .frame(width: 8, height: 8)
817 Text("\(result.port)")
818 .font(.system(.caption, design: .monospaced)) 669 .font(.system(.caption, design: .monospaced))
819 .lineLimit(1)
820 .frame(width: 52, alignment: .leading) 670 .frame(width: 52, alignment: .leading)
821 Text(result.service) 671 Text(row.service)
822 .font(.system(.caption, design: .monospaced)) 672 .font(.system(.caption, design: .monospaced))
823 .foregroundStyle(result.open ? .primary : .secondary) 673 .foregroundStyle(.primary)
824 Spacer() 674 Spacer()
825 if result.open { 675 if let durationLabel = row.durationLabel {
826 Text("Open") 676 Text(durationLabel)
827 .font(.system(.caption2, design: .monospaced)) 677 .font(.system(.caption2, design: .monospaced))
828 .foregroundStyle(.green) 678 .foregroundStyle(.secondary)
829 } 679 }
680 Text(row.statusLabel)
681 .font(.system(.caption2, design: .monospaced))
682 .foregroundStyle(ResultColors.color(for: row.statusTone))
830 } 683 }
831 684 if let banner = row.banner {
832 if let banner = result.banner {
833 Text(banner) 685 Text(banner)
834 .font(.system(.caption2, design: .monospaced)) 686 .font(.system(.caption2, design: .monospaced))
835 .foregroundStyle(.secondary) 687 .foregroundStyle(.secondary)
836 .lineLimit(1) 688 .padding(.leading, 8)
837 .padding(.leading, 16)
838 } 689 }
839 } 690 }
840 } 691 }
841 } 692 }
842 } 693 }
694}
843 695
844 private func parsedCustomPorts(from input: String) -> [UInt16] { 696struct SectionTitleView: View {
845 let parts = input.split(separator: ",", omittingEmptySubsequences: true) 697 let title: String
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 }
859 698
860 return ports 699 var body: some View {
700 Text(title)
701 .font(.system(.headline))
702 .foregroundStyle(.white)
861 } 703 }
704}
862 705
863 private var httpStatusSummaryParts: [(text: String, color: Color)] { 706struct CardView<Content: View>: View {
864 var parts: [(text: String, color: Color)] = [] 707 let content: Content
865 708
866 if let statusCode = viewModel.httpStatusCode { 709 init(@ViewBuilder content: () -> Content) {
867 parts.append(("HTTP \(statusCode)", .cyan)) 710 self.content = content()
868 } 711 }
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 }
875 712
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)
877 } 725 }
726}
878 727
879 private var http3AvailabilityNote: String? { 728struct LoadingCardView: View {
880 guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else { 729 let text: String
881 return nil 730
731 var body: some View {
732 CardView {
733 ProgressView(text)
734 .frame(maxWidth: .infinity, alignment: .center)
882 } 735 }
883 return "(HTTP/3 available)"
884 } 736 }
737}
738
739struct MessageCardView: View {
740 let text: String
741 let isError: Bool
885 742
886 private func httpSecurityGradeColor(for grade: String) -> Color { 743 var body: some View {
887 switch grade { 744 CardView {
888 case "A", "B": 745 MessageRowView(text: text, isError: isError)
889 .green
890 case "C":
891 .yellow
892 case "D", "F":
893 .red
894 default:
895 .secondary
896 } 746 }
897 } 747 }
748}
749
750struct MessageRowView: View {
751 let text: String
752 let isError: Bool
898 753
899 private func expiryColor(_ days: Int) -> Color { 754 var body: some View {
900 if days < 30 { return .red } 755 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
901 if days < 60 { return .yellow } 756 .font(.system(.caption, design: .monospaced))
902 return .green 757 .foregroundStyle(isError ? .red : .secondary)
903 } 758 }
759}
904 760
905 private func shareResults() { 761struct LabeledValueRow: View {
906 let text = viewModel.exportText() 762 let row: InfoRowViewData
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)
912 763
913 do { 764 var body: some View {
914 try text.write(to: tempURL, atomically: true, encoding: .utf8) 765 VStack(alignment: .leading, spacing: 2) {
915 } catch { 766 Text(row.label)
916 return 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)
917 } 773 }
774 }
775}
918 776
919 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) 777enum ResultColors {
920 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 778 static func color(for tone: ResultTone) -> Color {
921 let rootVC = windowScene.keyWindow?.rootViewController else { return } 779 switch tone {
922 var presenter = rootVC 780 case .primary:
923 while let presented = presenter.presentedViewController { 781 return .primary
924 presenter = presented 782 case .secondary:
783 return .secondary
784 case .success:
785 return .green
786 case .warning:
787 return .yellow
788 case .failure:
789 return .red
925 } 790 }
926 activityVC.popoverPresentationController?.sourceView = presenter.view
927 presenter.present(activityVC, animated: true)
928 } 791 }
929} 792}
930 793
931extension DateFormatter { 794extension DateFormatter {
932 static let certDate: DateFormatter = { 795 static let certDate: DateFormatter = {
933 let f = DateFormatter() 796 let formatter = DateFormatter()
934 f.dateStyle = .medium 797 formatter.dateStyle = .medium
935 f.timeStyle = .short 798 formatter.timeStyle = .short
936 return f 799 return formatter
937 }() 800 }()
938} 801}
939 802
@@ -948,10 +811,7 @@ private struct SettingsView: View {
948 guard resolverOption == .custom else { 811 guard resolverOption == .custom else {
949 return nil 812 return nil
950 } 813 }
951 814 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
952 return DNSResolverOption.isValidCustomURL(customResolverURL)
953 ? nil
954 : "Resolver URL must start with https://"
955 } 815 }
956 816
957 var body: some View { 817 var body: some View {
@@ -981,9 +841,7 @@ private struct SettingsView: View {
981 .onAppear { 841 .onAppear {
982 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 842 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
983 resolverOption = DNSResolverOption.option(for: currentResolverURL) 843 resolverOption = DNSResolverOption.option(for: currentResolverURL)
984 customResolverURL = resolverOption == .custom 844 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
985 ? currentResolverURL
986 : DNSResolverOption.defaultURLString
987 } 845 }
988 .onChange(of: resolverOption) { _, newValue in 846 .onChange(of: resolverOption) { _, newValue in
989 guard let presetURL = newValue.urlString else { 847 guard let presetURL = newValue.urlString else {
@@ -993,9 +851,7 @@ private struct SettingsView: View {
993 storedResolverURL = presetURL 851 storedResolverURL = presetURL
994 } 852 }
995 .onChange(of: customResolverURL) { _, newValue in 853 .onChange(of: customResolverURL) { _, newValue in
996 guard resolverOption == .custom else { 854 guard resolverOption == .custom else { return }
997 return
998 }
999 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) 855 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1000 } 856 }
1001 } 857 }
DomainDig/DNSLookupService.swift +18 −5
@@ -57,8 +57,6 @@ enum DNSResolverOption: String, CaseIterable, Identifiable {
57} 57}
58 58
59struct DNSLookupService { 59struct DNSLookupService {
60 private static let rrsigQueryType = 46
61 private static let dnskeyQueryType = 48
62 private static let internetClass = 1 60 private static let internetClass = 1
63 61
64 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { 62 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
@@ -93,7 +91,7 @@ struct DNSLookupService {
93 } 91 }
94 } 92 }
95 93
96 static func lookupAll(domain: String) async -> [DNSSection] { 94 static func lookupAll(domain: String) async -> ServiceResult<[DNSSection]> {
97 typealias Result = ( 95 typealias Result = (
98 type: DNSRecordType, 96 type: DNSRecordType,
99 records: [DNSRecord], 97 records: [DNSRecord],
@@ -109,8 +107,11 @@ struct DNSLookupService {
109 resolverURLString: resolverURLString 107 resolverURLString: resolverURLString
110 ) 108 )
111 109
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
113 for recordType in DNSRecordType.allCases { 111 for recordType in DNSRecordType.allCases {
112 guard recordType != .PTR else {
113 continue
114 }
114 let shouldQueryWildcard = wildcardTypes.contains(recordType) 115 let shouldQueryWildcard = wildcardTypes.contains(recordType)
115 group.addTask { 116 group.addTask {
116 var apexRecords: [DNSRecord] = [] 117 var apexRecords: [DNSRecord] = []
@@ -159,6 +160,12 @@ struct DNSLookupService {
159 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0) 160 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
160 } 161 }
161 } 162 }
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")
162 } 169 }
163 170
164 private static func lookupResponse( 171 private static func lookupResponse(
@@ -218,11 +225,17 @@ struct DNSLookupService {
218 return response.authenticatedData 225 return response.authenticatedData
219 } 226 }
220 227
221 private static func currentResolverURLString() -> String { 228 static func currentResolverURLString() -> String {
222 let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey) 229 let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
223 return DNSResolverOption.resolvedURLString(from: storedValue) 230 return DNSResolverOption.resolvedURLString(from: storedValue)
224 } 231 }
225 232
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
226 private static func validatedResolverURL(from urlString: String) throws -> URL { 239 private static func validatedResolverURL(from urlString: String) throws -> URL {
227 guard let url = URL(string: urlString) else { 240 guard let url = URL(string: urlString) else {
228 throw URLError(.badURL) 241 throw URLError(.badURL)
DomainDig/DomainViewModel.swift +965 −476
@@ -1,24 +1,162 @@
1import Foundation 1import Foundation
2import SwiftUI 2import SwiftUI
3 3
4enum ResultTone {
5 case primary
6 case secondary
7 case success
8 case warning
9 case failure
10}
11
12struct SummaryFieldViewData: Identifiable {
13 let id = UUID()
14 let label: String
15 let value: String
16 let tone: ResultTone
17}
18
19struct InfoRowViewData: Identifiable {
20 let id = UUID()
21 let label: String
22 let value: String
23 let tone: ResultTone
24}
25
26struct SectionMessageViewData {
27 let text: String
28 let isError: Bool
29}
30
31struct DNSRecordSectionViewData: Identifiable {
32 let id = UUID()
33 let title: String
34 let rows: [InfoRowViewData]
35 let wildcardRows: [InfoRowViewData]
36 let wildcardTitle: String?
37 let message: SectionMessageViewData?
38}
39
40struct EmailRowViewData: Identifiable {
41 let id = UUID()
42 let label: String
43 let status: String
44 let statusTone: ResultTone
45 let detail: String
46 let auxiliaryDetail: String?
47}
48
49struct RedirectHopViewData: Identifiable {
50 let id = UUID()
51 let stepLabel: String
52 let statusCode: String
53 let url: String
54 let isFinal: Bool
55}
56
57struct ReachabilityRowViewData: Identifiable {
58 let id = UUID()
59 let portLabel: String
60 let latencyLabel: String
61 let statusLabel: String
62 let statusTone: ResultTone
63}
64
65struct PortScanRowViewData: Identifiable {
66 let id = UUID()
67 let portLabel: String
68 let service: String
69 let statusLabel: String
70 let statusTone: ResultTone
71 let banner: String?
72 let durationLabel: String?
73}
74
75struct 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
4@MainActor 145@MainActor
5@Observable 146@Observable
6final class DomainViewModel { 147final class DomainViewModel {
7 var domain: String = "" 148 var domain: String = ""
8 149
9 // DNS
10 var dnsSections: [DNSSection] = [] 150 var dnsSections: [DNSSection] = []
11 var dnsLoading = false 151 var dnsLoading = false
12 var dnsError: String? 152 var dnsError: String?
13 153
14 // SSL
15 var sslInfo: SSLCertificateInfo? 154 var sslInfo: SSLCertificateInfo?
16 var sslLoading = false 155 var sslLoading = false
17 var sslError: String? 156 var sslError: String?
18 var hstsPreloaded: Bool? 157 var hstsPreloaded: Bool?
19 var hstsLoading = false 158 var hstsLoading = false
20 159
21 // HTTP Headers
22 var httpHeaders: [HTTPHeader] = [] 160 var httpHeaders: [HTTPHeader] = []
23 var httpSecurityGrade: String? 161 var httpSecurityGrade: String?
24 var httpStatusCode: Int? 162 var httpStatusCode: Int?
@@ -28,32 +166,26 @@ final class DomainViewModel {
28 var httpHeadersLoading = false 166 var httpHeadersLoading = false
29 var httpHeadersError: String? 167 var httpHeadersError: String?
30 168
31 // Reachability
32 var reachabilityResults: [PortReachability] = [] 169 var reachabilityResults: [PortReachability] = []
33 var reachabilityLoading = false 170 var reachabilityLoading = false
34 var reachabilityError: String? 171 var reachabilityError: String?
35 172
36 // IP Geolocation
37 var ipGeolocation: IPGeolocation? 173 var ipGeolocation: IPGeolocation?
38 var ipGeolocationLoading = false 174 var ipGeolocationLoading = false
39 var ipGeolocationError: String? 175 var ipGeolocationError: String?
40 176
41 // Email Security
42 var emailSecurity: EmailSecurityResult? 177 var emailSecurity: EmailSecurityResult?
43 var emailSecurityLoading = false 178 var emailSecurityLoading = false
44 var emailSecurityError: String? 179 var emailSecurityError: String?
45 180
46 // PTR / Reverse DNS
47 var ptrRecord: String? 181 var ptrRecord: String?
48 var ptrLoading = false 182 var ptrLoading = false
49 var ptrError: String? 183 var ptrError: String?
50 184
51 // Redirect Chain
52 var redirectChain: [RedirectHop] = [] 185 var redirectChain: [RedirectHop] = []
53 var redirectChainLoading = false 186 var redirectChainLoading = false
54 var redirectChainError: String? 187 var redirectChainError: String?
55 188
56 // Port Scan
57 var portScanResults: [PortScanResult] = [] 189 var portScanResults: [PortScanResult] = []
58 var portScanLoading = false 190 var portScanLoading = false
59 var portScanError: String? 191 var portScanError: String?
@@ -63,368 +195,494 @@ final class DomainViewModel {
63 195
64 var hasRun = false 196 var hasRun = false
65 private(set) var searchedDomain: String = "" 197 private(set) var searchedDomain: String = ""
198 private(set) var lastLookupDurationMs: Int?
66 199
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?
68 204
69 private static let recentSearchesKey = "recentSearches" 205 private static let recentSearchesKey = "recentSearches"
70 private static let maxRecent = 20 206 private static let maxRecent = 20
71
72 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] 207 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
73 208
74 // MARK: - Saved Domains
75
76 private static let savedDomainsKey = "savedDomains" 209 private static let savedDomainsKey = "savedDomains"
77
78 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] 210 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
79 211
80 var isCurrentDomainSaved: Bool { 212 private static let historyKey = "lookupHistory"
81 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) 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 ?? ""
82 } 228 }
83 229
84 func toggleSavedDomain() { 230 var resultsLoaded: Bool {
85 if isCurrentDomainSaved { 231 hasRun &&
86 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() } 232 !dnsLoading &&
87 } else { 233 !sslLoading &&
88 savedDomains.append(searchedDomain) 234 !hstsLoading &&
89 } 235 !httpHeadersLoading &&
90 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) 236 !reachabilityLoading &&
237 !ipGeolocationLoading &&
238 !emailSecurityLoading &&
239 !ptrLoading &&
240 !redirectChainLoading &&
241 !portScanLoading &&
242 !customPortScanLoading
91 } 243 }
92 244
93 func removeSavedDomain(_ domain: String) { 245 var isCloudflareProxied: Bool {
94 savedDomains.removeAll { $0 == domain } 246 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
95 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
96 } 247 }
97 248
98 func removeSavedDomains(at offsets: IndexSet) { 249 var isCurrentDomainSaved: Bool {
99 savedDomains.remove(atOffsets: offsets) 250 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
100 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
101 } 251 }
102 252
103 // MARK: - History 253 var resolverDisplayName: String {
254 DNSLookupService.currentResolverDisplayName()
255 }
104 256
105 private static let historyKey = "lookupHistory" 257 var resolverURLString: String {
106 private static let maxHistory = 50 258 DNSLookupService.currentResolverURLString()
259 }
107 260
108 var history: [HistoryEntry] = { 261 var allPortScanResults: [PortScanResult] {
109 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), 262 (portScanResults + customPortResults).sorted {
110 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { 263 if $0.kind == $1.kind {
111 return [] 264 return $0.port < $1.port
265 }
266 return $0.kind == .standard
112 } 267 }
113 return entries 268 }
114 }()
115 269
116 private func saveHistoryEntry() { 270 var currentSnapshot: LookupSnapshot {
117 let entry = HistoryEntry( 271 LookupSnapshot(
118 domain: searchedDomain, 272 domain: searchedDomain,
119 timestamp: Date(), 273 timestamp: Date(),
274 resolverDisplayName: resolverDisplayName,
275 resolverURLString: resolverURLString,
276 totalLookupDurationMs: lastLookupDurationMs,
120 dnsSections: dnsSections, 277 dnsSections: dnsSections,
278 dnsError: dnsError,
121 sslInfo: sslInfo, 279 sslInfo: sslInfo,
280 sslError: sslError,
281 hstsPreloaded: hstsPreloaded,
122 httpHeaders: httpHeaders, 282 httpHeaders: httpHeaders,
283 httpSecurityGrade: httpSecurityGrade,
284 httpStatusCode: httpStatusCode,
285 httpResponseTimeMs: httpResponseTimeMs,
286 httpProtocol: httpProtocol,
287 http3Advertised: http3Advertised,
288 httpHeadersError: httpHeadersError,
123 reachabilityResults: reachabilityResults, 289 reachabilityResults: reachabilityResults,
290 reachabilityError: reachabilityError,
124 ipGeolocation: ipGeolocation, 291 ipGeolocation: ipGeolocation,
292 ipGeolocationError: ipGeolocationError,
125 emailSecurity: emailSecurity, 293 emailSecurity: emailSecurity,
126 mtaSts: emailSecurity?.mtaSts, 294 emailSecurityError: emailSecurityError,
127 ptrRecord: ptrRecord, 295 ptrRecord: ptrRecord,
296 ptrError: ptrError,
128 redirectChain: redirectChain, 297 redirectChain: redirectChain,
129 portScanResults: portScanResults, 298 redirectChainError: redirectChainError,
130 hstsPreloaded: hstsPreloaded 299 portScanResults: allPortScanResults,
300 portScanError: combinedPortScanError,
301 isLive: true
131 ) 302 )
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 }
139 } 303 }
140 304
141 func removeHistoryEntries(at offsets: IndexSet) { 305 var summaryFields: [SummaryFieldViewData] {
142 history.remove(atOffsets: offsets) 306 Self.summaryFields(from: currentSnapshot)
143 if let data = try? JSONEncoder().encode(history) {
144 UserDefaults.standard.set(data, forKey: Self.historyKey)
145 }
146 } 307 }
147 308
148 // MARK: - Computed 309 var domainRows: [InfoRowViewData] {
310 Self.domainRows(from: currentSnapshot)
311 }
149 312
150 var trimmedDomain: String { 313 var dnsRows: [DNSRecordSectionViewData] {
151 domain 314 Self.dnsRows(from: currentSnapshot)
152 .trimmingCharacters(in: .whitespacesAndNewlines)
153 .replacingOccurrences(of: "https://", with: "")
154 .replacingOccurrences(of: "http://", with: "")
155 .components(separatedBy: "/").first ?? ""
156 } 315 }
157 316
158 /// True when all lookups have finished (regardless of success/failure). 317 var dnssecLabel: String? {
159 var resultsLoaded: Bool { 318 Self.dnssecLabel(from: currentSnapshot)
160 hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
161 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
162 && !redirectChainLoading && !portScanLoading
163 } 319 }
164 320
165 /// True when response headers indicate the domain is behind Cloudflare's proxy. 321 var ptrMessage: SectionMessageViewData? {
166 /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud 322 Self.ptrMessage(from: currentSnapshot)
167 /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge. 323 }
168 var isCloudflareProxied: Bool { 324
169 httpHeaders.contains { $0.name.lowercased() == "cf-ray" } 325 var webCertificateRows: [InfoRowViewData] {
326 Self.webCertificateRows(from: currentSnapshot)
327 }
328
329 var webResponseRows: [InfoRowViewData] {
330 Self.webResponseRows(from: currentSnapshot)
170 } 331 }
171 332
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 }
173 390
174 func reset() { 391 func reset() {
392 lookupTask?.cancel()
393 customPortScanTask?.cancel()
175 hasRun = false 394 hasRun = false
176 searchedDomain = "" 395 searchedDomain = ""
177 dnsSections = [] 396 lastLookupDurationMs = nil
178 dnsError = nil 397 clearLookupState()
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
214 } 398 }
215 399
216 // MARK: - Run
217
218 func run() { 400 func run() {
219 let target = trimmedDomain 401 let target = trimmedDomain
220 guard !target.isEmpty else { return } 402 guard !target.isEmpty else { return }
221 403
404 lookupTask?.cancel()
405 customPortScanTask?.cancel()
406
407 let lookupID = UUID()
408 activeLookupID = lookupID
409 lookupStartedAt = Date()
410 lastLookupDurationMs = nil
222 addRecentSearch(target) 411 addRecentSearch(target)
223 searchedDomain = target 412 searchedDomain = target
224 hasRun = true 413 hasRun = true
414 clearLookupState()
415 setAllLoadingStates(true)
416 customPortScanLoading = false
225 417
226 // Reset all state 418 lookupTask = Task { [weak self] in
227 dnsSections = [] 419 guard let self else { return }
228 dnsError = nil 420 await self.performLookup(domain: target, lookupID: lookupID)
229 dnsLoading = true 421 }
230 sslInfo = nil 422 }
231 sslError = nil 423
232 sslLoading = true 424 func runCustomPortScan(ports: [UInt16]) async {
233 hstsPreloaded = nil 425 guard !searchedDomain.isEmpty else {
234 hstsLoading = true 426 customPortScanError = "Run a domain lookup first"
235 httpHeaders = [] 427 return
236 httpSecurityGrade = nil 428 }
237 httpStatusCode = nil 429
238 httpResponseTimeMs = nil 430 guard !ports.isEmpty else {
239 httpProtocol = nil 431 customPortScanError = "Enter at least one valid port"
240 http3Advertised = false 432 customPortResults = []
241 httpHeadersError = nil 433 return
242 httpHeadersLoading = true 434 }
243 reachabilityResults = [] 435
244 reachabilityError = nil 436 customPortScanTask?.cancel()
245 reachabilityLoading = true 437 let domain = searchedDomain
246 ipGeolocation = nil 438 let lookupID = activeLookupID
247 ipGeolocationError = nil 439
248 ipGeolocationLoading = true 440 customPortScanLoading = 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 = []
262 customPortScanError = nil 441 customPortScanError = nil
263 customPortScanLoading = false 442 customPortResults = []
264 443
265 Task { 444 customPortScanTask = Task { [weak self] in
266 await withTaskGroup(of: Void.self) { group in 445 guard let self else { return }
267 // DNS → chained: email security, PTR, geolocation 446 let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0)
268 group.addTask { @MainActor in 447 guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return }
269 await self.runDNS(domain: target) 448 self.applyCustomPortResult(result)
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()
304 } 449 }
305 } 450 }
306 451
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 }
308 486
309 private func runDNS(domain: String) async { 487 private func runDNS(domain: String, lookupID: UUID) async {
310 do { 488 let result = await DNSLookupService.lookupAll(domain: domain)
311 let sections = await DNSLookupService.lookupAll(domain: domain) 489 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
490 switch result {
491 case let .success(sections):
312 dnsSections = sections 492 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
313 } 500 }
314 dnsLoading = false 501 dnsLoading = false
315 } 502 }
316 503
317 private func runSSL(domain: String) async { 504 private func runSSL(domain: String, lookupID: UUID) async {
318 do { 505 let result = await SSLCheckService.check(domain: domain)
319 let info = try await SSLCheckService.check(domain: domain) 506 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
507 switch result {
508 case let .success(info):
320 sslInfo = info 509 sslInfo = info
321 } catch { 510 sslError = nil
322 sslError = error.localizedDescription 511 case let .empty(message):
512 sslInfo = nil
513 sslError = message
514 case let .error(message):
515 sslInfo = nil
516 sslError = message
323 } 517 }
324 sslLoading = false 518 sslLoading = false
325 } 519 }
326 520
327 private func runHSTSPreload(domain: String) async { 521 private func runHSTSPreload(domain: String, lookupID: UUID) async {
328 hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain) 522 let result = await SSLCheckService.checkHSTSPreload(domain: domain)
523 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
524 hstsPreloaded = result
329 hstsLoading = false 525 hstsLoading = false
330 } 526 }
331 527
332 private func runHTTPHeaders(domain: String) async { 528 private func runHTTPHeaders(domain: String, lookupID: UUID) async {
333 do { 529 let result = await HTTPHeadersService.fetch(domain: domain)
334 let result = try await HTTPHeadersService.fetch(domain: domain) 530 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
335 httpHeaders = result.headers 531 switch result {
336 httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue 532 case let .success(headersResult):
337 httpStatusCode = result.statusCode 533 httpHeaders = headersResult.headers
338 httpResponseTimeMs = result.responseTimeMs 534 httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue
339 httpProtocol = result.httpProtocol 535 httpStatusCode = headersResult.statusCode
340 http3Advertised = result.http3Advertised 536 httpResponseTimeMs = headersResult.responseTimeMs
341 } catch { 537 httpProtocol = headersResult.httpProtocol
342 httpHeadersError = error.localizedDescription 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
343 } 556 }
344 httpHeadersLoading = false 557 httpHeadersLoading = false
345 } 558 }
346 559
347 private func runReachability(domain: String) async { 560 private func runReachability(domain: String, lookupID: UUID) async {
348 let results = await ReachabilityService.checkAll(domain: domain) 561 let result = await ReachabilityService.checkAll(domain: domain)
349 reachabilityResults = results 562 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
350 reachabilityLoading = false 563 switch result {
351 } 564 case let .success(results):
352 565 reachabilityResults = results
353 private func runIPGeolocation() async { 566 reachabilityError = nil
354 // Find the first A record IP 567 case let .empty(message):
355 guard let aSection = dnsSections.first(where: { $0.recordType == .A }), 568 reachabilityResults = []
356 let firstIP = aSection.records.first?.value else { 569 reachabilityError = message
357 ipGeolocationError = "No A record available" 570 case let .error(message):
358 ipGeolocationLoading = false 571 reachabilityResults = []
359 return 572 reachabilityError = message
360 } 573 }
361 do { 574 reachabilityLoading = false
362 let geo = try await IPGeolocationService.lookup(ip: firstIP)
363 ipGeolocation = geo
364 } catch {
365 ipGeolocationError = error.localizedDescription
366 }
367 ipGeolocationLoading = false
368 } 575 }
369 576
370 private func runEmailSecurity(domain: String) async { 577 private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async {
371 // Extract TXT records from already-fetched DNS sections
372 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
373 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) 578 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 }
375 emailSecurityLoading = false 591 emailSecurityLoading = false
376 } 592 }
377 593
378 private func runReverseDNS() async { 594 private func runReverseDNS(ip: String, lookupID: UUID) async {
379 guard let aSection = dnsSections.first(where: { $0.recordType == .A }), 595 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
380 let firstIP = aSection.records.first?.value else { 596 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
381 ptrError = "No A record available" 597 switch result {
382 ptrLoading = false 598 case let .success(record):
383 return 599 ptrRecord = record
384 } 600 ptrError = nil
385 let result = await ReverseDNSService.lookup(ip: firstIP) 601 case let .empty(message):
386 ptrRecord = result 602 ptrRecord = nil
387 if result == nil { 603 ptrError = message
388 ptrError = "No PTR record found" 604 case let .error(message):
605 ptrRecord = nil
606 ptrError = message
389 } 607 }
390 ptrLoading = false 608 ptrLoading = false
391 } 609 }
392 610
393 private func runRedirectChain(domain: String) async { 611 private func runRedirectChain(domain: String, lookupID: UUID) async {
394 do { 612 let result = await RedirectChainService.trace(domain: domain)
395 let hops = try await RedirectChainService.trace(domain: domain) 613 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
614 switch result {
615 case let .success(hops):
396 redirectChain = hops 616 redirectChain = hops
397 } catch { 617 redirectChainError = nil
398 redirectChainError = error.localizedDescription 618 case let .empty(message):
619 redirectChain = []
620 redirectChainError = message
621 case let .error(message):
622 redirectChain = []
623 redirectChainError = message
399 } 624 }
400 redirectChainLoading = false 625 redirectChainLoading = false
401 } 626 }
402 627
403 private func runPortScan(domain: String) async { 628 private func runPortScan(domain: String, lookupID: UUID) async {
404 let results = await PortScanService.scanAll(domain: domain) 629 let result = await PortScanService.scanAll(domain: domain)
405 let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) 630 switch result {
406 portScanResults = enrichedResults 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 }
407 portScanLoading = false 645 portScanLoading = false
408 } 646 }
409 647
410 func runCustomPortScan(ports: [UInt16]) async { 648 private func runIPGeolocation(ip: String, lookupID: UUID) async {
411 guard !searchedDomain.isEmpty else { 649 let result = await IPGeolocationService.lookup(ip: ip)
412 customPortScanError = "Run a domain lookup first" 650 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
413 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
414 } 661 }
662 ipGeolocationLoading = false
663 }
415 664
416 guard !ports.isEmpty else { 665 private func finishDependentWithoutPrimaryIP(lookupID: UUID) async {
417 customPortScanError = "Enter at least one valid port" 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):
418 customPortResults = [] 680 customPortResults = []
419 return 681 customPortScanError = message
682 case let .error(message):
683 customPortResults = []
684 customPortScanError = message
420 } 685 }
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
428 customPortScanLoading = false 686 customPortScanLoading = false
429 } 687 }
430 688
@@ -453,271 +711,502 @@ final class DomainViewModel {
453 } 711 }
454 } 712 }
455 713
456 // MARK: - Export 714 private func saveHistoryEntry(replaceLatest: Bool) {
457 715 guard !searchedDomain.isEmpty else { return }
458 func exportText() -> String { 716 let entry = HistoryEntry(
459 return Self.formatExportText(
460 domain: searchedDomain, 717 domain: searchedDomain,
461 date: Date(), 718 timestamp: Date(),
462 dnsSections: dnsSections, 719 dnsSections: dnsSections,
463 sslInfo: sslInfo, 720 sslInfo: sslInfo,
464 sslError: sslError,
465 hstsPreloaded: hstsPreloaded,
466 httpHeaders: httpHeaders, 721 httpHeaders: httpHeaders,
467 httpSecurityGrade: httpSecurityGrade,
468 httpStatusCode: httpStatusCode,
469 httpResponseTimeMs: httpResponseTimeMs,
470 httpProtocol: httpProtocol,
471 http3Advertised: http3Advertised,
472 httpHeadersError: httpHeadersError,
473 reachabilityResults: reachabilityResults, 722 reachabilityResults: reachabilityResults,
474 ipGeolocation: ipGeolocation, 723 ipGeolocation: ipGeolocation,
475 ipGeolocationError: ipGeolocationError,
476 emailSecurity: emailSecurity, 724 emailSecurity: emailSecurity,
725 mtaSts: emailSecurity?.mtaSts,
477 ptrRecord: ptrRecord, 726 ptrRecord: ptrRecord,
478 redirectChain: redirectChain, 727 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
480 ) 741 )
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 }
481 } 928 }
482 929
483 static func formatExportText( 930 static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] {
484 domain: String, 931 guard let emailSecurity = snapshot.emailSecurity else { return [] }
485 date: Date, 932 return [
486 dnsSections: [DNSSection], 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),
487 sslInfo: SSLCertificateInfo?, 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),
488 sslError: String? = 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)" }),
489 hstsPreloaded: Bool? = nil, 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),
490 httpHeaders: [HTTPHeader], 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)
491 httpSecurityGrade: String? = nil, 938 ]
492 httpStatusCode: Int? = nil, 939 }
493 httpResponseTimeMs: Int? = nil, 940
494 httpProtocol: String? = nil, 941 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
495 http3Advertised: Bool = false, 942 snapshot.reachabilityResults.map {
496 httpHeadersError: String? = nil, 943 ReachabilityRowViewData(
497 reachabilityResults: [PortReachability], 944 portLabel: "Port \($0.port)",
498 ipGeolocation: IPGeolocation?, 945 latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—",
499 ipGeolocationError: String? = nil, 946 statusLabel: $0.reachable ? "Reachable" : "Unreachable",
500 emailSecurity: EmailSecurityResult? = nil, 947 statusTone: $0.reachable ? .success : .failure
501 ptrRecord: String? = nil, 948 )
502 redirectChain: [RedirectHop] = [], 949 }
503 portScanResults: [PortScanResult] = [] 950 }
504 ) -> String { 951
505 let dateFmt = DateFormatter() 952 static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
506 dateFmt.dateFormat = "yyyy-MM-dd HH:mm" 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"
507 986
508 var lines: [String] = [ 987 var lines: [String] = [
509 "DomainDig Export", 988 "DomainDig Export",
510 "Domain: \(domain)", 989 "Domain: \(snapshot.domain)",
511 "Date: \(dateFmt.string(from: date))", 990 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
991 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
992 "Resolver: \(snapshot.resolverDisplayName)",
993 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
512 ] 994 ]
513 995
514 // Reachability 996 func appendSection(_ title: String, body: () -> Void) {
515 if !reachabilityResults.isEmpty {
516 lines.append("") 997 lines.append("")
517 lines.append("Reachability") 998 lines.append(title)
518 lines.append("------------") 999 lines.append(String(repeating: "-", count: title.count))
519 for result in reachabilityResults { 1000 body()
520 if result.reachable, let ms = result.latencyMs { 1001 }
521 lines.append(" Port \(result.port) \(ms)ms Reachable") 1002
522 } else { 1003 appendSection("Summary") {
523 lines.append(" Port \(result.port) — Unreachable") 1004 for item in summaryFields(from: snapshot) {
524 } 1005 lines.append(" \(item.label): \(item.value)")
525 } 1006 }
526 } 1007 }
527 1008
528 // Redirect Chain 1009 appendSection("Domain") {
529 if !redirectChain.isEmpty { 1010 for row in domainRows(from: snapshot) {
530 lines.append("") 1011 lines.append(" \(row.label): \(row.value)")
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 }
540 } 1012 }
541 } 1013 }
542 1014
543 // DNS 1015 appendSection("DNS") {
544 lines.append("") 1016 if let dnsError = snapshot.dnsError {
545 lines.append("DNS Records") 1017 lines.append(" Error: \(dnsError)")
546 lines.append("-----------") 1018 }
547 for section in dnsSections { 1019 if let dnssecLabel = dnssecLabel(from: snapshot) {
548 lines.append(section.recordType.rawValue) 1020 lines.append(" \(dnssecLabel)")
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 }
557 } 1021 }
558 if !section.wildcardRecords.isEmpty { 1022 for section in dnsRows(from: snapshot) {
559 lines.append("*.\(domain)") 1023 lines.append(" \(section.title)")
560 for record in section.wildcardRecords { 1024 if let message = section.message {
561 lines.append(" \(record.value) TTL \(record.ttl)") 1025 lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)")
562 } 1026 }
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)")
563 } 1041 }
564 } 1042 }
565 1043
566 // PTR 1044 appendSection("Web") {
567 if let ptr = ptrRecord { 1045 if let sslError = snapshot.sslError {
568 lines.append("PTR (Reverse DNS)") 1046 lines.append(" TLS Error: \(sslError)")
569 lines.append(" \(ptr)") 1047 } else {
570 } 1048 for row in webCertificateRows(from: snapshot) {
1049 lines.append(" \(row.label): \(row.value)")
1050 }
1051 }
571 1052
572 // Email Security 1053 if let httpHeadersError = snapshot.httpHeadersError {
573 if let email = emailSecurity { 1054 lines.append(" Headers Error: \(httpHeadersError)")
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))"
582 } else { 1055 } 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 }
584 } 1067 }
585 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") 1068
586 let mtaDescription = if let mode = email.mtaSts?.policyMode { 1069 if let redirectChainError = snapshot.redirectChainError {
587 "mode: \(mode)" 1070 lines.append(" Redirect Error: \(redirectChainError)")
588 } else if email.mtaSts?.txtFound == true { 1071 } else if snapshot.redirectChain.isEmpty {
589 "Policy unavailable" 1072 lines.append(" Redirects: No redirect data available")
590 } else { 1073 } 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 }
592 } 1078 }
593 lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)")
594 lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")")
595 } 1079 }
596 1080
597 // SSL 1081 appendSection("Email") {
598 if let info = sslInfo { 1082 if let emailSecurityError = snapshot.emailSecurityError {
599 let certDateFmt = DateFormatter() 1083 lines.append(" Error: \(emailSecurityError)")
600 certDateFmt.dateStyle = .medium 1084 } else if emailRows(from: snapshot).isEmpty {
601 certDateFmt.timeStyle = .none 1085 lines.append(" No email security records found")
602 1086 } else {
603 lines.append("") 1087 for row in emailRows(from: snapshot) {
604 lines.append("SSL / TLS Certificate") 1088 lines.append(" \(row.label): \(row.status)")
605 lines.append("---------------------") 1089 lines.append(" \(row.detail)")
606 lines.append("Common Name: \(info.commonName)") 1090 if let auxiliaryDetail = row.auxiliaryDetail {
607 lines.append("Issuer: \(info.issuer)") 1091 lines.append(" \(auxiliaryDetail)")
608 lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))") 1092 }
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)")
627 } 1093 }
628 } 1094 }
629 } else if let error = sslError {
630 lines.append("")
631 lines.append("SSL / TLS Certificate")
632 lines.append("---------------------")
633 lines.append("Error: \(error)")
634 } 1095 }
635 1096
636 // HTTP Headers 1097 appendSection("Network") {
637 if !httpHeaders.isEmpty { 1098 if let reachabilityError = snapshot.reachabilityError {
638 lines.append("") 1099 lines.append(" Reachability Error: \(reachabilityError)")
639 lines.append("HTTP Headers") 1100 } else if reachabilityRows(from: snapshot).isEmpty {
640 lines.append("------------") 1101 lines.append(" Reachability: No results")
641 for header in httpHeaders { 1102 } else {
642 lines.append(" \(header.name): \(header.value)") 1103 lines.append(" Reachability:")
643 } 1104 for row in reachabilityRows(from: snapshot) {
644 if let httpSecurityGrade { 1105 lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)")
645 lines.append("Grade: \(httpSecurityGrade)") 1106 }
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)")
655 } 1107 }
656 if http3Advertised { 1108
657 lines.append("HTTP/3 Advertised: Yes") 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 }
658 } 1118 }
659 } else if let error = httpHeadersError {
660 lines.append("")
661 lines.append("HTTP Headers")
662 lines.append("------------")
663 lines.append("Error: \(error)")
664 }
665 1119
666 // IP Geolocation 1120 if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty {
667 if let geo = ipGeolocation { 1121 lines.append(" Port Scan Error: \(portScanError)")
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)")
677 } 1122 }
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 }
684 1123
685 // Open Ports 1124 lines.append(" Standard Ports:")
686 if !portScanResults.isEmpty { 1125 let standardRows = portRows(from: snapshot, kind: .standard)
687 lines.append("") 1126 if standardRows.isEmpty {
688 lines.append("Open Ports") 1127 lines.append(" No results")
689 lines.append("----------")
690 let openPorts = portScanResults.filter { $0.open }
691 if openPorts.isEmpty {
692 lines.append(" No open ports detected")
693 } else { 1128 } else {
694 for port in openPorts { 1129 for row in standardRows {
695 let bannerSuffix = port.banner.map { " \($0)" } ?? "" 1130 lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")")
696 lines.append(" \(port.port) \(port.service)\(bannerSuffix)") 1131 if let banner = row.banner {
1132 lines.append(" Banner: \(banner)")
1133 }
697 } 1134 }
698 } 1135 }
699 let closedPorts = portScanResults.filter { !$0.open } 1136
700 if !closedPorts.isEmpty { 1137 lines.append(" Custom Ports:")
701 lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))") 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 }
702 } 1148 }
703 } 1149 }
704 1150
705 return lines.joined(separator: "\n") 1151 return lines.joined(separator: "\n")
706 } 1152 }
707 1153
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 }
709 1157
710 private func addRecentSearch(_ domain: String) { 1158 private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? {
711 recentSearches.removeAll { $0.lowercased() == domain.lowercased() } 1159 snapshot.redirectChain.last?.url
712 recentSearches.insert(domain, at: 0) 1160 }
713 if recentSearches.count > Self.maxRecent { 1161
714 recentSearches = Array(recentSearches.prefix(Self.maxRecent)) 1162 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
1163 if snapshot.sslInfo != nil {
1164 return "Valid"
715 } 1165 }
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"
717 } 1170 }
718 1171
719 func clearRecentSearches() { 1172 private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone {
720 recentSearches.removeAll() 1173 if snapshot.sslInfo != nil {
721 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) 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
722 } 1211 }
723} 1212}
DomainDig/EmailSecurityService.swift +5 −2
@@ -8,7 +8,7 @@ struct EmailSecurityService {
8 8
9 /// Analyze email security records. SPF is parsed from existing TXT records; 9 /// Analyze email security records. SPF is parsed from existing TXT records;
10 /// DMARC and DKIM require additional DoH queries. 10 /// 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> {
12 // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup 12 // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup
13 // in case the earlier DNS section missed or normalized the record differently. 13 // in case the earlier DNS section missed or normalized the record differently.
14 let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value 14 let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value
@@ -49,13 +49,16 @@ struct EmailSecurityService {
49 value: bimiValue 49 value: bimiValue
50 ) 50 )
51 51
52 return EmailSecurityResult( 52 let result = EmailSecurityResult(
53 spf: spf, 53 spf: spf,
54 dmarc: dmarc, 54 dmarc: dmarc,
55 dkim: dkim, 55 dkim: dkim,
56 bimi: bimi, 56 bimi: bimi,
57 mtaSts: mtaSts 57 mtaSts: mtaSts
58 ) 58 )
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")
59 } 62 }
60 63
61 /// Query a TXT record for the given subdomain via DoH. 64 /// Query a TXT record for the given subdomain via DoH.
DomainDig/HTTPHeadersService.swift +33 −28
@@ -35,41 +35,46 @@ struct HTTPHeadersResult {
35} 35}
36 36
37struct HTTPHeadersService { 37struct HTTPHeadersService {
38 static func fetch(domain: String) async throws -> HTTPHeadersResult { 38 static func fetch(domain: String) async -> ServiceResult<HTTPHeadersResult> {
39 let url = URL(string: "https://\(domain)")! 39 let url = URL(string: "https://\(domain)")!
40 var request = URLRequest(url: url, timeoutInterval: 10) 40 var request = URLRequest(url: url, timeoutInterval: 10)
41 request.httpMethod = "HEAD" 41 request.httpMethod = "HEAD"
42 let metricsDelegate = TaskMetricsDelegate() 42 let metricsDelegate = TaskMetricsDelegate()
43 let startTime = Date() 43 let startTime = Date()
44 44
45 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) 45 do {
46 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) 46 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
47 47 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))
48 guard let httpResponse = response as? HTTPURLResponse else { 48
49 throw URLError(.badServerResponse) 49 guard let httpResponse = response as? HTTPURLResponse else {
50 } 50 return .error(URLError(.badServerResponse).localizedDescription)
51 51 }
52 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in 52
53 guard let name = entry.key as? String, 53 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
54 let value = entry.value as? String else { return nil } 54 guard let name = entry.key as? String,
55 return HTTPHeader(name: name, value: value) 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)
56 } 77 }
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 )
73 } 78 }
74 79
75 private static func protocolLabel(for networkProtocolName: String?) -> String? { 80 private static func protocolLabel(for networkProtocolName: String?) -> String? {
DomainDig/HistoryView.swift +93 −469
@@ -1,15 +1,13 @@
1import SwiftUI 1import SwiftUI
2import MapKit
3 2
4struct HistoryView: View { 3struct HistoryView: View {
5 @Bindable var viewModel: DomainViewModel 4 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7 5
8 private let dateFmt: DateFormatter = { 6 private let dateFormatter: DateFormatter = {
9 let f = DateFormatter() 7 let formatter = DateFormatter()
10 f.dateStyle = .medium 8 formatter.dateStyle = .medium
11 f.timeStyle = .short 9 formatter.timeStyle = .short
12 return f 10 return formatter
13 }() 11 }()
14 12
15 var body: some View { 13 var body: some View {
@@ -22,15 +20,22 @@ struct HistoryView: View {
22 } else { 20 } else {
23 ForEach(viewModel.history) { entry in 21 ForEach(viewModel.history) { entry in
24 NavigationLink { 22 NavigationLink {
25 HistoryDetailView(entry: entry) 23 HistoryDetailView(viewModel: viewModel, entry: entry)
26 } label: { 24 } label: {
27 VStack(alignment: .leading, spacing: 2) { 25 VStack(alignment: .leading, spacing: 4) {
28 Text(entry.domain) 26 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced)) 27 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary) 28 .foregroundStyle(.primary)
31 Text(dateFmt.string(from: entry.timestamp)) 29 HStack(spacing: 8) {
32 .font(.system(.caption2, design: .monospaced)) 30 Text(dateFormatter.string(from: entry.timestamp))
33 .foregroundStyle(.secondary) 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)
34 } 39 }
35 } 40 }
36 .listRowBackground(Color(.systemGray6).opacity(0.5)) 41 .listRowBackground(Color(.systemGray6).opacity(0.5))
@@ -52,47 +57,102 @@ struct HistoryView: View {
52 } 57 }
53} 58}
54 59
55// MARK: - History Detail View (Read-Only Cached Results)
56
57struct HistoryDetailView: View { 60struct HistoryDetailView: View {
61 @Bindable var viewModel: DomainViewModel
58 let entry: HistoryEntry 62 let entry: HistoryEntry
59 63
60 private let dateFmt: DateFormatter = { 64 private let dateFormatter: DateFormatter = {
61 let f = DateFormatter() 65 let formatter = DateFormatter()
62 f.dateStyle = .medium 66 formatter.dateStyle = .medium
63 f.timeStyle = .short 67 formatter.timeStyle = .short
64 return f 68 return formatter
65 }() 69 }()
66 70
71 private var snapshot: LookupSnapshot {
72 entry.snapshot
73 }
74
67 var body: some View { 75 var body: some View {
68 ScrollView(.vertical) { 76 ScrollView(.vertical) {
69 VStack(alignment: .leading, spacing: 0) { 77 VStack(alignment: .leading, spacing: 0) {
70 cachedBanner 78 snapshotBanner
71 reachabilitySection 79 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
72 redirectChainSection 80 .padding(.top, 8)
73 dnsSection 81 DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot))
74 emailSecuritySection 82 .padding(.top, 16)
75 sslSection 83 DNSSectionView(
76 httpHeadersSection 84 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
77 ipGeolocationSection 85 sections: DomainViewModel.dnsRows(from: snapshot),
78 portScanSection 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)
79 } 132 }
80 .padding(.horizontal) 133 .padding(.horizontal)
81 .padding(.bottom, 32) 134 .padding(.bottom, 32)
82 } 135 }
83 .background(Color.black) 136 .background(Color.black)
84 .navigationTitle(entry.domain) 137 .navigationTitle(entry.domain)
138 .toolbar {
139 Button("Re-run") {
140 viewModel.rerunLookup(from: entry)
141 }
142 }
85 .preferredColorScheme(.dark) 143 .preferredColorScheme(.dark)
86 } 144 }
87 145
88 // MARK: - Cached Banner 146 private var snapshotBanner: some View {
89 147 HStack(spacing: 8) {
90 private var cachedBanner: some View {
91 HStack(spacing: 6) {
92 Image(systemName: "archivebox") 148 Image(systemName: "archivebox")
93 .font(.caption) 149 .font(.caption)
94 Text("Cached result from \(dateFmt.string(from: entry.timestamp))") 150 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
95 .font(.system(.caption, design: .monospaced)) 151 .font(.system(.caption, design: .monospaced))
152 Spacer()
153 Text("Live re-run available")
154 .font(.system(.caption2, design: .monospaced))
155 .foregroundStyle(.secondary)
96 } 156 }
97 .foregroundStyle(.secondary) 157 .foregroundStyle(.secondary)
98 .padding(8) 158 .padding(8)
@@ -101,440 +161,4 @@ struct HistoryDetailView: View {
101 .cornerRadius(6) 161 .cornerRadius(6)
102 .padding(.vertical, 12) 162 .padding(.vertical, 12)
103 } 163 }
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 }
540} 164}
DomainDig/IPGeolocationService.swift +12 −7
@@ -1,17 +1,22 @@
1import Foundation 1import Foundation
2 2
3struct IPGeolocationService { 3struct IPGeolocationService {
4 static func lookup(ip: String) async throws -> IPGeolocation { 4 static func lookup(ip: String) async -> ServiceResult<IPGeolocation> {
5 let url = URL(string: "https://ipapi.co/\(ip)/json/")! 5 let url = URL(string: "https://ipapi.co/\(ip)/json/")!
6 let request = URLRequest(url: url, timeoutInterval: 10) 6 let request = URLRequest(url: url, timeoutInterval: 10)
7 7
8 let (data, response) = try await URLSession.shared.data(for: request) 8 do {
9 let (data, response) = try await URLSession.shared.data(for: request)
9 10
10 guard let httpResponse = response as? HTTPURLResponse, 11 guard let httpResponse = response as? HTTPURLResponse,
11 httpResponse.statusCode == 200 else { 12 httpResponse.statusCode == 200 else {
12 throw URLError(.badServerResponse) 13 return .error(URLError(.badServerResponse).localizedDescription)
13 } 14 }
14 15
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 }
16 } 21 }
17} 22}
DomainDig/Models.swift +66 −3
@@ -1,5 +1,11 @@
1import Foundation 1import Foundation
2 2
3enum ServiceResult<Value> {
4 case success(Value)
5 case empty(String)
6 case error(String)
7}
8
3// MARK: - DNS Models 9// MARK: - DNS Models
4 10
5enum DNSRecordType: String, CaseIterable, Codable { 11enum DNSRecordType: String, CaseIterable, Codable {
@@ -13,6 +19,7 @@ enum DNSRecordType: String, CaseIterable, Codable {
13 case SRV 19 case SRV
14 case CAA 20 case CAA
15 case DS 21 case DS
22 case PTR
16 23
17 var queryType: Int { 24 var queryType: Int {
18 switch self { 25 switch self {
@@ -26,6 +33,7 @@ enum DNSRecordType: String, CaseIterable, Codable {
26 case .SRV: return 33 33 case .SRV: return 33
27 case .CAA: return 257 34 case .CAA: return 257
28 case .DS: return 43 35 case .DS: return 43
36 case .PTR: return 12
29 } 37 }
30 } 38 }
31 39
@@ -223,18 +231,34 @@ struct RedirectHop: Identifiable, Codable {
223 231
224// MARK: - Port Scan Models 232// MARK: - Port Scan Models
225 233
234enum PortScanKind: String, Codable {
235 case standard
236 case custom
237}
238
226struct PortScanResult: Identifiable, Codable { 239struct PortScanResult: Identifiable, Codable {
227 var id = UUID() 240 var id = UUID()
228 let port: UInt16 241 let port: UInt16
229 let service: String 242 let service: String
230 let open: Bool 243 let open: Bool
231 var banner: String? 244 var banner: String?
232 245 let kind: PortScanKind
233 nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) { 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 ) {
234 self.port = port 256 self.port = port
235 self.service = service 257 self.service = service
236 self.open = open 258 self.open = open
237 self.banner = banner 259 self.banner = banner
260 self.kind = kind
261 self.durationMs = durationMs
238 } 262 }
239 263
240 init(from decoder: Decoder) throws { 264 init(from decoder: Decoder) throws {
@@ -244,6 +268,8 @@ struct PortScanResult: Identifiable, Codable {
244 service = try container.decode(String.self, forKey: .service) 268 service = try container.decode(String.self, forKey: .service)
245 open = try container.decode(Bool.self, forKey: .open) 269 open = try container.decode(Bool.self, forKey: .open)
246 banner = try container.decodeIfPresent(String.self, forKey: .banner) 270 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)
247 } 273 }
248} 274}
249 275
@@ -264,13 +290,28 @@ struct HistoryEntry: Identifiable, Codable {
264 var redirectChain: [RedirectHop] 290 var redirectChain: [RedirectHop]
265 var portScanResults: [PortScanResult] 291 var portScanResults: [PortScanResult]
266 var hstsPreloaded: Bool? 292 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?
267 304
268 init(domain: String, timestamp: Date, dnsSections: [DNSSection], 305 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
269 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], 306 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
270 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, 307 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
271 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, 308 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
272 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], 309 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) {
274 self.domain = domain 315 self.domain = domain
275 self.timestamp = timestamp 316 self.timestamp = timestamp
276 self.dnsSections = dnsSections 317 self.dnsSections = dnsSections
@@ -284,6 +325,17 @@ struct HistoryEntry: Identifiable, Codable {
284 self.redirectChain = redirectChain 325 self.redirectChain = redirectChain
285 self.portScanResults = portScanResults 326 self.portScanResults = portScanResults
286 self.hstsPreloaded = hstsPreloaded 327 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
287 } 339 }
288 340
289 init(from decoder: Decoder) throws { 341 init(from decoder: Decoder) throws {
@@ -302,6 +354,17 @@ struct HistoryEntry: Identifiable, Codable {
302 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] 354 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
303 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] 355 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
304 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) 356 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)
305 } 368 }
306} 369}
307 370
DomainDig/PortScanService.swift +41 −13
@@ -20,12 +20,18 @@ struct PortScanService {
20 PortInfo(port: 8443, service: "HTTPS Alt"), 20 PortInfo(port: 8443, service: "HTTPS Alt"),
21 ] 21 ]
22 22
23 static func scanAll(domain: String) async -> [PortScanResult] { 23 static func scanAll(domain: String) async -> ServiceResult<[PortScanResult]> {
24 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in 24 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
25 for info in ports { 25 for info in ports {
26 group.addTask { 26 group.addTask {
27 let open = await probe(domain: domain, port: info.port) 27 let result = await probe(domain: domain, port: info.port)
28 return PortScanResult(port: info.port, service: info.service, open: open) 28 return PortScanResult(
29 port: info.port,
30 service: info.service,
31 open: result.open,
32 kind: .standard,
33 durationMs: result.durationMs
34 )
29 } 35 }
30 } 36 }
31 37
@@ -35,20 +41,24 @@ struct PortScanService {
35 } 41 }
36 42
37 // Sort by port number 43 // 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
39 } 46 }
47 .pipe { $0.isEmpty ? .empty("No port scan results") : .success($0) }
40 } 48 }
41 49
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]> {
43 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in 51 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
44 for port in ports { 52 for port in ports {
45 let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom" 53 let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom"
46 group.addTask { 54 group.addTask {
47 let open = await probe(domain: domain, port: port, timeout: timeout) 55 let result = await probe(domain: domain, port: port, timeout: timeout)
48 return PortScanResult( 56 return PortScanResult(
49 port: port, 57 port: port,
50 service: service, 58 service: service,
51 open: open 59 open: result.open,
60 kind: .custom,
61 durationMs: result.durationMs
52 ) 62 )
53 } 63 }
54 } 64 }
@@ -58,8 +68,10 @@ struct PortScanService {
58 results.append(result) 68 results.append(result)
59 } 69 }
60 70
61 return results.sorted { $0.port < $1.port } 71 let sorted = results.sorted { $0.port < $1.port }
72 return sorted.isEmpty ? [] : sorted
62 } 73 }
74 .pipe { $0.isEmpty ? .empty("No custom port scan results") : .success($0) }
63 } 75 }
64 76
65 static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? { 77 static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? {
@@ -111,11 +123,11 @@ struct PortScanService {
111 } 123 }
112 } 124 }
113 125
114 private static func probe(domain: String, port: UInt16) async -> Bool { 126 private static func probe(domain: String, port: UInt16) async -> PortProbeResult {
115 await probe(domain: domain, port: port, timeout: 5) 127 await probe(domain: domain, port: port, timeout: 5)
116 } 128 }
117 129
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 {
119 await withCheckedContinuation { continuation in 131 await withCheckedContinuation { continuation in
120 let host = NWEndpoint.Host(domain) 132 let host = NWEndpoint.Host(domain)
121 let nwPort = NWEndpoint.Port(rawValue: port)! 133 let nwPort = NWEndpoint.Port(rawValue: port)!
@@ -143,13 +155,19 @@ struct PortScanService {
143 } 155 }
144} 156}
145 157
158private struct PortProbeResult: Sendable {
159 let open: Bool
160 let durationMs: Int?
161}
162
146private final class ProbeContext: @unchecked Sendable { 163private final class ProbeContext: @unchecked Sendable {
147 private let connection: NWConnection 164 private let connection: NWConnection
148 private let continuation: CheckedContinuation<Bool, Never> 165 private let continuation: CheckedContinuation<PortProbeResult, Never>
166 private let start = CFAbsoluteTimeGetCurrent()
149 private let lock = NSLock() 167 private let lock = NSLock()
150 private nonisolated(unsafe) var resumed = false 168 private nonisolated(unsafe) var resumed = false
151 169
152 init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) { 170 init(connection: NWConnection, continuation: CheckedContinuation<PortProbeResult, Never>) {
153 self.connection = connection 171 self.connection = connection
154 self.continuation = continuation 172 self.continuation = continuation
155 } 173 }
@@ -164,7 +182,17 @@ private final class ProbeContext: @unchecked Sendable {
164 lock.unlock() 182 lock.unlock()
165 183
166 connection.cancel() 184 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)
168 } 196 }
169} 197}
170 198
DomainDig/ReachabilityService.swift +3 −2
@@ -29,10 +29,11 @@ struct ReachabilityService {
29 } 29 }
30 } 30 }
31 31
32 static func checkAll(domain: String) async -> [PortReachability] { 32 static func checkAll(domain: String) async -> ServiceResult<[PortReachability]> {
33 async let port443 = check(domain: domain, port: 443) 33 async let port443 = check(domain: domain, port: 443)
34 async let port80 = check(domain: domain, port: 80) 34 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)
36 } 37 }
37} 38}
38 39
DomainDig/RedirectChainService.swift +9 −4
@@ -1,12 +1,17 @@
1import Foundation 1import Foundation
2 2
3struct RedirectChainService { 3struct RedirectChainService {
4 static func trace(domain: String) async throws -> [RedirectHop] { 4 static func trace(domain: String) async -> ServiceResult<[RedirectHop]> {
5 // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely
6 do { 5 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)
8 } catch { 8 } 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 }
10 } 15 }
11 } 16 }
12 17
DomainDig/ReverseDNSService.swift +14 −35
@@ -1,48 +1,27 @@
1import Foundation 1import Foundation
2 2
3struct ReverseDNSService { 3struct ReverseDNSService {
4 /// Look up the PTR record for an IPv4 address via Cloudflare DoH. 4 static func lookup(ip: String, resolverURLString: String) async -> ServiceResult<String> {
5 static func lookup(ip: String) async -> String? {
6 let octets = ip.split(separator: ".") 5 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 }
8 9
9 let reversed = octets.reversed().joined(separator: ".") 10 let reversed = octets.reversed().joined(separator: ".")
10 let ptrDomain = "\(reversed).in-addr.arpa" 11 let ptrDomain = "\(reversed).in-addr.arpa"
11 12
12 // PTR record type = 12
13 do { 13 do {
14 let records = try await lookupPTR(domain: ptrDomain) 14 let records = try await DNSLookupService.lookup(
15 return records.first 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")
16 } catch { 23 } catch {
17 return nil 24 return .error(error.localizedDescription)
18 } 25 }
19 } 26 }
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 }
48} 27}
DomainDig/SSLCheckService.swift +10 −7
@@ -3,7 +3,7 @@ import Security
3 3
4struct SSLCheckService { 4struct SSLCheckService {
5 5
6 static func check(domain: String) async throws -> SSLCertificateInfo { 6 static func check(domain: String) async -> ServiceResult<SSLCertificateInfo> {
7 let delegate = SSLSessionDelegate() 7 let delegate = SSLSessionDelegate()
8 let session = URLSession( 8 let session = URLSession(
9 configuration: .ephemeral, 9 configuration: .ephemeral,
@@ -15,14 +15,17 @@ struct SSLCheckService {
15 let url = URL(string: "https://\(domain)")! 15 let url = URL(string: "https://\(domain)")!
16 let request = URLRequest(url: url, timeoutInterval: 10) 16 let request = URLRequest(url: url, timeoutInterval: 10)
17 17
18 // We only need to establish the connection to grab the cert 18 do {
19 _ = try await session.data(for: request) 19 _ = try await session.data(for: request)
20 20
21 guard let trust = delegate.serverTrust else { 21 guard let trust = delegate.serverTrust else {
22 throw SSLError.noCertificate 22 return .empty(SSLError.noCertificate.localizedDescription)
23 } 23 }
24 24
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 }
26 } 29 }
27 30
28 static func checkHSTSPreload(domain: String) async -> Bool? { 31 static func checkHSTSPreload(domain: String) async -> Bool? {