krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.4.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 }
385 }
386 }
387 .frame(maxWidth: .infinity, alignment: .leading)
388 .padding(.top, 16)
389 }
390
391 private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
392 VStack(alignment: .leading, spacing: 2) {
393 HStack(spacing: 8) {
394 Text(label)
395 .font(.system(.caption, design: .monospaced))
396 .fontWeight(.semibold)
397 .frame(width: 52, alignment: .leading)
398 Text(record.found ? "✓" : "✗")
399 .font(.system(.caption, design: .monospaced))
400 .foregroundStyle(record.found ? .green : .red)
401 if let value = record.value {
402 let isExpanded = expandedEmailField == label
403 let displayValue = isExpanded ? value : String(value.prefix(80))
404 Text(displayValue)
405 .font(.system(.caption2, design: .monospaced))
406 .foregroundStyle(.primary)
407 .textSelection(.enabled)
408 .lineLimit(isExpanded ? nil : 1)
409 .onTapGesture {
410 withAnimation {
411 expandedEmailField = isExpanded ? nil : label
412 }
413 }
414 } else {
415 Text("No record found")
416 .font(.system(.caption2, design: .monospaced))
417 .foregroundStyle(.secondary)
418 }
419 }
420 }
421 }
422
423 // MARK: - SSL Results
424
425 private var sslResultsSection: some View {
426 VStack(alignment: .leading, spacing: 12) {
427 sectionHeader("SSL / TLS Certificate")
428
429 if viewModel.sslLoading {
430 ProgressView("Checking certificate…")
431 .frame(maxWidth: .infinity, alignment: .center)
432 .padding()
433 } else if let error = viewModel.sslError {
434 errorLabel(error)
435 } else if let info = viewModel.sslInfo {
436 sslDetail(info, domain: viewModel.searchedDomain)
437 }
438 }
439 .padding(.top, 16)
440 }
441
442 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
443 horizontallyScrollableCard(spacing: 8) {
444 certRow("Common Name", info.commonName)
445 certRow("Issuer", info.issuer)
446
447 VStack(alignment: .leading, spacing: 2) {
448 Text("SANs")
449 .font(.system(.caption2, design: .monospaced))
450 .foregroundStyle(.secondary)
451 ForEach(info.subjectAltNames, id: \.self) { san in
452 Text(san)
453 .font(.system(.caption, design: .monospaced))
454 .textSelection(.enabled)
455 }
456 }
457
458 let formatter = DateFormatter.certDate
459 certRow("Valid From", formatter.string(from: info.validFrom))
460 certRow("Valid Until", formatter.string(from: info.validUntil))
461
462 HStack {
463 Text("Days Until Expiry")
464 .font(.system(.caption2, design: .monospaced))
465 .foregroundStyle(.secondary)
466 Spacer()
467 Text("\(info.daysUntilExpiry)")
468 .font(.system(.caption, design: .monospaced))
469 .fontWeight(.bold)
470 .foregroundStyle(expiryColor(info.daysUntilExpiry))
471 }
472
473 certRow("Chain Depth", "\(info.chainDepth)")
474 if viewModel.hstsLoading {
475 hstsLoadingRow
476 } else if let hstsPreloaded = viewModel.hstsPreloaded {
477 hstsStatusRow(hstsPreloaded)
478 }
479 if let tlsVersion = info.tlsVersion {
480 certRow("TLS Version", tlsVersion)
481 }
482 if let cipherSuite = info.cipherSuite {
483 certRow("Cipher Suite", cipherSuite)
484 }
485 if !info.chain.isEmpty {
486 VStack(alignment: .leading, spacing: 6) {
487 Text("Certificate Chain")
488 .font(.system(.caption2, design: .monospaced))
489 .foregroundStyle(.secondary)
490 ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
491 DisclosureGroup {
492 Text(certificate.issuer)
493 .font(.system(.caption, design: .monospaced))
494 .foregroundStyle(.secondary)
495 .textSelection(.enabled)
496 } label: {
497 Text(certificate.subject)
498 .font(.system(.caption, design: .monospaced))
499 .foregroundStyle(.primary)
500 .textSelection(.enabled)
501 }
502 .tint(index == 0 ? .cyan : .secondary)
503 }
504 }
505 }
506 Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
507 .font(.system(.caption, design: .monospaced))
508 .foregroundStyle(.cyan)
509 }
510 }
511
512 // MARK: - HTTP Headers
513
514 private var httpHeadersSection: some View {
515 VStack(alignment: .leading, spacing: 12) {
516 sectionHeader("HTTP Headers")
517
518 if viewModel.httpHeadersLoading {
519 ProgressView("Fetching headers…")
520 .frame(maxWidth: .infinity, alignment: .center)
521 .padding()
522 } else if let error = viewModel.httpHeadersError {
523 errorLabel(error)
524 } else {
525 horizontallyScrollableCard {
526 ForEach(viewModel.httpHeaders) { header in
527 HStack(alignment: .top, spacing: 4) {
528 Text(header.name + ":")
529 .font(.system(.caption, design: .monospaced))
530 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
531 Text(header.value)
532 .font(.system(.caption, design: .monospaced))
533 .foregroundStyle(.primary)
534 .textSelection(.enabled)
535 }
536 }
537 }
538 }
539 }
540 .padding(.top, 16)
541 }
542
543 // MARK: - IP Geolocation
544
545 private var ipGeolocationSection: some View {
546 VStack(alignment: .leading, spacing: 12) {
547 sectionHeader("IP Location")
548
549 if viewModel.ipGeolocationLoading {
550 ProgressView("Looking up location…")
551 .frame(maxWidth: .infinity, alignment: .center)
552 .padding()
553 } else if let geo = viewModel.ipGeolocation {
554 ipGeolocationDetail(geo)
555 } else if let error = viewModel.ipGeolocationError {
556 if error == "No A record available" {
557 Text("No location data available")
558 .font(.system(.caption, design: .monospaced))
559 .foregroundStyle(.secondary)
560 .padding(8)
561 } else {
562 errorLabel(error)
563 }
564 }
565 }
566 .padding(.top, 16)
567 }
568
569 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
570 VStack(alignment: .leading, spacing: 6) {
571 horizontallyScrollableContent(spacing: 6) {
572 certRow("IP", geo.ip)
573 if let org = geo.org {
574 certRow("Org / ISP", org)
575 }
576 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
577 if !location.isEmpty {
578 certRow("Location", location)
579 }
580 }
581
582 if let lat = geo.latitude, let lon = geo.longitude {
583 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
584 Map(initialPosition: .region(MKCoordinateRegion(
585 center: coordinate,
586 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
587 ))) {
588 Marker(geo.ip, coordinate: coordinate)
589 }
590 .mapStyle(.standard)
591 .frame(maxWidth: .infinity)
592 .frame(height: 180)
593 .cornerRadius(8)
594 }
595 }
596 .padding(10)
597 .background(Color(.systemGray6).opacity(0.5))
598 .cornerRadius(6)
599 }
600
601 // MARK: - Port Scan
602
603 private var portScanSection: some View {
604 VStack(alignment: .leading, spacing: 12) {
605 sectionHeader("Open Ports")
606
607 if viewModel.portScanLoading {
608 ProgressView("Scanning ports…")
609 .frame(maxWidth: .infinity, alignment: .center)
610 .padding()
611 } else if let error = viewModel.portScanError {
612 errorLabel(error)
613 } else {
614 VStack(alignment: .leading, spacing: 4) {
615 ForEach(viewModel.portScanResults) { result in
616 HStack(spacing: 8) {
617 Circle()
618 .fill(result.open ? Color.green : Color(.systemGray4))
619 .frame(width: 8, height: 8)
620 Text("\(result.port)")
621 .font(.system(.caption, design: .monospaced))
622 .frame(width: 44, alignment: .leading)
623 Text(result.service)
624 .font(.system(.caption, design: .monospaced))
625 .foregroundStyle(result.open ? .primary : .secondary)
626 Spacer()
627 if result.open {
628 Text("Open")
629 .font(.system(.caption2, design: .monospaced))
630 .foregroundStyle(.green)
631 }
632 }
633 }
634 }
635 .padding(10)
636 .background(Color(.systemGray6).opacity(0.5))
637 .cornerRadius(6)
638 }
639 }
640 .padding(.top, 16)
641 }
642
643 // MARK: - Helpers
644
645 private func sectionHeader(_ title: String) -> some View {
646 Text(title)
647 .font(.system(.headline, design: .default))
648 .foregroundStyle(.white)
649 }
650
651 private var dnssecStatus: Bool? {
652 viewModel.dnsSections.compactMap(\.dnssecSigned).first
653 }
654
655 private func certRow(_ label: String, _ value: String) -> some View {
656 VStack(alignment: .leading, spacing: 2) {
657 Text(label)
658 .font(.system(.caption2, design: .monospaced))
659 .foregroundStyle(.secondary)
660 Text(value)
661 .font(.system(.caption, design: .monospaced))
662 .textSelection(.enabled)
663 }
664 }
665
666 private var hstsLoadingRow: some View {
667 HStack {
668 Text("HSTS Preload")
669 .font(.system(.caption2, design: .monospaced))
670 .foregroundStyle(.secondary)
671 Spacer()
672 ProgressView()
673 .controlSize(.small)
674 }
675 }
676
677 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
678 HStack {
679 Text("HSTS Preload")
680 .font(.system(.caption2, design: .monospaced))
681 .foregroundStyle(.secondary)
682 Spacer()
683 Text(isPreloaded ? "Preloaded" : "Not preloaded")
684 .font(.system(.caption, design: .monospaced))
685 .foregroundStyle(isPreloaded ? .green : .secondary)
686 }
687 }
688
689 private func horizontallyScrollableCard<Content: View>(
690 spacing: CGFloat = 4,
691 @ViewBuilder content: () -> Content
692 ) -> some View {
693 horizontallyScrollableContent(spacing: spacing) {
694 content()
695 }
696 .padding(10)
697 .background(Color(.systemGray6).opacity(0.5))
698 .cornerRadius(6)
699 }
700
701 private func horizontallyScrollableContent<Content: View>(
702 spacing: CGFloat = 4,
703 @ViewBuilder content: () -> Content
704 ) -> some View {
705 ScrollView(.horizontal) {
706 VStack(alignment: .leading, spacing: spacing) {
707 content()
708 }
709 .scrollTargetLayout()
710 }
711 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
712 .frame(maxWidth: .infinity, alignment: .leading)
713 }
714
715 private func errorLabel(_ message: String) -> some View {
716 Label(message, systemImage: "exclamationmark.triangle.fill")
717 .font(.system(.caption, design: .monospaced))
718 .foregroundStyle(.red)
719 .padding(8)
720 }
721
722 private func expiryColor(_ days: Int) -> Color {
723 if days < 30 { return .red }
724 if days < 60 { return .yellow }
725 return .green
726 }
727
728 private func shareResults() {
729 let text = viewModel.exportText()
730 let dateFmt = DateFormatter()
731 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
732 let timestamp = dateFmt.string(from: Date())
733 let filename = "\(timestamp)_domaindigresults.txt"
734 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
735
736 do {
737 try text.write(to: tempURL, atomically: true, encoding: .utf8)
738 } catch {
739 return
740 }
741
742 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
743 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
744 let rootVC = windowScene.keyWindow?.rootViewController else { return }
745 var presenter = rootVC
746 while let presented = presenter.presentedViewController {
747 presenter = presented
748 }
749 activityVC.popoverPresentationController?.sourceView = presenter.view
750 presenter.present(activityVC, animated: true)
751 }
752}
753
754extension DateFormatter {
755 static let certDate: DateFormatter = {
756 let f = DateFormatter()
757 f.dateStyle = .medium
758 f.timeStyle = .short
759 return f
760 }()
761}
762
763private struct SettingsView: View {
764 @AppStorage(DNSResolverOption.userDefaultsKey)
765 private var storedResolverURL = DNSResolverOption.defaultURLString
766
767 @State private var resolverOption: DNSResolverOption = .cloudflare
768 @State private var customResolverURL = DNSResolverOption.defaultURLString
769
770 private var customResolverError: String? {
771 guard resolverOption == .custom else {
772 return nil
773 }
774
775 return DNSResolverOption.isValidCustomURL(customResolverURL)
776 ? nil
777 : "Resolver URL must start with https://"
778 }
779
780 var body: some View {
781 Form {
782 Section {
783 Picker("Resolver", selection: $resolverOption) {
784 ForEach(DNSResolverOption.allCases) { option in
785 Text(option.title).tag(option)
786 }
787 }
788
789 if resolverOption == .custom {
790 TextField("https://resolver.example/dns-query", text: $customResolverURL)
791 .textInputAutocapitalization(.never)
792 .autocorrectionDisabled()
793 .keyboardType(.URL)
794
795 if let customResolverError {
796 Text(customResolverError)
797 .font(.caption)
798 .foregroundStyle(.red)
799 }
800 }
801 }
802 }
803 .navigationTitle("Settings")
804 .onAppear {
805 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
806 resolverOption = DNSResolverOption.option(for: currentResolverURL)
807 customResolverURL = resolverOption == .custom
808 ? currentResolverURL
809 : DNSResolverOption.defaultURLString
810 }
811 .onChange(of: resolverOption) { _, newValue in
812 guard let presetURL = newValue.urlString else {
813 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
814 return
815 }
816 storedResolverURL = presetURL
817 }
818 .onChange(of: customResolverURL) { _, newValue in
819 guard resolverOption == .custom else {
820 return
821 }
822 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
823 }
824 }
825}
826
827#Preview {
828 ContentView()
829}