krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v1.5.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            sectionHeader("HTTP Headers")
548
549            if viewModel.httpHeadersLoading {
550                ProgressView("Fetching headers…")
551                    .frame(maxWidth: .infinity, alignment: .center)
552                    .padding()
553            } else if let error = viewModel.httpHeadersError {
554                errorLabel(error)
555            } else {
556                horizontallyScrollableCard {
557                    ForEach(viewModel.httpHeaders) { header in
558                        HStack(alignment: .top, spacing: 4) {
559                            Text(header.name + ":")
560                                .font(.system(.caption, design: .monospaced))
561                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
562                            Text(header.value)
563                                .font(.system(.caption, design: .monospaced))
564                                .foregroundStyle(.primary)
565                                .textSelection(.enabled)
566                        }
567                    }
568                }
569            }
570        }
571        .padding(.top, 16)
572    }
573
574    // MARK: - IP Geolocation
575
576    private var ipGeolocationSection: some View {
577        VStack(alignment: .leading, spacing: 12) {
578            sectionHeader("IP Location")
579
580            if viewModel.ipGeolocationLoading {
581                ProgressView("Looking up location…")
582                    .frame(maxWidth: .infinity, alignment: .center)
583                    .padding()
584            } else if let geo = viewModel.ipGeolocation {
585                ipGeolocationDetail(geo)
586            } else if let error = viewModel.ipGeolocationError {
587                if error == "No A record available" {
588                    Text("No location data available")
589                        .font(.system(.caption, design: .monospaced))
590                        .foregroundStyle(.secondary)
591                        .padding(8)
592                } else {
593                    errorLabel(error)
594                }
595            }
596        }
597        .padding(.top, 16)
598    }
599
600    private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
601        VStack(alignment: .leading, spacing: 6) {
602            horizontallyScrollableContent(spacing: 6) {
603                certRow("IP", geo.ip)
604                if let org = geo.org {
605                    certRow("Org / ISP", org)
606                }
607                let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
608                if !location.isEmpty {
609                    certRow("Location", location)
610                }
611            }
612
613            if let lat = geo.latitude, let lon = geo.longitude {
614                let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
615                Map(initialPosition: .region(MKCoordinateRegion(
616                    center: coordinate,
617                    span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
618                ))) {
619                    Marker(geo.ip, coordinate: coordinate)
620                }
621                .mapStyle(.standard)
622                .frame(maxWidth: .infinity)
623                .frame(height: 180)
624                .cornerRadius(8)
625            }
626        }
627        .padding(10)
628        .background(Color(.systemGray6).opacity(0.5))
629        .cornerRadius(6)
630    }
631
632    // MARK: - Port Scan
633
634    private var portScanSection: some View {
635        VStack(alignment: .leading, spacing: 12) {
636            sectionHeader("Open Ports")
637
638            if viewModel.portScanLoading {
639                ProgressView("Scanning ports…")
640                    .frame(maxWidth: .infinity, alignment: .center)
641                    .padding()
642            } else if let error = viewModel.portScanError {
643                errorLabel(error)
644            } else {
645                VStack(alignment: .leading, spacing: 4) {
646                    ForEach(viewModel.portScanResults) { result in
647                        HStack(spacing: 8) {
648                            Circle()
649                                .fill(result.open ? Color.green : Color(.systemGray4))
650                                .frame(width: 8, height: 8)
651                            Text("\(result.port)")
652                                .font(.system(.caption, design: .monospaced))
653                                .frame(width: 44, alignment: .leading)
654                            Text(result.service)
655                                .font(.system(.caption, design: .monospaced))
656                                .foregroundStyle(result.open ? .primary : .secondary)
657                            Spacer()
658                            if result.open {
659                                Text("Open")
660                                    .font(.system(.caption2, design: .monospaced))
661                                    .foregroundStyle(.green)
662                            }
663                        }
664                    }
665                }
666                .padding(10)
667                .background(Color(.systemGray6).opacity(0.5))
668                .cornerRadius(6)
669            }
670        }
671        .padding(.top, 16)
672    }
673
674    // MARK: - Helpers
675
676    private func sectionHeader(_ title: String) -> some View {
677        Text(title)
678            .font(.system(.headline, design: .default))
679            .foregroundStyle(.white)
680    }
681
682    private var dnssecStatus: Bool? {
683        viewModel.dnsSections.compactMap(\.dnssecSigned).first
684    }
685
686    private func certRow(_ label: String, _ value: String) -> some View {
687        VStack(alignment: .leading, spacing: 2) {
688            Text(label)
689                .font(.system(.caption2, design: .monospaced))
690                .foregroundStyle(.secondary)
691            Text(value)
692                .font(.system(.caption, design: .monospaced))
693                .textSelection(.enabled)
694        }
695    }
696
697    private var hstsLoadingRow: some View {
698        HStack {
699            Text("HSTS Preload")
700                .font(.system(.caption2, design: .monospaced))
701                .foregroundStyle(.secondary)
702            Spacer()
703            ProgressView()
704                .controlSize(.small)
705        }
706    }
707
708    private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
709        HStack {
710            Text("HSTS Preload")
711                .font(.system(.caption2, design: .monospaced))
712                .foregroundStyle(.secondary)
713            Spacer()
714            Text(isPreloaded ? "Preloaded" : "Not preloaded")
715                .font(.system(.caption, design: .monospaced))
716                .foregroundStyle(isPreloaded ? .green : .secondary)
717        }
718    }
719
720    private func horizontallyScrollableCard<Content: View>(
721        spacing: CGFloat = 4,
722        @ViewBuilder content: () -> Content
723    ) -> some View {
724        horizontallyScrollableContent(spacing: spacing) {
725            content()
726        }
727        .padding(10)
728        .background(Color(.systemGray6).opacity(0.5))
729        .cornerRadius(6)
730    }
731
732    private func horizontallyScrollableContent<Content: View>(
733        spacing: CGFloat = 4,
734        @ViewBuilder content: () -> Content
735    ) -> some View {
736        ScrollView(.horizontal) {
737            VStack(alignment: .leading, spacing: spacing) {
738                content()
739            }
740            .scrollTargetLayout()
741        }
742        .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
743        .frame(maxWidth: .infinity, alignment: .leading)
744    }
745
746    private func errorLabel(_ message: String) -> some View {
747        Label(message, systemImage: "exclamationmark.triangle.fill")
748            .font(.system(.caption, design: .monospaced))
749            .foregroundStyle(.red)
750            .padding(8)
751    }
752
753    private func expiryColor(_ days: Int) -> Color {
754        if days < 30 { return .red }
755        if days < 60 { return .yellow }
756        return .green
757    }
758
759    private func shareResults() {
760        let text = viewModel.exportText()
761        let dateFmt = DateFormatter()
762        dateFmt.dateFormat = "yyyyMMdd_HHmmss"
763        let timestamp = dateFmt.string(from: Date())
764        let filename = "\(timestamp)_domaindigresults.txt"
765        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
766
767        do {
768            try text.write(to: tempURL, atomically: true, encoding: .utf8)
769        } catch {
770            return
771        }
772
773        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
774        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
775              let rootVC = windowScene.keyWindow?.rootViewController else { return }
776        var presenter = rootVC
777        while let presented = presenter.presentedViewController {
778            presenter = presented
779        }
780        activityVC.popoverPresentationController?.sourceView = presenter.view
781        presenter.present(activityVC, animated: true)
782    }
783}
784
785extension DateFormatter {
786    static let certDate: DateFormatter = {
787        let f = DateFormatter()
788        f.dateStyle = .medium
789        f.timeStyle = .short
790        return f
791    }()
792}
793
794private struct SettingsView: View {
795    @AppStorage(DNSResolverOption.userDefaultsKey)
796    private var storedResolverURL = DNSResolverOption.defaultURLString
797
798    @State private var resolverOption: DNSResolverOption = .cloudflare
799    @State private var customResolverURL = DNSResolverOption.defaultURLString
800
801    private var customResolverError: String? {
802        guard resolverOption == .custom else {
803            return nil
804        }
805
806        return DNSResolverOption.isValidCustomURL(customResolverURL)
807            ? nil
808            : "Resolver URL must start with https://"
809    }
810
811    var body: some View {
812        Form {
813            Section {
814                Picker("Resolver", selection: $resolverOption) {
815                    ForEach(DNSResolverOption.allCases) { option in
816                        Text(option.title).tag(option)
817                    }
818                }
819
820                if resolverOption == .custom {
821                    TextField("https://resolver.example/dns-query", text: $customResolverURL)
822                        .textInputAutocapitalization(.never)
823                        .autocorrectionDisabled()
824                        .keyboardType(.URL)
825
826                    if let customResolverError {
827                        Text(customResolverError)
828                            .font(.caption)
829                            .foregroundStyle(.red)
830                    }
831                }
832            }
833        }
834        .navigationTitle("Settings")
835        .onAppear {
836            let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
837            resolverOption = DNSResolverOption.option(for: currentResolverURL)
838            customResolverURL = resolverOption == .custom
839                ? currentResolverURL
840                : DNSResolverOption.defaultURLString
841        }
842        .onChange(of: resolverOption) { _, newValue in
843            guard let presetURL = newValue.urlString else {
844                storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
845                return
846            }
847            storedResolverURL = presetURL
848        }
849        .onChange(of: customResolverURL) { _, newValue in
850            guard resolverOption == .custom else {
851                return
852            }
853            storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
854        }
855    }
856}
857
858#Preview {
859    ContentView()
860}