DomainDig/ContentView.swift
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}