DomainDig/ContentView.swift

ede15c586af71b8d6f2c190ffdad0324414cac06
domain-dig/DomainDig/ContentView.swift history · blame · raw

686 lines · 26179 bytes

  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            }
 58        }
 59        .onAppear {
 60            domainFieldFocused = true
 61        }
 62    }
 63
 64    // MARK: - Input
 65
 66    private var inputSection: some View {
 67        VStack(spacing: 12) {
 68            TextField("e.g. cleberg.net", text: $viewModel.domain)
 69                .font(.system(.title3, design: .monospaced))
 70                .textInputAutocapitalization(.never)
 71                .autocorrectionDisabled()
 72                .keyboardType(.URL)
 73                .padding(12)
 74                .background(Color(.systemGray6))
 75                .cornerRadius(8)
 76                .focused($domainFieldFocused)
 77                .onSubmit { viewModel.run() }
 78
 79            Button {
 80                domainFieldFocused = false
 81                viewModel.run()
 82            } label: {
 83                Text("Run")
 84                    .font(.headline)
 85                    .frame(maxWidth: .infinity)
 86                    .padding(.vertical, 12)
 87            }
 88            .buttonStyle(.borderedProminent)
 89            .disabled(viewModel.trimmedDomain.isEmpty)
 90        }
 91        .padding(.vertical, 16)
 92    }
 93
 94    // MARK: - Action Buttons (Share + Bookmark)
 95
 96    private var actionButtons: some View {
 97        HStack {
 98            Spacer()
 99            if viewModel.resultsLoaded {
100                Button {
101                    viewModel.toggleSavedDomain()
102                } label: {
103                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
104                        .font(.system(.body))
105                        .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
106                }
107                Button {
108                    shareResults()
109                } label: {
110                    Image(systemName: "square.and.arrow.up")
111                        .font(.system(.body))
112                        .foregroundStyle(.secondary)
113                }
114            }
115        }
116        .padding(.top, 8)
117    }
118
119    // MARK: - Recent Searches
120
121    private var recentSearchesSection: some View {
122        VStack(alignment: .leading, spacing: 8) {
123            HStack {
124                Text("RECENT")
125                    .font(.system(.caption2, design: .monospaced))
126                    .foregroundStyle(.secondary)
127                Spacer()
128                Button("Clear") {
129                    viewModel.clearRecentSearches()
130                }
131                .font(.system(.caption2, design: .monospaced))
132                .foregroundStyle(.secondary)
133            }
134
135            ForEach(viewModel.recentSearches, id: \.self) { domain in
136                Button {
137                    viewModel.domain = domain
138                    domainFieldFocused = false
139                    viewModel.run()
140                } label: {
141                    Text(domain)
142                        .font(.system(.callout, design: .monospaced))
143                        .foregroundStyle(.primary)
144                        .frame(maxWidth: .infinity, alignment: .leading)
145                        .padding(.vertical, 6)
146                        .padding(.horizontal, 10)
147                        .background(Color(.systemGray6).opacity(0.5))
148                        .cornerRadius(6)
149                }
150            }
151        }
152        .padding(.top, 8)
153    }
154
155    // MARK: - Reachability
156
157    private var reachabilitySection: some View {
158        VStack(alignment: .leading, spacing: 12) {
159            sectionHeader("Reachability")
160
161            if viewModel.reachabilityLoading {
162                ProgressView("Checking ports…")
163                    .frame(maxWidth: .infinity, alignment: .center)
164                    .padding()
165            } else if let error = viewModel.reachabilityError {
166                errorLabel(error)
167            } else {
168                VStack(alignment: .leading, spacing: 4) {
169                    ForEach(viewModel.reachabilityResults) { result in
170                        HStack(spacing: 8) {
171                            Circle()
172                                .fill(result.reachable ? Color.green : Color.red)
173                                .frame(width: 8, height: 8)
174                            Text("Port \(result.port)")
175                                .font(.system(.caption, design: .monospaced))
176                            if result.reachable, let ms = result.latencyMs {
177                                Text("\(ms)ms")
178                                    .font(.system(.caption, design: .monospaced))
179                                    .foregroundStyle(.secondary)
180                            } else if !result.reachable {
181                                Text("—")
182                                    .font(.system(.caption, design: .monospaced))
183                                    .foregroundStyle(.secondary)
184                            }
185                            Spacer()
186                            Text(result.reachable ? "Reachable" : "Unreachable")
187                                .font(.system(.caption, design: .monospaced))
188                                .foregroundStyle(result.reachable ? .green : .red)
189                        }
190                    }
191                }
192                .padding(10)
193                .background(Color(.systemGray6).opacity(0.5))
194                .cornerRadius(6)
195            }
196        }
197        .padding(.top, 8)
198    }
199
200    // MARK: - Redirect Chain
201
202    private var redirectChainSection: some View {
203        VStack(alignment: .leading, spacing: 12) {
204            sectionHeader("Redirect Chain")
205
206            if viewModel.redirectChainLoading {
207                ProgressView("Tracing redirects…")
208                    .frame(maxWidth: .infinity, alignment: .center)
209                    .padding()
210            } else if let error = viewModel.redirectChainError {
211                errorLabel(error)
212            } else if viewModel.redirectChain.isEmpty {
213                Text("No redirect data")
214                    .font(.system(.caption, design: .monospaced))
215                    .foregroundStyle(.secondary)
216                    .padding(8)
217            } else if viewModel.redirectChain.count == 1,
218                      let only = viewModel.redirectChain.first,
219                      only.isFinal, !(300...399).contains(only.statusCode) {
220                Text("No redirects — direct connection")
221                    .font(.system(.caption, design: .monospaced))
222                    .foregroundStyle(.secondary)
223                    .padding(10)
224                    .frame(maxWidth: .infinity, alignment: .leading)
225                    .background(Color(.systemGray6).opacity(0.5))
226                    .cornerRadius(6)
227            } else {
228                horizontallyScrollableCard {
229                    ForEach(viewModel.redirectChain) { hop in
230                        HStack(alignment: .top, spacing: 6) {
231                            Text("\(hop.stepNumber)")
232                                .font(.system(.caption, design: .monospaced))
233                                .foregroundStyle(.secondary)
234                                .frame(width: 16, alignment: .trailing)
235                            Text("\(hop.statusCode)")
236                                .font(.system(.caption, design: .monospaced))
237                                .foregroundStyle(.cyan)
238                                .frame(width: 30, alignment: .leading)
239                            Text(hop.url)
240                                .font(.system(.caption, design: .monospaced))
241                                .foregroundStyle(.primary)
242                                .textSelection(.enabled)
243                            if hop.isFinal {
244                                Text("(final)")
245                                    .font(.system(.caption2, design: .monospaced))
246                                    .foregroundStyle(.secondary)
247                            }
248                        }
249                    }
250                }
251            }
252        }
253        .padding(.top, 16)
254    }
255
256    // MARK: - DNS Results
257
258    private var dnsResultsSection: some View {
259        VStack(alignment: .leading, spacing: 12) {
260            sectionHeader("DNS Records")
261
262            if viewModel.dnsLoading {
263                ProgressView("Querying DNS…")
264                    .frame(maxWidth: .infinity, alignment: .center)
265                    .padding()
266            } else if let error = viewModel.dnsError {
267                errorLabel(error)
268            } else {
269                ForEach(viewModel.dnsSections) { section in
270                    dnsRecordSection(section)
271                    if section.recordType == .A {
272                        ptrRow
273                    }
274                }
275            }
276        }
277        .padding(.top, 16)
278    }
279
280    private var ptrRow: some View {
281        horizontallyScrollableCard {
282            Text("PTR (Reverse DNS)")
283                .font(.system(.subheadline, design: .monospaced))
284                .fontWeight(.semibold)
285                .foregroundStyle(.cyan)
286
287            if viewModel.ptrLoading {
288                ProgressView()
289                    .frame(maxWidth: .infinity, alignment: .center)
290                    .padding(4)
291            } else if let ptr = viewModel.ptrRecord {
292                Text(ptr)
293                    .font(.system(.caption, design: .monospaced))
294                    .foregroundStyle(.primary)
295                    .textSelection(.enabled)
296            } else {
297                Text("No PTR record found")
298                    .font(.system(.caption, design: .monospaced))
299                    .foregroundStyle(.secondary)
300            }
301        }
302    }
303
304    private func dnsRecordSection(_ section: DNSSection) -> some View {
305        horizontallyScrollableCard {
306            Text(section.recordType.rawValue)
307                .font(.system(.subheadline, design: .monospaced))
308                .fontWeight(.semibold)
309                .foregroundStyle(.cyan)
310
311            if let error = section.error {
312                errorLabel(error)
313            } else if section.records.isEmpty {
314                Text("No records found")
315                    .font(.system(.caption, design: .monospaced))
316                    .foregroundStyle(.secondary)
317            } else {
318                dnsRecordRows(section.records)
319            }
320
321            if !section.wildcardRecords.isEmpty {
322                Text("*.\(viewModel.searchedDomain)")
323                    .font(.system(.caption, design: .monospaced))
324                    .fontWeight(.medium)
325                    .foregroundStyle(.cyan.opacity(0.7))
326                    .padding(.top, 4)
327
328                dnsRecordRows(section.wildcardRecords)
329            }
330        }
331    }
332
333    private func dnsRecordRows(_ records: [DNSRecord]) -> some View {
334        ForEach(records) { record in
335            HStack(alignment: .top) {
336                Text(record.value)
337                    .font(.system(.caption, design: .monospaced))
338                    .foregroundStyle(.primary)
339                    .textSelection(.enabled)
340                Spacer()
341                Text("TTL \(record.ttl)")
342                    .font(.system(.caption2, design: .monospaced))
343                    .foregroundStyle(.secondary)
344            }
345        }
346    }
347
348    // MARK: - Email Security
349
350    @State private var expandedEmailField: String?
351
352    private var emailSecuritySection: some View {
353        VStack(alignment: .leading, spacing: 12) {
354            sectionHeader("Email Security")
355
356            if viewModel.emailSecurityLoading {
357                ProgressView("Checking email records…")
358                    .frame(maxWidth: .infinity, alignment: .center)
359                    .padding()
360            } else if let error = viewModel.emailSecurityError {
361                errorLabel(error)
362            } else if let email = viewModel.emailSecurity {
363                horizontallyScrollableCard(spacing: 6) {
364                    emailSecurityRow("SPF", record: email.spf)
365                    emailSecurityRow("DMARC", record: email.dmarc)
366                    emailSecurityRow("DKIM", record: email.dkim)
367                }
368            }
369        }
370        .frame(maxWidth: .infinity, alignment: .leading)
371        .padding(.top, 16)
372    }
373
374    private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
375        VStack(alignment: .leading, spacing: 2) {
376            HStack(spacing: 8) {
377                Text(label)
378                    .font(.system(.caption, design: .monospaced))
379                    .fontWeight(.semibold)
380                    .frame(width: 52, alignment: .leading)
381                Text(record.found ? "✓" : "✗")
382                    .font(.system(.caption, design: .monospaced))
383                    .foregroundStyle(record.found ? .green : .red)
384                if let value = record.value {
385                    let isExpanded = expandedEmailField == label
386                    let displayValue = isExpanded ? value : String(value.prefix(80))
387                    Text(displayValue)
388                        .font(.system(.caption2, design: .monospaced))
389                        .foregroundStyle(.primary)
390                        .textSelection(.enabled)
391                        .lineLimit(isExpanded ? nil : 1)
392                        .onTapGesture {
393                            withAnimation {
394                                expandedEmailField = isExpanded ? nil : label
395                            }
396                        }
397                } else {
398                    Text("No record found")
399                        .font(.system(.caption2, design: .monospaced))
400                        .foregroundStyle(.secondary)
401                }
402            }
403        }
404    }
405
406    // MARK: - SSL Results
407
408    private var sslResultsSection: some View {
409        VStack(alignment: .leading, spacing: 12) {
410            sectionHeader("SSL / TLS Certificate")
411
412            if viewModel.sslLoading {
413                ProgressView("Checking certificate…")
414                    .frame(maxWidth: .infinity, alignment: .center)
415                    .padding()
416            } else if let error = viewModel.sslError {
417                errorLabel(error)
418            } else if let info = viewModel.sslInfo {
419                sslDetail(info)
420            }
421        }
422        .padding(.top, 16)
423    }
424
425    private func sslDetail(_ info: SSLCertificateInfo) -> some View {
426        horizontallyScrollableCard(spacing: 8) {
427            certRow("Common Name", info.commonName)
428            certRow("Issuer", info.issuer)
429
430            VStack(alignment: .leading, spacing: 2) {
431                Text("SANs")
432                    .font(.system(.caption2, design: .monospaced))
433                    .foregroundStyle(.secondary)
434                ForEach(info.subjectAltNames, id: \.self) { san in
435                    Text(san)
436                        .font(.system(.caption, design: .monospaced))
437                        .textSelection(.enabled)
438                }
439            }
440
441            let formatter = DateFormatter.certDate
442            certRow("Valid From", formatter.string(from: info.validFrom))
443            certRow("Valid Until", formatter.string(from: info.validUntil))
444
445            HStack {
446                Text("Days Until Expiry")
447                    .font(.system(.caption2, design: .monospaced))
448                    .foregroundStyle(.secondary)
449                Spacer()
450                Text("\(info.daysUntilExpiry)")
451                    .font(.system(.caption, design: .monospaced))
452                    .fontWeight(.bold)
453                    .foregroundStyle(expiryColor(info.daysUntilExpiry))
454            }
455
456            certRow("Chain Depth", "\(info.chainDepth)")
457        }
458    }
459
460    // MARK: - HTTP Headers
461
462    private var httpHeadersSection: some View {
463        VStack(alignment: .leading, spacing: 12) {
464            sectionHeader("HTTP Headers")
465
466            if viewModel.httpHeadersLoading {
467                ProgressView("Fetching headers…")
468                    .frame(maxWidth: .infinity, alignment: .center)
469                    .padding()
470            } else if let error = viewModel.httpHeadersError {
471                errorLabel(error)
472            } else {
473                horizontallyScrollableCard {
474                    ForEach(viewModel.httpHeaders) { header in
475                        HStack(alignment: .top, spacing: 4) {
476                            Text(header.name + ":")
477                                .font(.system(.caption, design: .monospaced))
478                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
479                            Text(header.value)
480                                .font(.system(.caption, design: .monospaced))
481                                .foregroundStyle(.primary)
482                                .textSelection(.enabled)
483                        }
484                    }
485                }
486            }
487        }
488        .padding(.top, 16)
489    }
490
491    // MARK: - IP Geolocation
492
493    private var ipGeolocationSection: some View {
494        VStack(alignment: .leading, spacing: 12) {
495            sectionHeader("IP Location")
496
497            if viewModel.ipGeolocationLoading {
498                ProgressView("Looking up location…")
499                    .frame(maxWidth: .infinity, alignment: .center)
500                    .padding()
501            } else if let geo = viewModel.ipGeolocation {
502                ipGeolocationDetail(geo)
503            } else if let error = viewModel.ipGeolocationError {
504                if error == "No A record available" {
505                    Text("No location data available")
506                        .font(.system(.caption, design: .monospaced))
507                        .foregroundStyle(.secondary)
508                        .padding(8)
509                } else {
510                    errorLabel(error)
511                }
512            }
513        }
514        .padding(.top, 16)
515    }
516
517    private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
518        VStack(alignment: .leading, spacing: 6) {
519            horizontallyScrollableContent(spacing: 6) {
520                certRow("IP", geo.ip)
521                if let org = geo.org {
522                    certRow("Org / ISP", org)
523                }
524                let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
525                if !location.isEmpty {
526                    certRow("Location", location)
527                }
528            }
529
530            if let lat = geo.latitude, let lon = geo.longitude {
531                let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
532                Map(initialPosition: .region(MKCoordinateRegion(
533                    center: coordinate,
534                    span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
535                ))) {
536                    Marker(geo.ip, coordinate: coordinate)
537                }
538                .mapStyle(.standard)
539                .frame(maxWidth: .infinity)
540                .frame(height: 180)
541                .cornerRadius(8)
542            }
543        }
544        .padding(10)
545        .background(Color(.systemGray6).opacity(0.5))
546        .cornerRadius(6)
547    }
548
549    // MARK: - Port Scan
550
551    private var portScanSection: some View {
552        VStack(alignment: .leading, spacing: 12) {
553            sectionHeader("Open Ports")
554
555            if viewModel.portScanLoading {
556                ProgressView("Scanning ports…")
557                    .frame(maxWidth: .infinity, alignment: .center)
558                    .padding()
559            } else if let error = viewModel.portScanError {
560                errorLabel(error)
561            } else {
562                VStack(alignment: .leading, spacing: 4) {
563                    ForEach(viewModel.portScanResults) { result in
564                        HStack(spacing: 8) {
565                            Circle()
566                                .fill(result.open ? Color.green : Color(.systemGray4))
567                                .frame(width: 8, height: 8)
568                            Text("\(result.port)")
569                                .font(.system(.caption, design: .monospaced))
570                                .frame(width: 44, alignment: .leading)
571                            Text(result.service)
572                                .font(.system(.caption, design: .monospaced))
573                                .foregroundStyle(result.open ? .primary : .secondary)
574                            Spacer()
575                            if result.open {
576                                Text("Open")
577                                    .font(.system(.caption2, design: .monospaced))
578                                    .foregroundStyle(.green)
579                            }
580                        }
581                    }
582                }
583                .padding(10)
584                .background(Color(.systemGray6).opacity(0.5))
585                .cornerRadius(6)
586            }
587        }
588        .padding(.top, 16)
589    }
590
591    // MARK: - Helpers
592
593    private func sectionHeader(_ title: String) -> some View {
594        Text(title)
595            .font(.system(.headline, design: .default))
596            .foregroundStyle(.white)
597    }
598
599    private func certRow(_ label: String, _ value: String) -> some View {
600        VStack(alignment: .leading, spacing: 2) {
601            Text(label)
602                .font(.system(.caption2, design: .monospaced))
603                .foregroundStyle(.secondary)
604            Text(value)
605                .font(.system(.caption, design: .monospaced))
606                .textSelection(.enabled)
607        }
608    }
609
610    private func horizontallyScrollableCard<Content: View>(
611        spacing: CGFloat = 4,
612        @ViewBuilder content: () -> Content
613    ) -> some View {
614        horizontallyScrollableContent(spacing: spacing) {
615            content()
616        }
617        .padding(10)
618        .background(Color(.systemGray6).opacity(0.5))
619        .cornerRadius(6)
620    }
621
622    private func horizontallyScrollableContent<Content: View>(
623        spacing: CGFloat = 4,
624        @ViewBuilder content: () -> Content
625    ) -> some View {
626        ScrollView(.horizontal) {
627            VStack(alignment: .leading, spacing: spacing) {
628                content()
629            }
630            .scrollTargetLayout()
631        }
632        .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
633        .frame(maxWidth: .infinity, alignment: .leading)
634    }
635
636    private func errorLabel(_ message: String) -> some View {
637        Label(message, systemImage: "exclamationmark.triangle.fill")
638            .font(.system(.caption, design: .monospaced))
639            .foregroundStyle(.red)
640            .padding(8)
641    }
642
643    private func expiryColor(_ days: Int) -> Color {
644        if days < 30 { return .red }
645        if days < 60 { return .yellow }
646        return .green
647    }
648
649    private func shareResults() {
650        let text = viewModel.exportText()
651        let dateFmt = DateFormatter()
652        dateFmt.dateFormat = "yyyyMMdd_HHmmss"
653        let timestamp = dateFmt.string(from: Date())
654        let filename = "\(timestamp)_domaindigresults.txt"
655        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
656
657        do {
658            try text.write(to: tempURL, atomically: true, encoding: .utf8)
659        } catch {
660            return
661        }
662
663        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
664        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
665              let rootVC = windowScene.keyWindow?.rootViewController else { return }
666        var presenter = rootVC
667        while let presented = presenter.presentedViewController {
668            presenter = presented
669        }
670        activityVC.popoverPresentationController?.sourceView = presenter.view
671        presenter.present(activityVC, animated: true)
672    }
673}
674
675extension DateFormatter {
676    static let certDate: DateFormatter = {
677        let f = DateFormatter()
678        f.dateStyle = .medium
679        f.timeStyle = .short
680        return f
681    }()
682}
683
684#Preview {
685    ContentView()
686}