krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.6.0: DomainDig/ContentView.swift · raw

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