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