krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v2.0.0: DomainDig/ContentView.swift · raw
1import MapKit
2import SwiftUI
3
4struct ContentView: View {
5 @State private var viewModel = DomainViewModel()
6 @State private var navigationPath = NavigationPath()
7 @FocusState private var domainFieldFocused: Bool
8 @State private var customPortInput = ""
9 @State private var customPortsExpanded = false
10 @State private var trackingNoteDraft = ""
11 @State private var editingTrackedDomain: TrackedDomain?
12 @State private var showTrackLimitAlert = false
13
14 var body: some View {
15 NavigationStack(path: $navigationPath) {
16 ScrollView(.vertical) {
17 VStack(spacing: 0) {
18 inputSection
19 if viewModel.hasRun {
20 actionButtons
21 SummaryView(fields: viewModel.summaryFields)
22 .padding(.top, 8)
23 if let changeSummary = viewModel.currentChangeSummary {
24 DomainChangeSummaryView(summary: changeSummary)
25 .padding(.top, 12)
26 }
27 DomainSectionView(
28 rows: viewModel.domainRows,
29 suggestions: viewModel.suggestionRows,
30 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
31 availabilityLoading: viewModel.availabilityLoading,
32 suggestionsLoading: viewModel.suggestionsLoading,
33 trackedDomain: viewModel.currentTrackedDomain,
34 trackingLimitMessage: viewModel.trackingLimitMessage,
35 onTrack: {
36 if !viewModel.trackCurrentDomain() {
37 showTrackLimitAlert = true
38 }
39 },
40 onTogglePinned: {
41 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
42 viewModel.togglePinned(for: trackedDomain)
43 },
44 onEditNote: {
45 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
46 trackingNoteDraft = trackedDomain.note ?? ""
47 editingTrackedDomain = trackedDomain
48 }
49 )
50 .padding(.top, 16)
51 if !viewModel.currentDiffSections.isEmpty {
52 DomainDiffView(
53 title: "Latest Changes",
54 sections: viewModel.currentDiffSections,
55 showsUnchanged: false
56 )
57 .padding(.top, 16)
58 }
59 DNSSectionView(
60 dnssecLabel: viewModel.dnssecLabel,
61 sections: viewModel.dnsRows,
62 ptrMessage: viewModel.ptrMessage,
63 loading: viewModel.dnsLoading || viewModel.ptrLoading,
64 sectionError: viewModel.dnsError
65 )
66 .padding(.top, 16)
67 WebSectionView(
68 certificateRows: viewModel.webCertificateRows,
69 sslInfo: viewModel.sslInfo,
70 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
71 sslError: viewModel.sslError,
72 responseRows: viewModel.webResponseRows,
73 headers: viewModel.httpHeaders,
74 headersLoading: viewModel.httpHeadersLoading,
75 headersError: viewModel.httpHeadersError,
76 redirects: viewModel.redirectRows,
77 redirectLoading: viewModel.redirectChainLoading,
78 redirectError: viewModel.redirectChainError,
79 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
80 )
81 .padding(.top, 16)
82 EmailSectionView(
83 rows: viewModel.emailRows,
84 loading: viewModel.emailSecurityLoading,
85 error: viewModel.emailSecurityError
86 )
87 .padding(.top, 16)
88 NetworkSectionView(
89 reachabilityRows: viewModel.reachabilityRows,
90 reachabilityLoading: viewModel.reachabilityLoading,
91 reachabilityError: viewModel.reachabilityError,
92 locationRows: viewModel.locationRows,
93 geolocation: viewModel.ipGeolocation,
94 geolocationLoading: viewModel.ipGeolocationLoading,
95 geolocationError: viewModel.ipGeolocationError,
96 standardPortRows: viewModel.standardPortRows,
97 customPortRows: viewModel.customPortRows,
98 portScanLoading: viewModel.portScanLoading,
99 portScanError: viewModel.portScanError,
100 customPortScanLoading: viewModel.customPortScanLoading,
101 customPortScanError: viewModel.customPortScanError,
102 isCloudflareProxied: viewModel.isCloudflareProxied,
103 customPortsExpanded: $customPortsExpanded,
104 customPortInput: $customPortInput,
105 onScanCustomPorts: runCustomPortScan
106 )
107 .padding(.top, 16)
108 } else if !viewModel.recentSearches.isEmpty {
109 recentSearchesSection
110 }
111 }
112 .padding(.horizontal)
113 .padding(.bottom, 32)
114 }
115 .background(Color.black)
116 .navigationTitle("DomainDig")
117 .toolbarColorScheme(.dark, for: .navigationBar)
118 .preferredColorScheme(.dark)
119 .toolbar {
120 ToolbarItemGroup(placement: .topBarTrailing) {
121 if viewModel.hasRun {
122 Button {
123 viewModel.reset()
124 } label: {
125 Image(systemName: "xmark.circle")
126 .foregroundStyle(.secondary)
127 }
128 }
129 NavigationLink {
130 WatchlistView(viewModel: viewModel)
131 } label: {
132 Image(systemName: "eye")
133 .foregroundStyle(.secondary)
134 }
135 Menu {
136 NavigationLink {
137 HistoryView(viewModel: viewModel)
138 } label: {
139 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
140 }
141
142 NavigationLink {
143 SavedDomainsView(viewModel: viewModel)
144 } label: {
145 Label("Saved Domains", systemImage: "bookmark")
146 }
147
148 NavigationLink {
149 SettingsView()
150 } label: {
151 Label("Settings", systemImage: "gearshape")
152 }
153 } label: {
154 Image(systemName: "ellipsis.circle")
155 .foregroundStyle(.secondary)
156 }
157 }
158 }
159 }
160 .onAppear {
161 domainFieldFocused = true
162 }
163 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
164 navigationPath = NavigationPath()
165 domainFieldFocused = false
166 }
167 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
168 Button("OK", role: .cancel) {}
169 } message: {
170 Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
171 }
172 .sheet(item: $editingTrackedDomain) { trackedDomain in
173 NavigationStack {
174 Form {
175 Section("Tracking Note") {
176 TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
177 .lineLimit(3...6)
178 .textInputAutocapitalization(.never)
179 .autocorrectionDisabled()
180 }
181 }
182 .navigationTitle(trackedDomain.domain)
183 .toolbar {
184 ToolbarItem(placement: .cancellationAction) {
185 Button("Cancel") {
186 editingTrackedDomain = nil
187 }
188 }
189 ToolbarItem(placement: .confirmationAction) {
190 Button("Save") {
191 viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
192 editingTrackedDomain = nil
193 }
194 }
195 }
196 }
197 }
198 }
199
200 private var inputSection: some View {
201 VStack(spacing: 12) {
202 TextField("e.g. cleberg.net", text: $viewModel.domain)
203 .font(.system(.title3, design: .monospaced))
204 .textInputAutocapitalization(.never)
205 .autocorrectionDisabled()
206 .keyboardType(.URL)
207 .padding(12)
208 .background(Color(.systemGray6))
209 .cornerRadius(8)
210 .focused($domainFieldFocused)
211 .onSubmit { viewModel.run() }
212
213 Button {
214 domainFieldFocused = false
215 viewModel.run()
216 } label: {
217 Text("Run")
218 .font(.headline)
219 .frame(maxWidth: .infinity)
220 .padding(.vertical, 12)
221 }
222 .buttonStyle(.borderedProminent)
223 .disabled(viewModel.trimmedDomain.isEmpty)
224 }
225 .padding(.vertical, 16)
226 }
227
228 private var actionButtons: some View {
229 HStack {
230 Spacer()
231 if viewModel.resultsLoaded {
232 Button {
233 viewModel.toggleSavedDomain()
234 } label: {
235 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
236 .font(.system(.body))
237 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
238 }
239 Button {
240 shareResults()
241 } label: {
242 Image(systemName: "square.and.arrow.up")
243 .font(.system(.body))
244 .foregroundStyle(.secondary)
245 }
246 }
247 }
248 }
249
250 private var recentSearchesSection: some View {
251 VStack(alignment: .leading, spacing: 8) {
252 HStack {
253 Text("RECENT")
254 .font(.system(.caption2, design: .monospaced))
255 .foregroundStyle(.secondary)
256 Spacer()
257 Button("Clear") {
258 viewModel.clearRecentSearches()
259 }
260 .font(.system(.caption2, design: .monospaced))
261 .foregroundStyle(.secondary)
262 }
263
264 ForEach(viewModel.recentSearches, id: \.self) { domain in
265 Button {
266 viewModel.domain = domain
267 domainFieldFocused = false
268 viewModel.run()
269 } label: {
270 Text(domain)
271 .font(.system(.callout, design: .monospaced))
272 .foregroundStyle(.primary)
273 .frame(maxWidth: .infinity, alignment: .leading)
274 .padding(.vertical, 6)
275 .padding(.horizontal, 10)
276 .background(Color(.systemGray6).opacity(0.5))
277 .cornerRadius(6)
278 }
279 }
280 }
281 .padding(.top, 8)
282 }
283
284 private func runCustomPortScan() {
285 let ports = parsedCustomPorts(from: customPortInput)
286 Task {
287 await viewModel.runCustomPortScan(ports: ports)
288 }
289 }
290
291 private func parsedCustomPorts(from input: String) -> [UInt16] {
292 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
293 var seen = Set<UInt16>()
294 var ports: [UInt16] = []
295
296 for part in parts {
297 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
298 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
299 continue
300 }
301 ports.append(value)
302 if ports.count == 20 {
303 break
304 }
305 }
306
307 return ports
308 }
309
310 private func shareResults() {
311 let text = viewModel.exportText()
312 let dateFmt = DateFormatter()
313 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
314 let timestamp = dateFmt.string(from: Date())
315 let filename = "\(timestamp)_domaindigresults.txt"
316 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
317
318 do {
319 try text.write(to: tempURL, atomically: true, encoding: .utf8)
320 } catch {
321 return
322 }
323
324 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
325 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
326 let rootVC = windowScene.keyWindow?.rootViewController else { return }
327 var presenter = rootVC
328 while let presented = presenter.presentedViewController {
329 presenter = presented
330 }
331 activityVC.popoverPresentationController?.sourceView = presenter.view
332 presenter.present(activityVC, animated: true)
333 }
334}
335
336struct SummaryView: View {
337 let fields: [SummaryFieldViewData]
338
339 var body: some View {
340 VStack(alignment: .leading, spacing: 12) {
341 SectionTitleView(title: "Summary")
342 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
343 ForEach(fields) { field in
344 VStack(alignment: .leading, spacing: 4) {
345 Text(field.label)
346 .font(.system(.caption2, design: .monospaced))
347 .foregroundStyle(.secondary)
348 Text(field.value)
349 .font(.system(.caption, design: .monospaced))
350 .foregroundStyle(ResultColors.color(for: field.tone))
351 .lineLimit(2)
352 .textSelection(.enabled)
353 }
354 .frame(maxWidth: .infinity, alignment: .leading)
355 .padding(10)
356 .background(Color(.systemGray6).opacity(0.5))
357 .cornerRadius(6)
358 }
359 }
360 }
361 }
362}
363
364struct DomainChangeSummaryView: View {
365 let summary: DomainChangeSummary
366
367 var body: some View {
368 CardView(allowsHorizontalScroll: false) {
369 HStack {
370 Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
371 .font(.system(.caption, design: .monospaced))
372 .foregroundStyle(summary.hasChanges ? .yellow : .green)
373 Spacer()
374 Text(summary.generatedAt, style: .time)
375 .font(.system(.caption2, design: .monospaced))
376 .foregroundStyle(.secondary)
377 }
378
379 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
380 .font(.system(.caption, design: .monospaced))
381 .foregroundStyle(.primary)
382 }
383 }
384}
385
386struct DomainDiffView: View {
387 let title: String
388 let sections: [DomainDiffSection]
389 let showsUnchanged: Bool
390
391 private var filteredSections: [DomainDiffSection] {
392 guard !showsUnchanged else { return sections }
393 return sections
394 .map { section in
395 DomainDiffSection(
396 title: section.title,
397 items: section.items.filter { $0.changeType != .unchanged }
398 )
399 }
400 .filter { !$0.items.isEmpty }
401 }
402
403 var body: some View {
404 VStack(alignment: .leading, spacing: 12) {
405 SectionTitleView(title: title)
406 if filteredSections.isEmpty {
407 MessageCardView(text: "No comparison data available", isError: false)
408 } else {
409 ForEach(filteredSections) { section in
410 CardView(allowsHorizontalScroll: false) {
411 Text(section.title)
412 .font(.system(.subheadline, design: .monospaced))
413 .fontWeight(.semibold)
414 .foregroundStyle(.cyan)
415
416 ForEach(section.items) { item in
417 VStack(alignment: .leading, spacing: 4) {
418 HStack {
419 Text(item.label)
420 .font(.system(.caption, design: .monospaced))
421 .foregroundStyle(.secondary)
422 Spacer()
423 Text(changeLabel(for: item.changeType))
424 .font(.system(.caption2, design: .monospaced))
425 .foregroundStyle(changeColor(for: item.changeType))
426 }
427 if let oldValue = item.oldValue {
428 Text("Old: \(oldValue)")
429 .font(.system(.caption2, design: .monospaced))
430 .foregroundStyle(.secondary)
431 .textSelection(.enabled)
432 }
433 if let newValue = item.newValue {
434 Text("New: \(newValue)")
435 .font(.system(.caption, design: .monospaced))
436 .foregroundStyle(.primary)
437 .textSelection(.enabled)
438 }
439 }
440 }
441 }
442 }
443 }
444 }
445 }
446
447 private func changeLabel(for changeType: DiffChangeType) -> String {
448 switch changeType {
449 case .added:
450 return "Added"
451 case .removed:
452 return "Removed"
453 case .changed:
454 return "Changed"
455 case .unchanged:
456 return "Unchanged"
457 }
458 }
459
460 private func changeColor(for changeType: DiffChangeType) -> Color {
461 switch changeType {
462 case .added:
463 return .green
464 case .removed:
465 return .red
466 case .changed:
467 return .yellow
468 case .unchanged:
469 return .secondary
470 }
471 }
472}
473
474struct TrackedDomainDetailHeaderView: View {
475 let trackedDomain: TrackedDomain
476
477 var body: some View {
478 VStack(alignment: .leading, spacing: 4) {
479 if let note = trackedDomain.note?.nilIfEmpty {
480 LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
481 }
482 HStack(spacing: 8) {
483 if trackedDomain.isPinned {
484 Label("Pinned", systemImage: "pin.fill")
485 }
486 Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
487 }
488 .font(.system(.caption2, design: .monospaced))
489 .foregroundStyle(.secondary)
490 }
491 }
492}
493
494struct DomainSectionView: View {
495 let rows: [InfoRowViewData]
496 let suggestions: [DomainSuggestionViewData]
497 let showSuggestions: Bool
498 let availabilityLoading: Bool
499 let suggestionsLoading: Bool
500 let trackedDomain: TrackedDomain?
501 let trackingLimitMessage: String?
502 let onTrack: () -> Void
503 let onTogglePinned: () -> Void
504 let onEditNote: (() -> Void)?
505
506 var body: some View {
507 VStack(alignment: .leading, spacing: 12) {
508 HStack {
509 SectionTitleView(title: "Domain")
510 Spacer()
511 if let trackedDomain {
512 HStack(spacing: 8) {
513 Text("Tracked")
514 .font(.system(.caption, design: .monospaced))
515 .foregroundStyle(.green)
516 Button {
517 onTogglePinned()
518 } label: {
519 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
520 }
521 .buttonStyle(.bordered)
522 .font(.system(.caption, design: .monospaced))
523 if let onEditNote {
524 Button("Note") {
525 onEditNote()
526 }
527 .buttonStyle(.bordered)
528 .font(.system(.caption, design: .monospaced))
529 }
530 }
531 } else {
532 Button("Track") {
533 onTrack()
534 }
535 .buttonStyle(.bordered)
536 .font(.system(.caption, design: .monospaced))
537 }
538 }
539 CardView(allowsHorizontalScroll: false) {
540 ForEach(rows) { row in
541 LabeledValueRow(row: row)
542 }
543 if let trackedDomain {
544 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
545 .padding(.top, 4)
546 } else if let trackingLimitMessage {
547 MessageRowView(text: trackingLimitMessage, isError: false)
548 .padding(.top, 4)
549 }
550 if availabilityLoading {
551 ProgressView("Checking availability…")
552 .appLoadingStyle()
553 .padding(.top, 4)
554 }
555 if showSuggestions {
556 Text("Suggestions")
557 .font(.system(.caption, design: .monospaced))
558 .foregroundStyle(.secondary)
559 .padding(.top, 4)
560 if suggestionsLoading {
561 ProgressView("Checking alternatives…")
562 .appLoadingStyle()
563 } else if suggestions.isEmpty {
564 MessageRowView(text: "No suggestions", isError: false)
565 } else {
566 ForEach(suggestions) { suggestion in
567 HStack {
568 Text(suggestion.domain)
569 .font(.system(.caption, design: .monospaced))
570 .foregroundStyle(.primary)
571 .textSelection(.enabled)
572 Spacer()
573 Text(suggestion.status)
574 .font(.system(.caption2, design: .monospaced))
575 .foregroundStyle(ResultColors.color(for: suggestion.tone))
576 }
577 }
578 }
579 }
580 }
581 }
582 }
583}
584
585struct DNSSectionView: View {
586 let dnssecLabel: String?
587 let sections: [DNSRecordSectionViewData]
588 let ptrMessage: SectionMessageViewData?
589 let loading: Bool
590 let sectionError: String?
591
592 var body: some View {
593 VStack(alignment: .leading, spacing: 12) {
594 HStack(alignment: .top, spacing: 8) {
595 SectionTitleView(title: "DNS")
596 Spacer()
597 if let dnssecLabel {
598 Text(dnssecLabel)
599 .font(.system(.caption2, design: .monospaced))
600 .foregroundStyle(.secondary)
601 .multilineTextAlignment(.trailing)
602 }
603 }
604
605 if loading {
606 LoadingCardView(text: "Querying DNS…")
607 } else if let sectionError, sections.isEmpty {
608 MessageCardView(text: sectionError, isError: true)
609 } else {
610 ForEach(sections) { section in
611 CardView {
612 Text(section.title)
613 .font(.system(.subheadline, design: .monospaced))
614 .fontWeight(.semibold)
615 .foregroundStyle(.cyan)
616
617 if let message = section.message {
618 MessageRowView(text: message.text, isError: message.isError)
619 }
620
621 ForEach(section.rows) { row in
622 LabeledValueRow(row: row)
623 }
624
625 if let wildcardTitle = section.wildcardTitle {
626 Text(wildcardTitle)
627 .font(.system(.caption, design: .monospaced))
628 .foregroundStyle(.secondary)
629 .padding(.top, 4)
630 ForEach(section.wildcardRows) { row in
631 LabeledValueRow(row: row)
632 }
633 }
634 }
635 }
636
637 if let ptrMessage {
638 CardView {
639 Text("PTR")
640 .font(.system(.subheadline, design: .monospaced))
641 .fontWeight(.semibold)
642 .foregroundStyle(.cyan)
643 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
644 }
645 }
646 }
647 }
648 }
649}
650
651struct WebSectionView: View {
652 let certificateRows: [InfoRowViewData]
653 let sslInfo: SSLCertificateInfo?
654 let sslLoading: Bool
655 let sslError: String?
656 let responseRows: [InfoRowViewData]
657 let headers: [HTTPHeader]
658 let headersLoading: Bool
659 let headersError: String?
660 let redirects: [RedirectHopViewData]
661 let redirectLoading: Bool
662 let redirectError: String?
663 let finalURL: String?
664
665 var body: some View {
666 VStack(alignment: .leading, spacing: 12) {
667 SectionTitleView(title: "Web")
668
669 CardView {
670 Text("TLS")
671 .font(.system(.subheadline, design: .monospaced))
672 .fontWeight(.semibold)
673 .foregroundStyle(.cyan)
674 if sslLoading {
675 ProgressView("Checking certificate…")
676 .appLoadingStyle()
677 } else if let sslError {
678 MessageRowView(text: sslError, isError: true)
679 } else {
680 ForEach(certificateRows) { row in
681 LabeledValueRow(row: row)
682 }
683 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
684 Text("SANs")
685 .font(.system(.caption2, design: .monospaced))
686 .foregroundStyle(.secondary)
687 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
688 Text(san)
689 .font(.system(.caption, design: .monospaced))
690 .textSelection(.enabled)
691 }
692 }
693 }
694 }
695
696 CardView {
697 Text("Headers")
698 .font(.system(.subheadline, design: .monospaced))
699 .fontWeight(.semibold)
700 .foregroundStyle(.cyan)
701 if headersLoading {
702 ProgressView("Fetching headers…")
703 .appLoadingStyle()
704 } else if let headersError {
705 MessageRowView(text: headersError, isError: true)
706 } else {
707 ForEach(responseRows) { row in
708 LabeledValueRow(row: row)
709 }
710 if headers.isEmpty {
711 MessageRowView(text: "No HTTP headers returned", isError: false)
712 } else {
713 ForEach(headers) { header in
714 HStack(alignment: .top, spacing: 4) {
715 Text(header.name + ":")
716 .font(.system(.caption, design: .monospaced))
717 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
718 Text(header.value)
719 .font(.system(.caption, design: .monospaced))
720 .foregroundStyle(.primary)
721 .textSelection(.enabled)
722 }
723 }
724 }
725 }
726 }
727
728 CardView {
729 Text("Redirects")
730 .font(.system(.subheadline, design: .monospaced))
731 .fontWeight(.semibold)
732 .foregroundStyle(.cyan)
733 if redirectLoading {
734 ProgressView("Tracing redirects…")
735 .appLoadingStyle()
736 } else if let redirectError {
737 MessageRowView(text: redirectError, isError: true)
738 } else if redirects.isEmpty {
739 MessageRowView(text: "No redirect data available", isError: false)
740 } else {
741 if let finalURL {
742 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
743 }
744 ForEach(redirects) { redirect in
745 HStack(alignment: .top, spacing: 6) {
746 Text(redirect.stepLabel)
747 .font(.system(.caption, design: .monospaced))
748 .foregroundStyle(.secondary)
749 .frame(width: 16, alignment: .trailing)
750 Text(redirect.statusCode)
751 .font(.system(.caption, design: .monospaced))
752 .foregroundStyle(.cyan)
753 .frame(width: 36, alignment: .leading)
754 Text(redirect.url)
755 .font(.system(.caption, design: .monospaced))
756 .textSelection(.enabled)
757 if redirect.isFinal {
758 Text("(final)")
759 .font(.system(.caption2, design: .monospaced))
760 .foregroundStyle(.secondary)
761 }
762 }
763 }
764 }
765 }
766 }
767 }
768}
769
770struct EmailSectionView: View {
771 let rows: [EmailRowViewData]
772 let loading: Bool
773 let error: String?
774
775 var body: some View {
776 VStack(alignment: .leading, spacing: 12) {
777 SectionTitleView(title: "Email")
778 CardView {
779 if loading {
780 ProgressView("Checking email records…")
781 .appLoadingStyle()
782 } else if let error {
783 MessageRowView(text: error, isError: true)
784 } else if rows.isEmpty {
785 MessageRowView(text: "No email security records found", isError: false)
786 } else {
787 ForEach(rows) { row in
788 VStack(alignment: .leading, spacing: 4) {
789 HStack(spacing: 8) {
790 Text(row.label)
791 .font(.system(.caption, design: .monospaced))
792 .foregroundStyle(.cyan)
793 .frame(width: 76, alignment: .leading)
794 Text(row.status)
795 .font(.system(.caption, design: .monospaced))
796 .foregroundStyle(ResultColors.color(for: row.statusTone))
797 }
798 Text(row.detail)
799 .font(.system(.caption2, design: .monospaced))
800 .foregroundStyle(.primary)
801 .textSelection(.enabled)
802 if let auxiliaryDetail = row.auxiliaryDetail {
803 Text(auxiliaryDetail)
804 .font(.system(.caption2, design: .monospaced))
805 .foregroundStyle(.secondary)
806 }
807 }
808 }
809 }
810 }
811 }
812 }
813}
814
815struct NetworkSectionView: View {
816 let reachabilityRows: [ReachabilityRowViewData]
817 let reachabilityLoading: Bool
818 let reachabilityError: String?
819 let locationRows: [InfoRowViewData]
820 let geolocation: IPGeolocation?
821 let geolocationLoading: Bool
822 let geolocationError: String?
823 let standardPortRows: [PortScanRowViewData]
824 let customPortRows: [PortScanRowViewData]
825 let portScanLoading: Bool
826 let portScanError: String?
827 let customPortScanLoading: Bool
828 let customPortScanError: String?
829 let isCloudflareProxied: Bool
830 @Binding var customPortsExpanded: Bool
831 @Binding var customPortInput: String
832 let onScanCustomPorts: () -> Void
833
834 var body: some View {
835 VStack(alignment: .leading, spacing: 12) {
836 SectionTitleView(title: "Network")
837
838 CardView {
839 Text("Reachability")
840 .font(.system(.subheadline, design: .monospaced))
841 .fontWeight(.semibold)
842 .foregroundStyle(.cyan)
843 if reachabilityLoading {
844 ProgressView("Checking ports…")
845 .appLoadingStyle()
846 } else if let reachabilityError {
847 MessageRowView(text: reachabilityError, isError: true)
848 } else {
849 ForEach(reachabilityRows) { row in
850 HStack {
851 Text(row.portLabel)
852 .font(.system(.caption, design: .monospaced))
853 Spacer()
854 Text(row.latencyLabel)
855 .font(.system(.caption2, design: .monospaced))
856 .foregroundStyle(.secondary)
857 Text(row.statusLabel)
858 .font(.system(.caption, design: .monospaced))
859 .foregroundStyle(ResultColors.color(for: row.statusTone))
860 }
861 }
862 }
863 }
864
865 CardView(allowsHorizontalScroll: false) {
866 Text("Location")
867 .font(.system(.subheadline, design: .monospaced))
868 .fontWeight(.semibold)
869 .foregroundStyle(.cyan)
870 if geolocationLoading {
871 ProgressView("Looking up location…")
872 .appLoadingStyle()
873 } else if let geolocationError, geolocation == nil {
874 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
875 } else if let geolocation {
876 ForEach(locationRows) { row in
877 LabeledValueRow(row: row)
878 }
879 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
880 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
881 Map(initialPosition: .region(MKCoordinateRegion(
882 center: coordinate,
883 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
884 ))) {
885 Marker(geolocation.ip, coordinate: coordinate)
886 }
887 .mapStyle(.standard)
888 .frame(maxWidth: .infinity)
889 .frame(height: 180)
890 .cornerRadius(8)
891 }
892 } else {
893 MessageRowView(text: "No location data available", isError: false)
894 }
895 }
896
897 CardView(allowsHorizontalScroll: false) {
898 Text("Port Scan")
899 .font(.system(.subheadline, design: .monospaced))
900 .fontWeight(.semibold)
901 .foregroundStyle(.cyan)
902
903 if isCloudflareProxied {
904 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
905 .font(.system(.caption2, design: .monospaced))
906 .foregroundStyle(.orange)
907 .fixedSize(horizontal: false, vertical: true)
908 }
909
910 if portScanLoading {
911 ProgressView("Scanning ports…")
912 .appLoadingStyle()
913 } else if let portScanError, standardPortRows.isEmpty {
914 MessageRowView(text: portScanError, isError: true)
915 } else {
916 Text("Standard Ports")
917 .font(.system(.caption, design: .monospaced))
918 .foregroundStyle(.secondary)
919 PortRowsView(rows: standardPortRows)
920 }
921
922 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
923 VStack(alignment: .leading, spacing: 10) {
924 TextField("8888, 9000, 27017", text: $customPortInput)
925 .font(.system(.caption, design: .monospaced))
926 .textInputAutocapitalization(.never)
927 .autocorrectionDisabled()
928 .keyboardType(.numberPad)
929 .padding(10)
930 .background(Color(.systemGray6).opacity(0.5))
931 .cornerRadius(6)
932
933 Button("Scan") {
934 onScanCustomPorts()
935 }
936 .buttonStyle(.borderedProminent)
937 .disabled(customPortScanLoading)
938
939 if customPortScanLoading {
940 ProgressView("Scanning custom ports…")
941 .appLoadingStyle()
942 } else if let customPortScanError {
943 MessageRowView(text: customPortScanError, isError: true)
944 } else {
945 PortRowsView(rows: customPortRows)
946 }
947 }
948 .padding(.top, 8)
949 }
950 .font(.system(.caption, design: .monospaced))
951 .tint(.secondary)
952 }
953 }
954 }
955}
956
957struct PortRowsView: View {
958 let rows: [PortScanRowViewData]
959
960 var body: some View {
961 if rows.isEmpty {
962 MessageRowView(text: "No results", isError: false)
963 } else {
964 ForEach(rows) { row in
965 VStack(alignment: .leading, spacing: 2) {
966 HStack {
967 Text(row.portLabel)
968 .font(.system(.caption, design: .monospaced))
969 .frame(width: 52, alignment: .leading)
970 Text(row.service)
971 .font(.system(.caption, design: .monospaced))
972 .foregroundStyle(.primary)
973 Spacer()
974 if let durationLabel = row.durationLabel {
975 Text(durationLabel)
976 .font(.system(.caption2, design: .monospaced))
977 .foregroundStyle(.secondary)
978 }
979 Text(row.statusLabel)
980 .font(.system(.caption2, design: .monospaced))
981 .foregroundStyle(ResultColors.color(for: row.statusTone))
982 }
983 if let banner = row.banner {
984 Text(banner)
985 .font(.system(.caption2, design: .monospaced))
986 .foregroundStyle(.secondary)
987 .padding(.leading, 8)
988 }
989 }
990 }
991 }
992 }
993}
994
995struct SectionTitleView: View {
996 let title: String
997
998 var body: some View {
999 Text(title)
1000 .font(.system(.headline))
1001 .foregroundStyle(.white)
1002 }
1003}
1004
1005struct CardView<Content: View>: View {
1006 let allowsHorizontalScroll: Bool
1007 let content: Content
1008
1009 init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) {
1010 self.allowsHorizontalScroll = allowsHorizontalScroll
1011 self.content = content()
1012 }
1013
1014 var body: some View {
1015 Group {
1016 if allowsHorizontalScroll {
1017 ScrollView(.horizontal) {
1018 cardContent
1019 .scrollTargetLayout()
1020 }
1021 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
1022 } else {
1023 cardContent
1024 .frame(maxWidth: .infinity, alignment: .leading)
1025 }
1026 }
1027 .frame(maxWidth: .infinity, alignment: .leading)
1028 .padding(10)
1029 .background(Color(.systemGray6).opacity(0.5))
1030 .cornerRadius(6)
1031 }
1032
1033 private var cardContent: some View {
1034 VStack(alignment: .leading, spacing: 6) {
1035 content
1036 }
1037 }
1038}
1039
1040struct LoadingCardView: View {
1041 let text: String
1042
1043 var body: some View {
1044 CardView {
1045 ProgressView(text)
1046 .appLoadingStyle()
1047 .frame(maxWidth: .infinity, alignment: .center)
1048 }
1049 }
1050}
1051
1052struct MessageCardView: View {
1053 let text: String
1054 let isError: Bool
1055
1056 var body: some View {
1057 CardView {
1058 MessageRowView(text: text, isError: isError)
1059 }
1060 }
1061}
1062
1063struct MessageRowView: View {
1064 let text: String
1065 let isError: Bool
1066
1067 var body: some View {
1068 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1069 .font(.system(.caption, design: .monospaced))
1070 .foregroundStyle(isError ? .red : .secondary)
1071 }
1072}
1073
1074struct LabeledValueRow: View {
1075 let row: InfoRowViewData
1076
1077 var body: some View {
1078 VStack(alignment: .leading, spacing: 2) {
1079 Text(row.label)
1080 .font(.system(.caption2, design: .monospaced))
1081 .foregroundStyle(.secondary)
1082 Text(row.value)
1083 .font(.system(.caption, design: .monospaced))
1084 .foregroundStyle(ResultColors.color(for: row.tone))
1085 .textSelection(.enabled)
1086 }
1087 }
1088}
1089
1090enum ResultColors {
1091 static func color(for tone: ResultTone) -> Color {
1092 switch tone {
1093 case .primary:
1094 return .primary
1095 case .secondary:
1096 return .secondary
1097 case .success:
1098 return .green
1099 case .warning:
1100 return .yellow
1101 case .failure:
1102 return .red
1103 }
1104 }
1105}
1106
1107extension DateFormatter {
1108 static let certDate: DateFormatter = {
1109 let formatter = DateFormatter()
1110 formatter.dateStyle = .medium
1111 formatter.timeStyle = .short
1112 return formatter
1113 }()
1114}
1115
1116private extension View {
1117 func appLoadingStyle() -> some View {
1118 font(.system(.caption, design: .monospaced))
1119 }
1120}
1121
1122private extension String {
1123 var nilIfEmpty: String? {
1124 isEmpty ? nil : self
1125 }
1126}
1127
1128private struct SettingsView: View {
1129 @AppStorage(DNSResolverOption.userDefaultsKey)
1130 private var storedResolverURL = DNSResolverOption.defaultURLString
1131
1132 @State private var resolverOption: DNSResolverOption = .cloudflare
1133 @State private var customResolverURL = DNSResolverOption.defaultURLString
1134
1135 private var customResolverError: String? {
1136 guard resolverOption == .custom else {
1137 return nil
1138 }
1139 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
1140 }
1141
1142 var body: some View {
1143 Form {
1144 Section {
1145 Picker("Resolver", selection: $resolverOption) {
1146 ForEach(DNSResolverOption.allCases) { option in
1147 Text(option.title).tag(option)
1148 }
1149 }
1150
1151 if resolverOption == .custom {
1152 TextField("https://resolver.example/dns-query", text: $customResolverURL)
1153 .textInputAutocapitalization(.never)
1154 .autocorrectionDisabled()
1155 .keyboardType(.URL)
1156
1157 if let customResolverError {
1158 Text(customResolverError)
1159 .font(.caption)
1160 .foregroundStyle(.red)
1161 }
1162 }
1163 }
1164 }
1165 .navigationTitle("Settings")
1166 .onAppear {
1167 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
1168 resolverOption = DNSResolverOption.option(for: currentResolverURL)
1169 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
1170 }
1171 .onChange(of: resolverOption) { _, newValue in
1172 guard let presetURL = newValue.urlString else {
1173 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
1174 return
1175 }
1176 storedResolverURL = presetURL
1177 }
1178 .onChange(of: customResolverURL) { _, newValue in
1179 guard resolverOption == .custom else { return }
1180 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
1181 }
1182 }
1183}
1184
1185#Preview {
1186 ContentView()
1187}