krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.7.0: DomainDig/ContentView.swift · raw
1import SwiftUI
2import MapKit
3
4struct ContentView: View {
5 @State private var viewModel = DomainViewModel()
6 @FocusState private var domainFieldFocused: Bool
7 @State private var customPortInput = ""
8 @State private var customPortsExpanded = false
9
10 var body: some View {
11 NavigationStack {
12 ScrollView(.vertical) {
13 VStack(spacing: 0) {
14 inputSection
15 if viewModel.hasRun {
16 actionButtons
17 reachabilitySection
18 redirectChainSection
19 dnsResultsSection
20 emailSecuritySection
21 sslResultsSection
22 httpHeadersSection
23 ipGeolocationSection
24 portScanSection
25 } else if !viewModel.recentSearches.isEmpty {
26 recentSearchesSection
27 }
28 }
29 .padding(.horizontal)
30 .padding(.bottom, 32)
31 }
32 .background(Color.black)
33 .navigationTitle("DomainDig")
34 .toolbarColorScheme(.dark, for: .navigationBar)
35 .preferredColorScheme(.dark)
36 .toolbar {
37 ToolbarItemGroup(placement: .topBarTrailing) {
38 if viewModel.hasRun {
39 Button {
40 viewModel.reset()
41 } label: {
42 Image(systemName: "xmark.circle")
43 .foregroundStyle(.secondary)
44 }
45 }
46 NavigationLink {
47 SavedDomainsView(viewModel: viewModel)
48 } label: {
49 Image(systemName: "bookmark")
50 .foregroundStyle(.secondary)
51 }
52 NavigationLink {
53 HistoryView(viewModel: viewModel)
54 } label: {
55 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
56 .foregroundStyle(.secondary)
57 }
58 }
59 ToolbarItem(placement: .topBarTrailing) {
60 NavigationLink {
61 SettingsView()
62 } label: {
63 Image(systemName: "gearshape")
64 .foregroundStyle(.secondary)
65 }
66 }
67 }
68 }
69 .onAppear {
70 domainFieldFocused = true
71 }
72 }
73
74 // MARK: - Input
75
76 private var inputSection: some View {
77 VStack(spacing: 12) {
78 TextField("e.g. cleberg.net", text: $viewModel.domain)
79 .font(.system(.title3, design: .monospaced))
80 .textInputAutocapitalization(.never)
81 .autocorrectionDisabled()
82 .keyboardType(.URL)
83 .padding(12)
84 .background(Color(.systemGray6))
85 .cornerRadius(8)
86 .focused($domainFieldFocused)
87 .onSubmit { viewModel.run() }
88
89 Button {
90 domainFieldFocused = false
91 viewModel.run()
92 } label: {
93 Text("Run")
94 .font(.headline)
95 .frame(maxWidth: .infinity)
96 .padding(.vertical, 12)
97 }
98 .buttonStyle(.borderedProminent)
99 .disabled(viewModel.trimmedDomain.isEmpty)
100 }
101 .padding(.vertical, 16)
102 }
103
104 // MARK: - Action Buttons (Share + Bookmark)
105
106 private var actionButtons: some View {
107 HStack {
108 Spacer()
109 if viewModel.resultsLoaded {
110 Button {
111 viewModel.toggleSavedDomain()
112 } label: {
113 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
114 .font(.system(.body))
115 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
116 }
117 Button {
118 shareResults()
119 } label: {
120 Image(systemName: "square.and.arrow.up")
121 .font(.system(.body))
122 .foregroundStyle(.secondary)
123 }
124 }
125 }
126 .padding(.top, 8)
127 }
128
129 // MARK: - Recent Searches
130
131 private var recentSearchesSection: some View {
132 VStack(alignment: .leading, spacing: 8) {
133 HStack {
134 Text("RECENT")
135 .font(.system(.caption2, design: .monospaced))
136 .foregroundStyle(.secondary)
137 Spacer()
138 Button("Clear") {
139 viewModel.clearRecentSearches()
140 }
141 .font(.system(.caption2, design: .monospaced))
142 .foregroundStyle(.secondary)
143 }
144
145 ForEach(viewModel.recentSearches, id: \.self) { domain in
146 Button {
147 viewModel.domain = domain
148 domainFieldFocused = false
149 viewModel.run()
150 } label: {
151 Text(domain)
152 .font(.system(.callout, design: .monospaced))
153 .foregroundStyle(.primary)
154 .frame(maxWidth: .infinity, alignment: .leading)
155 .padding(.vertical, 6)
156 .padding(.horizontal, 10)
157 .background(Color(.systemGray6).opacity(0.5))
158 .cornerRadius(6)
159 }
160 }
161 }
162 .padding(.top, 8)
163 }
164
165 // MARK: - Reachability
166
167 private var reachabilitySection: some View {
168 VStack(alignment: .leading, spacing: 12) {
169 sectionHeader("Reachability")
170
171 if viewModel.reachabilityLoading {
172 ProgressView("Checking ports…")
173 .frame(maxWidth: .infinity, alignment: .center)
174 .padding()
175 } else if let error = viewModel.reachabilityError {
176 errorLabel(error)
177 } else {
178 VStack(alignment: .leading, spacing: 4) {
179 ForEach(viewModel.reachabilityResults) { result in
180 HStack(spacing: 8) {
181 Circle()
182 .fill(result.reachable ? Color.green : Color.red)
183 .frame(width: 8, height: 8)
184 Text("Port \(result.port)")
185 .font(.system(.caption, design: .monospaced))
186 if result.reachable, let ms = result.latencyMs {
187 Text("\(ms)ms")
188 .font(.system(.caption, design: .monospaced))
189 .foregroundStyle(.secondary)
190 } else if !result.reachable {
191 Text("—")
192 .font(.system(.caption, design: .monospaced))
193 .foregroundStyle(.secondary)
194 }
195 Spacer()
196 Text(result.reachable ? "Reachable" : "Unreachable")
197 .font(.system(.caption, design: .monospaced))
198 .foregroundStyle(result.reachable ? .green : .red)
199 }
200 }
201 }
202 .padding(10)
203 .background(Color(.systemGray6).opacity(0.5))
204 .cornerRadius(6)
205 }
206 }
207 .padding(.top, 8)
208 }
209
210 // MARK: - Redirect Chain
211
212 private var redirectChainSection: some View {
213 VStack(alignment: .leading, spacing: 12) {
214 sectionHeader("Redirect Chain")
215
216 if viewModel.redirectChainLoading {
217 ProgressView("Tracing redirects…")
218 .frame(maxWidth: .infinity, alignment: .center)
219 .padding()
220 } else if let error = viewModel.redirectChainError {
221 errorLabel(error)
222 } else if viewModel.redirectChain.isEmpty {
223 Text("No redirect data")
224 .font(.system(.caption, design: .monospaced))
225 .foregroundStyle(.secondary)
226 .padding(8)
227 } else if viewModel.redirectChain.count == 1,
228 let only = viewModel.redirectChain.first,
229 only.isFinal, !(300...399).contains(only.statusCode) {
230 Text("No redirects — direct connection")
231 .font(.system(.caption, design: .monospaced))
232 .foregroundStyle(.secondary)
233 .padding(10)
234 .frame(maxWidth: .infinity, alignment: .leading)
235 .background(Color(.systemGray6).opacity(0.5))
236 .cornerRadius(6)
237 } else {
238 horizontallyScrollableCard {
239 ForEach(viewModel.redirectChain) { hop in
240 HStack(alignment: .top, spacing: 6) {
241 Text("\(hop.stepNumber)")
242 .font(.system(.caption, design: .monospaced))
243 .foregroundStyle(.secondary)
244 .frame(width: 16, alignment: .trailing)
245 Text("\(hop.statusCode)")
246 .font(.system(.caption, design: .monospaced))
247 .foregroundStyle(.cyan)
248 .frame(width: 30, alignment: .leading)
249 Text(hop.url)
250 .font(.system(.caption, design: .monospaced))
251 .foregroundStyle(.primary)
252 .textSelection(.enabled)
253 if hop.isFinal {
254 Text("(final)")
255 .font(.system(.caption2, design: .monospaced))
256 .foregroundStyle(.secondary)
257 }
258 }
259 }
260 }
261 }
262 }
263 .padding(.top, 16)
264 }
265
266 // MARK: - DNS Results
267
268 private var dnsResultsSection: some View {
269 VStack(alignment: .leading, spacing: 12) {
270 HStack(alignment: .top, spacing: 8) {
271 sectionHeader("DNS Records")
272 Spacer()
273 if let dnssecSigned = dnssecStatus {
274 Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗")
275 .font(.system(.caption2, design: .monospaced))
276 .foregroundStyle(dnssecSigned ? .green : .red)
277 .padding(.top, 1)
278 }
279 }
280
281 if viewModel.dnsLoading {
282 ProgressView("Querying DNS…")
283 .frame(maxWidth: .infinity, alignment: .center)
284 .padding()
285 } else if let error = viewModel.dnsError {
286 errorLabel(error)
287 } else {
288 ForEach(viewModel.dnsSections) { section in
289 dnsRecordSection(section)
290 if section.recordType == .A {
291 ptrRow
292 }
293 }
294 }
295 }
296 .padding(.top, 16)
297 }
298
299 private var ptrRow: some View {
300 horizontallyScrollableCard {
301 Text("PTR (Reverse DNS)")
302 .font(.system(.subheadline, design: .monospaced))
303 .fontWeight(.semibold)
304 .foregroundStyle(.cyan)
305
306 if viewModel.ptrLoading {
307 ProgressView()
308 .frame(maxWidth: .infinity, alignment: .center)
309 .padding(4)
310 } else if let ptr = viewModel.ptrRecord {
311 Text(ptr)
312 .font(.system(.caption, design: .monospaced))
313 .foregroundStyle(.primary)
314 .textSelection(.enabled)
315 } else {
316 Text("No PTR record found")
317 .font(.system(.caption, design: .monospaced))
318 .foregroundStyle(.secondary)
319 }
320 }
321 }
322
323 private func dnsRecordSection(_ section: DNSSection) -> some View {
324 horizontallyScrollableCard {
325 Text(section.recordType.rawValue)
326 .font(.system(.subheadline, design: .monospaced))
327 .fontWeight(.semibold)
328 .foregroundStyle(.cyan)
329
330 if let error = section.error {
331 errorLabel(error)
332 } else if section.records.isEmpty {
333 Text("No records found")
334 .font(.system(.caption, design: .monospaced))
335 .foregroundStyle(.secondary)
336 } else {
337 dnsRecordRows(section.records)
338 }
339
340 if !section.wildcardRecords.isEmpty {
341 Text("*.\(viewModel.searchedDomain)")
342 .font(.system(.caption, design: .monospaced))
343 .fontWeight(.medium)
344 .foregroundStyle(.cyan.opacity(0.7))
345 .padding(.top, 4)
346
347 dnsRecordRows(section.wildcardRecords)
348 }
349 }
350 }
351
352 private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
353 ForEach(records) { record in
354 HStack(alignment: .top) {
355 Text(record.value)
356 .font(.system(.caption, design: .monospaced))
357 .foregroundStyle(.primary)
358 .textSelection(.enabled)
359 Spacer()
360 Text("TTL \(record.ttl)")
361 .font(.system(.caption2, design: .monospaced))
362 .foregroundStyle(.secondary)
363 }
364 }
365 }
366
367 // MARK: - Email Security
368
369 @State private var expandedEmailField: String?
370
371 private var emailSecuritySection: some View {
372 VStack(alignment: .leading, spacing: 12) {
373 sectionHeader("Email Security")
374
375 if viewModel.emailSecurityLoading {
376 ProgressView("Checking email records…")
377 .frame(maxWidth: .infinity, alignment: .center)
378 .padding()
379 } else if let error = viewModel.emailSecurityError {
380 errorLabel(error)
381 } else if let email = viewModel.emailSecurity {
382 horizontallyScrollableCard(spacing: 6) {
383 emailSecurityRow("SPF", record: email.spf)
384 emailSecurityRow("DMARC", record: email.dmarc)
385 emailSecurityRow("DKIM", record: email.dkim)
386 emailSecurityRow("MTA-STS", mtaSts: email.mtaSts)
387 emailSecurityRow("BIMI", record: email.bimi)
388 }
389 }
390 }
391 .frame(maxWidth: .infinity, alignment: .leading)
392 .padding(.top, 16)
393 }
394
395 private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
396 VStack(alignment: .leading, spacing: 2) {
397 HStack(spacing: 8) {
398 Text(label)
399 .font(.system(.caption, design: .monospaced))
400 .fontWeight(.semibold)
401 .frame(width: 72, alignment: .leading)
402 Text(record.found ? "✓" : "✗")
403 .font(.system(.caption, design: .monospaced))
404 .foregroundStyle(record.found ? .green : .red)
405 if let value = record.value {
406 let isExpanded = expandedEmailField == label
407 let displayValue = isExpanded ? value : String(value.prefix(80))
408 Text(displayValue)
409 .font(.system(.caption2, design: .monospaced))
410 .foregroundStyle(.primary)
411 .textSelection(.enabled)
412 .lineLimit(isExpanded ? nil : 1)
413 .onTapGesture {
414 withAnimation {
415 expandedEmailField = isExpanded ? nil : label
416 }
417 }
418 if let selector = record.matchedSelector {
419 Text("(selector: \(selector))")
420 .font(.system(.caption2, design: .monospaced))
421 .foregroundStyle(.secondary)
422 }
423 } else {
424 Text("No record found")
425 .font(.system(.caption2, design: .monospaced))
426 .foregroundStyle(.secondary)
427 }
428 }
429 }
430 }
431
432 private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
433 VStack(alignment: .leading, spacing: 2) {
434 HStack(spacing: 8) {
435 Text(label)
436 .font(.system(.caption, design: .monospaced))
437 .fontWeight(.semibold)
438 .frame(width: 72, alignment: .leading)
439 Text(mtaSts?.txtFound == true ? "✓" : "✗")
440 .font(.system(.caption, design: .monospaced))
441 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
442 if let policyMode = mtaSts?.policyMode {
443 Text(policyMode)
444 .font(.system(.caption2, design: .monospaced))
445 .foregroundStyle(.primary)
446 .textSelection(.enabled)
447 } else {
448 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
449 .font(.system(.caption2, design: .monospaced))
450 .foregroundStyle(.secondary)
451 }
452 }
453 }
454 }
455
456 // MARK: - SSL Results
457
458 private var sslResultsSection: some View {
459 VStack(alignment: .leading, spacing: 12) {
460 sectionHeader("SSL / TLS Certificate")
461
462 if viewModel.sslLoading {
463 ProgressView("Checking certificate…")
464 .frame(maxWidth: .infinity, alignment: .center)
465 .padding()
466 } else if let error = viewModel.sslError {
467 errorLabel(error)
468 } else if let info = viewModel.sslInfo {
469 sslDetail(info, domain: viewModel.searchedDomain)
470 }
471 }
472 .padding(.top, 16)
473 }
474
475 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
476 horizontallyScrollableCard(spacing: 8) {
477 certRow("Common Name", info.commonName)
478 certRow("Issuer", info.issuer)
479
480 VStack(alignment: .leading, spacing: 2) {
481 Text("SANs")
482 .font(.system(.caption2, design: .monospaced))
483 .foregroundStyle(.secondary)
484 ForEach(info.subjectAltNames, id: \.self) { san in
485 Text(san)
486 .font(.system(.caption, design: .monospaced))
487 .textSelection(.enabled)
488 }
489 }
490
491 let formatter = DateFormatter.certDate
492 certRow("Valid From", formatter.string(from: info.validFrom))
493 certRow("Valid Until", formatter.string(from: info.validUntil))
494
495 VStack(alignment: .leading, spacing: 2) {
496 Text("Days Until Expiry")
497 .font(.system(.caption2, design: .monospaced))
498 .foregroundStyle(.secondary)
499 Text("\(info.daysUntilExpiry)")
500 .font(.system(.caption, design: .monospaced))
501 .fontWeight(.bold)
502 .foregroundStyle(expiryColor(info.daysUntilExpiry))
503 }
504
505 certRow("Chain Depth", "\(info.chainDepth)")
506 if viewModel.hstsLoading {
507 hstsLoadingRow
508 } else if let hstsPreloaded = viewModel.hstsPreloaded {
509 hstsStatusRow(hstsPreloaded)
510 }
511 if let tlsVersion = info.tlsVersion {
512 certRow("TLS Version", tlsVersion)
513 }
514 if let cipherSuite = info.cipherSuite {
515 certRow("Cipher Suite", cipherSuite)
516 }
517 if !info.chain.isEmpty {
518 VStack(alignment: .leading, spacing: 6) {
519 Text("Certificate Chain")
520 .font(.system(.caption2, design: .monospaced))
521 .foregroundStyle(.secondary)
522 ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
523 DisclosureGroup {
524 Text(certificate.issuer)
525 .font(.system(.caption, design: .monospaced))
526 .foregroundStyle(.secondary)
527 .textSelection(.enabled)
528 } label: {
529 Text(certificate.subject)
530 .font(.system(.caption, design: .monospaced))
531 .foregroundStyle(.primary)
532 .textSelection(.enabled)
533 }
534 .tint(index == 0 ? .cyan : .secondary)
535 }
536 }
537 }
538 Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
539 .font(.system(.caption, design: .monospaced))
540 .foregroundStyle(.cyan)
541 }
542 }
543
544 // MARK: - HTTP Headers
545
546 private var httpHeadersSection: some View {
547 VStack(alignment: .leading, spacing: 12) {
548 HStack(spacing: 8) {
549 sectionHeader("HTTP Headers")
550 if let grade = viewModel.httpSecurityGrade {
551 Text(grade)
552 .font(.system(.caption, design: .monospaced))
553 .foregroundStyle(httpSecurityGradeColor(for: grade))
554 .padding(.horizontal, 8)
555 .padding(.vertical, 2)
556 .background(httpSecurityGradeColor(for: grade).opacity(0.18))
557 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
558 }
559 Spacer()
560 }
561
562 if viewModel.httpHeadersLoading {
563 ProgressView("Fetching headers…")
564 .frame(maxWidth: .infinity, alignment: .center)
565 .padding()
566 } else if let error = viewModel.httpHeadersError {
567 errorLabel(error)
568 } else {
569 horizontallyScrollableCard {
570 if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil {
571 HStack(alignment: .top, spacing: 0) {
572 ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in
573 if index > 0 {
574 Text(" ")
575 .font(.system(.caption, design: .monospaced))
576 }
577 Text(part.text)
578 .font(.system(.caption, design: .monospaced))
579 .foregroundStyle(part.color)
580 }
581 if let http3AvailabilityNote {
582 Text(" ")
583 .font(.system(.caption, design: .monospaced))
584 Text(http3AvailabilityNote)
585 .font(.system(.caption, design: .monospaced))
586 .foregroundStyle(.secondary)
587 }
588 }
589 }
590 ForEach(viewModel.httpHeaders) { header in
591 HStack(alignment: .top, spacing: 4) {
592 Text(header.name + ":")
593 .font(.system(.caption, design: .monospaced))
594 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
595 Text(header.value)
596 .font(.system(.caption, design: .monospaced))
597 .foregroundStyle(.primary)
598 .textSelection(.enabled)
599 }
600 }
601 }
602 }
603 }
604 .padding(.top, 16)
605 }
606
607 // MARK: - IP Geolocation
608
609 private var ipGeolocationSection: some View {
610 VStack(alignment: .leading, spacing: 12) {
611 sectionHeader("IP Location")
612
613 if viewModel.ipGeolocationLoading {
614 ProgressView("Looking up location…")
615 .frame(maxWidth: .infinity, alignment: .center)
616 .padding()
617 } else if let geo = viewModel.ipGeolocation {
618 ipGeolocationDetail(geo)
619 } else if let error = viewModel.ipGeolocationError {
620 if error == "No A record available" {
621 Text("No location data available")
622 .font(.system(.caption, design: .monospaced))
623 .foregroundStyle(.secondary)
624 .padding(8)
625 } else {
626 errorLabel(error)
627 }
628 }
629 }
630 .padding(.top, 16)
631 }
632
633 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
634 VStack(alignment: .leading, spacing: 6) {
635 horizontallyScrollableContent(spacing: 6) {
636 certRow("IP", geo.ip)
637 if let org = geo.org {
638 certRow("Org / ISP", org)
639 }
640 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
641 if !location.isEmpty {
642 certRow("Location", location)
643 }
644 }
645
646 if let lat = geo.latitude, let lon = geo.longitude {
647 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
648 Map(initialPosition: .region(MKCoordinateRegion(
649 center: coordinate,
650 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
651 ))) {
652 Marker(geo.ip, coordinate: coordinate)
653 }
654 .mapStyle(.standard)
655 .frame(maxWidth: .infinity)
656 .frame(height: 180)
657 .cornerRadius(8)
658 }
659 }
660 .padding(10)
661 .background(Color(.systemGray6).opacity(0.5))
662 .cornerRadius(6)
663 }
664
665 // MARK: - Port Scan
666
667 private var portScanSection: some View {
668 VStack(alignment: .leading, spacing: 12) {
669 sectionHeader("Open Ports")
670
671 if viewModel.portScanLoading {
672 ProgressView("Scanning ports…")
673 .frame(maxWidth: .infinity, alignment: .center)
674 .padding()
675 } else if let error = viewModel.portScanError {
676 errorLabel(error)
677 } else {
678 VStack(alignment: .leading, spacing: 12) {
679 if viewModel.isCloudflareProxied {
680 Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.")
681 .font(.system(.caption2, design: .monospaced))
682 .foregroundStyle(.orange)
683 .padding(8)
684 .background(Color.orange.opacity(0.1))
685 .cornerRadius(6)
686 }
687 portScanResultsCard(viewModel.portScanResults)
688
689 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
690 VStack(alignment: .leading, spacing: 10) {
691 TextField("8888, 9000, 27017", text: $customPortInput)
692 .font(.system(.caption, design: .monospaced))
693 .textInputAutocapitalization(.never)
694 .autocorrectionDisabled()
695 .keyboardType(.numberPad)
696 .padding(10)
697 .background(Color(.systemGray6).opacity(0.5))
698 .cornerRadius(6)
699
700 Button("Scan") {
701 let ports = parsedCustomPorts(from: customPortInput)
702 Task {
703 await viewModel.runCustomPortScan(ports: ports)
704 }
705 }
706 .buttonStyle(.borderedProminent)
707 .tint(.blue)
708 .disabled(viewModel.customPortScanLoading)
709
710 if viewModel.customPortScanLoading {
711 ProgressView("Scanning custom ports…")
712 .font(.system(.caption, design: .monospaced))
713 } else if let error = viewModel.customPortScanError {
714 errorLabel(error)
715 } else if !viewModel.customPortResults.isEmpty {
716 portScanResultsCard(viewModel.customPortResults)
717 }
718 }
719 .padding(.top, 8)
720 }
721 .font(.system(.caption, design: .monospaced))
722 .tint(.secondary)
723 }
724 .padding(10)
725 .background(Color(.systemGray6).opacity(0.5))
726 .cornerRadius(6)
727 }
728 }
729 .padding(.top, 16)
730 }
731
732 // MARK: - Helpers
733
734 private func sectionHeader(_ title: String) -> some View {
735 Text(title)
736 .font(.system(.headline, design: .default))
737 .foregroundStyle(.white)
738 }
739
740 private var dnssecStatus: Bool? {
741 viewModel.dnsSections.compactMap(\.dnssecSigned).first
742 }
743
744 private func certRow(_ label: String, _ value: String) -> some View {
745 VStack(alignment: .leading, spacing: 2) {
746 Text(label)
747 .font(.system(.caption2, design: .monospaced))
748 .foregroundStyle(.secondary)
749 Text(value)
750 .font(.system(.caption, design: .monospaced))
751 .textSelection(.enabled)
752 }
753 }
754
755 private var hstsLoadingRow: some View {
756 VStack(alignment: .leading, spacing: 2) {
757 Text("HSTS Preload")
758 .font(.system(.caption2, design: .monospaced))
759 .foregroundStyle(.secondary)
760 ProgressView()
761 .controlSize(.small)
762 }
763 }
764
765 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
766 VStack(alignment: .leading, spacing: 2) {
767 Text("HSTS Preload")
768 .font(.system(.caption2, design: .monospaced))
769 .foregroundStyle(.secondary)
770 Text(isPreloaded ? "Preloaded" : "Not preloaded")
771 .font(.system(.caption, design: .monospaced))
772 .foregroundStyle(isPreloaded ? .green : .secondary)
773 }
774 }
775
776 private func horizontallyScrollableCard<Content: View>(
777 spacing: CGFloat = 4,
778 @ViewBuilder content: () -> Content
779 ) -> some View {
780 horizontallyScrollableContent(spacing: spacing) {
781 content()
782 }
783 .padding(10)
784 .background(Color(.systemGray6).opacity(0.5))
785 .cornerRadius(6)
786 }
787
788 private func horizontallyScrollableContent<Content: View>(
789 spacing: CGFloat = 4,
790 @ViewBuilder content: () -> Content
791 ) -> some View {
792 ScrollView(.horizontal) {
793 VStack(alignment: .leading, spacing: spacing) {
794 content()
795 }
796 .scrollTargetLayout()
797 }
798 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
799 .frame(maxWidth: .infinity, alignment: .leading)
800 }
801
802 private func errorLabel(_ message: String) -> some View {
803 Label(message, systemImage: "exclamationmark.triangle.fill")
804 .font(.system(.caption, design: .monospaced))
805 .foregroundStyle(.red)
806 .padding(8)
807 }
808
809 private func portScanResultsCard(_ results: [PortScanResult]) -> some View {
810 VStack(alignment: .leading, spacing: 4) {
811 ForEach(results) { result in
812 VStack(alignment: .leading, spacing: 2) {
813 HStack(spacing: 8) {
814 Circle()
815 .fill(result.open ? Color.green : Color(.systemGray4))
816 .frame(width: 8, height: 8)
817 Text("\(result.port)")
818 .font(.system(.caption, design: .monospaced))
819 .lineLimit(1)
820 .frame(width: 52, alignment: .leading)
821 Text(result.service)
822 .font(.system(.caption, design: .monospaced))
823 .foregroundStyle(result.open ? .primary : .secondary)
824 Spacer()
825 if result.open {
826 Text("Open")
827 .font(.system(.caption2, design: .monospaced))
828 .foregroundStyle(.green)
829 }
830 }
831
832 if let banner = result.banner {
833 Text(banner)
834 .font(.system(.caption2, design: .monospaced))
835 .foregroundStyle(.secondary)
836 .lineLimit(1)
837 .padding(.leading, 16)
838 }
839 }
840 }
841 }
842 }
843
844 private func parsedCustomPorts(from input: String) -> [UInt16] {
845 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
846 var seen = Set<UInt16>()
847 var ports: [UInt16] = []
848
849 for part in parts {
850 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
851 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
852 continue
853 }
854 ports.append(value)
855 if ports.count == 20 {
856 break
857 }
858 }
859
860 return ports
861 }
862
863 private var httpStatusSummaryParts: [(text: String, color: Color)] {
864 var parts: [(text: String, color: Color)] = []
865
866 if let statusCode = viewModel.httpStatusCode {
867 parts.append(("HTTP \(statusCode)", .cyan))
868 }
869 if let responseTimeMs = viewModel.httpResponseTimeMs {
870 parts.append(("\(responseTimeMs)ms", .secondary))
871 }
872 if let httpProtocol = viewModel.httpProtocol {
873 parts.append((httpProtocol, .secondary))
874 }
875
876 return parts
877 }
878
879 private var http3AvailabilityNote: String? {
880 guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else {
881 return nil
882 }
883 return "(HTTP/3 available)"
884 }
885
886 private func httpSecurityGradeColor(for grade: String) -> Color {
887 switch grade {
888 case "A", "B":
889 .green
890 case "C":
891 .yellow
892 case "D", "F":
893 .red
894 default:
895 .secondary
896 }
897 }
898
899 private func expiryColor(_ days: Int) -> Color {
900 if days < 30 { return .red }
901 if days < 60 { return .yellow }
902 return .green
903 }
904
905 private func shareResults() {
906 let text = viewModel.exportText()
907 let dateFmt = DateFormatter()
908 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
909 let timestamp = dateFmt.string(from: Date())
910 let filename = "\(timestamp)_domaindigresults.txt"
911 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
912
913 do {
914 try text.write(to: tempURL, atomically: true, encoding: .utf8)
915 } catch {
916 return
917 }
918
919 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
920 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
921 let rootVC = windowScene.keyWindow?.rootViewController else { return }
922 var presenter = rootVC
923 while let presented = presenter.presentedViewController {
924 presenter = presented
925 }
926 activityVC.popoverPresentationController?.sourceView = presenter.view
927 presenter.present(activityVC, animated: true)
928 }
929}
930
931extension DateFormatter {
932 static let certDate: DateFormatter = {
933 let f = DateFormatter()
934 f.dateStyle = .medium
935 f.timeStyle = .short
936 return f
937 }()
938}
939
940private struct SettingsView: View {
941 @AppStorage(DNSResolverOption.userDefaultsKey)
942 private var storedResolverURL = DNSResolverOption.defaultURLString
943
944 @State private var resolverOption: DNSResolverOption = .cloudflare
945 @State private var customResolverURL = DNSResolverOption.defaultURLString
946
947 private var customResolverError: String? {
948 guard resolverOption == .custom else {
949 return nil
950 }
951
952 return DNSResolverOption.isValidCustomURL(customResolverURL)
953 ? nil
954 : "Resolver URL must start with https://"
955 }
956
957 var body: some View {
958 Form {
959 Section {
960 Picker("Resolver", selection: $resolverOption) {
961 ForEach(DNSResolverOption.allCases) { option in
962 Text(option.title).tag(option)
963 }
964 }
965
966 if resolverOption == .custom {
967 TextField("https://resolver.example/dns-query", text: $customResolverURL)
968 .textInputAutocapitalization(.never)
969 .autocorrectionDisabled()
970 .keyboardType(.URL)
971
972 if let customResolverError {
973 Text(customResolverError)
974 .font(.caption)
975 .foregroundStyle(.red)
976 }
977 }
978 }
979 }
980 .navigationTitle("Settings")
981 .onAppear {
982 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
983 resolverOption = DNSResolverOption.option(for: currentResolverURL)
984 customResolverURL = resolverOption == .custom
985 ? currentResolverURL
986 : DNSResolverOption.defaultURLString
987 }
988 .onChange(of: resolverOption) { _, newValue in
989 guard let presetURL = newValue.urlString else {
990 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
991 return
992 }
993 storedResolverURL = presetURL
994 }
995 .onChange(of: customResolverURL) { _, newValue in
996 guard resolverOption == .custom else {
997 return
998 }
999 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1000 }
1001 }
1002}
1003
1004#Preview {
1005 ContentView()
1006}