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}