krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.0: DomainDig/ResultSectionViews.swift · raw
1import MapKit
2import SwiftUI
3
4// Result detail section views extracted from ContentView.swift — one per
5// inspection domain (availability/DNS, ownership, intelligence, subdomains,
6// DNS records, web, email, network, ports). Pure presentation over the
7// view-model report data; the shared primitives (CardView, SectionTitleView,
8// LabeledValueRow, ResultColors, appLoadingStyle) remain in ContentView.swift.
9
10struct DomainSectionView: View {
11 @Environment(\.appDensity) private var appDensity
12 @Binding var isCollapsed: Bool
13 let rows: [InfoRowViewData]
14 let suggestions: [DomainSuggestionViewData]
15 let showSuggestions: Bool
16 let availabilityLoading: Bool
17 let suggestionsLoading: Bool
18 let provenance: SectionProvenance?
19 let confidence: ConfidenceLevel?
20 let snapshotNote: String?
21 let trackedDomain: TrackedDomain?
22 let workflows: [DomainWorkflow]
23 let trackingLimitMessage: String?
24 let pricingLoading: Bool
25 let pricingError: String?
26 let showsPricingPlaceholder: Bool
27 let onTrack: () -> Void
28 let onTogglePinned: () -> Void
29 let onEditNote: (() -> Void)?
30 let onAddToWorkflow: (() -> Void)?
31 let onOpenWorkflow: ((DomainWorkflow) -> Void)?
32 let onRunWorkflow: ((DomainWorkflow) -> Void)?
33
34 var body: some View {
35 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
36 if let trackedDomain {
37 HStack(spacing: 8) {
38 // Icon-only: the header also carries Pin and Note, and the
39 // full "Tracked" pill compresses at larger text sizes.
40 // VoiceOver still hears the word via the label.
41 Image(systemName: "eye.fill")
42 .font(appDensity.font(.caption))
43 .foregroundStyle(Color(.statusPositive))
44 .padding(6)
45 .background(Color(.statusPositiveSurface), in: Circle())
46 .fixedSize()
47 .accessibilityLabel("Tracked")
48 Button {
49 onTogglePinned()
50 } label: {
51 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
52 }
53 .buttonStyle(.bordered)
54 .font(appDensity.font(.caption))
55 .accessibilityLabel("Pin domain")
56 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
57 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
58 if let onEditNote {
59 Button("Note") {
60 onEditNote()
61 }
62 .buttonStyle(.bordered)
63 .font(appDensity.font(.caption))
64 // Never compress into a vertical letter column.
65 .fixedSize()
66 }
67 }
68 } else {
69 Button("Track") {
70 AppHaptics.track()
71 onTrack()
72 }
73 .buttonStyle(.bordered)
74 .font(appDensity.font(.caption))
75 .fixedSize()
76 }
77 } content: {
78 CardView(allowsHorizontalScroll: false) {
79 SectionTrustMetadataView(
80 provenance: provenance,
81 confidence: confidence,
82 note: snapshotNote == nil ? nil : "Audit note present"
83 )
84 ForEach(rows) { row in
85 LabeledValueRow(row: row)
86 }
87 if let trackedDomain {
88 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
89 .padding(.top, 4)
90 } else if let trackingLimitMessage {
91 MessageRowView(text: trackingLimitMessage, isError: false)
92 .padding(.top, 4)
93 }
94 if let onAddToWorkflow {
95 Button {
96 onAddToWorkflow()
97 } label: {
98 Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
99 .font(appDensity.font(.caption))
100 }
101 .buttonStyle(.bordered)
102 .padding(.top, 4)
103 }
104 if !workflows.isEmpty {
105 VStack(alignment: .leading, spacing: 8) {
106 Text("Part of workflow")
107 .font(appDensity.font(.caption))
108 .foregroundStyle(Color(.appTextSecondary))
109
110 ForEach(workflows) { workflow in
111 HStack {
112 Text(workflow.name)
113 .font(appDensity.font(.caption))
114 .foregroundStyle(.primary)
115 Spacer()
116 if let onOpenWorkflow {
117 Button("Open") {
118 onOpenWorkflow(workflow)
119 }
120 .buttonStyle(.bordered)
121 .font(appDensity.font(.caption2))
122 }
123 if let onRunWorkflow {
124 Button("Run") {
125 onRunWorkflow(workflow)
126 }
127 .buttonStyle(.bordered)
128 .font(appDensity.font(.caption2))
129 }
130 }
131 }
132 }
133 .padding(.top, 4)
134 }
135 if availabilityLoading {
136 ProgressView("Checking availability…")
137 .appLoadingStyle()
138 .padding(.top, 4)
139 }
140 if showSuggestions {
141 Text("Suggestions")
142 .font(appDensity.font(.caption))
143 .foregroundStyle(Color(.appTextSecondary))
144 .padding(.top, 4)
145 if suggestionsLoading {
146 ProgressView("Checking alternatives…")
147 .appLoadingStyle()
148 } else if suggestions.isEmpty {
149 MessageRowView(text: "No suggestions", isError: false)
150 } else {
151 ForEach(suggestions) { suggestion in
152 HStack {
153 Text(suggestion.domain)
154 .font(appDensity.font(.caption))
155 .foregroundStyle(.primary)
156 .textSelection(.enabled)
157 Spacer()
158 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
159 }
160 }
161 }
162 }
163 if pricingLoading {
164 ProgressView("Loading external pricing…")
165 .appLoadingStyle()
166 .padding(.top, 4)
167 } else if let pricingError {
168 MessageRowView(text: pricingError, isError: false)
169 .padding(.top, 4)
170 } else if showsPricingPlaceholder {
171 MessageRowView(text: "Pricing signals available in Pro+", isError: false)
172 .padding(.top, 4)
173 }
174 }
175 }
176 }
177}
178
179struct OwnershipSectionView: View {
180 @Environment(\.appDensity) private var appDensity
181 @Binding var isCollapsed: Bool
182 let rows: [InfoRowViewData]
183 let loading: Bool
184 let error: String?
185 let provenance: SectionProvenance?
186 let confidence: ConfidenceLevel?
187 let showsHistoryPlaceholder: Bool
188 let history: [DomainOwnershipHistoryEvent]
189 let historyLoading: Bool
190 let historyError: String?
191 let historyCreditStatus: UsageCreditStatus?
192 let onLoadHistory: (() -> Void)?
193
194 init(
195 isCollapsed: Binding<Bool>,
196 rows: [InfoRowViewData],
197 loading: Bool,
198 error: String?,
199 provenance: SectionProvenance?,
200 confidence: ConfidenceLevel?,
201 showsHistoryPlaceholder: Bool,
202 history: [DomainOwnershipHistoryEvent] = [],
203 historyLoading: Bool = false,
204 historyError: String? = nil,
205 historyCreditStatus: UsageCreditStatus? = nil,
206 onLoadHistory: (() -> Void)? = nil
207 ) {
208 _isCollapsed = isCollapsed
209 self.rows = rows
210 self.loading = loading
211 self.error = error
212 self.provenance = provenance
213 self.confidence = confidence
214 self.showsHistoryPlaceholder = showsHistoryPlaceholder
215 self.history = history
216 self.historyLoading = historyLoading
217 self.historyError = historyError
218 self.historyCreditStatus = historyCreditStatus
219 self.onLoadHistory = onLoadHistory
220 }
221
222 var body: some View {
223 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
224 CardView(allowsHorizontalScroll: false) {
225 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
226 if loading {
227 ProgressView("Fetching RDAP ownership…")
228 .appLoadingStyle()
229 } else {
230 ForEach(rows) { row in
231 LabeledValueRow(row: row)
232 }
233 if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
234 MessageRowView(text: error, isError: error != "Unavailable")
235 .padding(.top, 4)
236 }
237 VStack(alignment: .leading, spacing: 8) {
238 HStack {
239 Text("History")
240 .font(appDensity.font(.caption))
241 .foregroundStyle(Color(.appTextSecondary))
242 Spacer()
243 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
244 Button("Load") {
245 onLoadHistory()
246 }
247 .buttonStyle(.bordered)
248 .font(appDensity.font(.caption2))
249 }
250 }
251 if historyLoading {
252 ProgressView("Loading history…")
253 .appLoadingStyle()
254 } else if !history.isEmpty {
255 ForEach(history) { event in
256 VStack(alignment: .leading, spacing: 3) {
257 Text(event.date.formatted(date: .abbreviated, time: .omitted))
258 .font(appDensity.font(.caption2))
259 .foregroundStyle(Color(.appTextSecondary))
260 Text(event.summary)
261 .font(appDensity.font(.caption))
262 Text(event.source)
263 .font(appDensity.font(.caption2))
264 .foregroundStyle(Color(.appTextSecondary))
265 }
266 }
267 } else if let historyError {
268 MessageRowView(text: historyError, isError: false)
269 } else if showsHistoryPlaceholder {
270 MessageRowView(text: "Ownership history available in Pro+", isError: false)
271 }
272 }
273 }
274 }
275 }
276 }
277}
278
279struct IntelligenceSectionView: View {
280 @Environment(\.appDensity) private var appDensity
281 @Binding var isCollapsed: Bool
282 let report: DomainReport
283 let showsPlaceholder: Bool
284
285 var body: some View {
286 CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
287 CardView(allowsHorizontalScroll: false) {
288 if showsPlaceholder {
289 MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
290 } else {
291 if let provider = report.inferredProvider {
292 intelligenceBlock(title: "Infrastructure") {
293 LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
294 if !provider.evidence.isEmpty {
295 MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false)
296 }
297 if !report.priorProviders.isEmpty {
298 LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
299 }
300 }
301 }
302 if let classification = report.domainClassification {
303 intelligenceBlock(title: "Classification") {
304 LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
305 MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false)
306 }
307 }
308 intelligenceBlock(title: "Risk Signals") {
309 if report.riskSignals.isEmpty {
310 MessageRowView(text: "No material historical risk signals detected", isError: false)
311 } else {
312 ForEach(report.riskSignals.prefix(4)) { signal in
313 VStack(alignment: .leading, spacing: 3) {
314 Text(signal.title)
315 .font(appDensity.font(.caption, weight: .semibold))
316 Text(signal.detail)
317 .font(appDensity.font(.caption2))
318 .foregroundStyle(Color(.appTextSecondary))
319 }
320 }
321 }
322 }
323 intelligenceBlock(title: "Ownership History") {
324 if report.ownershipTransitions.isEmpty {
325 MessageRowView(text: "No ownership transitions observed locally", isError: false)
326 } else {
327 ForEach(report.ownershipTransitions.prefix(4)) { event in
328 intelligenceEventRow(date: event.date, title: event.summary)
329 }
330 }
331 }
332 intelligenceBlock(title: "Hosting History") {
333 if report.hostingTransitions.isEmpty {
334 MessageRowView(text: "No hosting transitions observed locally", isError: false)
335 } else {
336 ForEach(report.hostingTransitions.prefix(4)) { event in
337 intelligenceEventRow(date: event.date, title: event.summary)
338 }
339 }
340 }
341 intelligenceBlock(title: "Subdomain Intelligence") {
342 if report.subdomainHistory.isEmpty {
343 MessageRowView(text: "No subdomain history available", isError: false)
344 } else {
345 ForEach(report.subdomainHistory.prefix(5)) { item in
346 VStack(alignment: .leading, spacing: 3) {
347 HStack {
348 Text(item.hostname)
349 .font(appDensity.font(.caption))
350 Spacer()
351 if item.isEphemeral {
352 Text("Ephemeral")
353 .font(appDensity.font(.caption2))
354 .foregroundStyle(Color(.statusWarning))
355 }
356 }
357 Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
358 .font(appDensity.font(.caption2))
359 .foregroundStyle(Color(.appTextSecondary))
360 }
361 }
362 }
363 }
364 intelligenceBlock(title: "Timeline") {
365 if report.intelligenceTimeline.isEmpty {
366 MessageRowView(text: "No inferred intelligence events yet", isError: false)
367 } else {
368 ForEach(report.intelligenceTimeline.prefix(5)) { event in
369 intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
370 }
371 }
372 }
373 }
374 }
375 }
376 }
377
378 @ViewBuilder
379 private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
380 VStack(alignment: .leading, spacing: 8) {
381 Text(title)
382 .font(appDensity.font(.subheadline, weight: .semibold))
383 .foregroundStyle(Color(.statusInfo))
384 content()
385 }
386 }
387
388 private func intelligenceEventRow(date: Date, title: String) -> some View {
389 VStack(alignment: .leading, spacing: 3) {
390 Text(date.formatted(date: .abbreviated, time: .omitted))
391 .font(appDensity.font(.caption2))
392 .foregroundStyle(Color(.appTextSecondary))
393 Text(title)
394 .font(appDensity.font(.caption))
395 }
396 }
397}
398
399struct SubdomainsSectionView: View {
400 @Environment(\.appDensity) private var appDensity
401 @Binding var isCollapsed: Bool
402 let rows: [SubdomainRowViewData]
403 let groups: [SubdomainGroup]
404 let loading: Bool
405 let error: String?
406 let provenance: SectionProvenance?
407 let confidence: ConfidenceLevel?
408 let showsExtendedPlaceholder: Bool
409 let extendedCount: Int
410 let extendedLoading: Bool
411 let extendedError: String?
412 let extendedCreditStatus: UsageCreditStatus?
413 let onLoadExtended: (() -> Void)?
414
415 init(
416 isCollapsed: Binding<Bool>,
417 rows: [SubdomainRowViewData],
418 groups: [SubdomainGroup],
419 loading: Bool,
420 error: String?,
421 provenance: SectionProvenance?,
422 confidence: ConfidenceLevel?,
423 showsExtendedPlaceholder: Bool,
424 extendedCount: Int = 0,
425 extendedLoading: Bool = false,
426 extendedError: String? = nil,
427 extendedCreditStatus: UsageCreditStatus? = nil,
428 onLoadExtended: (() -> Void)? = nil
429 ) {
430 _isCollapsed = isCollapsed
431 self.rows = rows
432 self.groups = groups
433 self.loading = loading
434 self.error = error
435 self.provenance = provenance
436 self.confidence = confidence
437 self.showsExtendedPlaceholder = showsExtendedPlaceholder
438 self.extendedCount = extendedCount
439 self.extendedLoading = extendedLoading
440 self.extendedError = extendedError
441 self.extendedCreditStatus = extendedCreditStatus
442 self.onLoadExtended = onLoadExtended
443 }
444
445 var body: some View {
446 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
447 CardView(allowsHorizontalScroll: false) {
448 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
449 if loading {
450 ProgressView("Checking certificate transparency…")
451 .appLoadingStyle()
452 } else if rows.isEmpty {
453 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
454 if showsExtendedPlaceholder {
455 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
456 .padding(.top, 4)
457 }
458 } else {
459 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
460 Button("Load extended results") {
461 onLoadExtended()
462 }
463 .buttonStyle(.bordered)
464 .font(appDensity.font(.caption2))
465 }
466 if !groups.isEmpty {
467 Text("Groups")
468 .font(appDensity.font(.caption2))
469 .foregroundStyle(Color(.appTextSecondary))
470 ForEach(groups) { group in
471 HStack {
472 Text("\(group.label).*")
473 .font(appDensity.font(.caption))
474 .foregroundStyle(Color(.statusInfo))
475 Spacer()
476 Text("\(group.subdomains.count)")
477 .font(appDensity.font(.caption2))
478 .foregroundStyle(Color(.appTextSecondary))
479 }
480 }
481 }
482 ForEach(rows) { row in
483 HStack(spacing: 8) {
484 Text(row.hostname)
485 .font(appDensity.font(.caption))
486 .foregroundStyle(.primary)
487 .textSelection(.enabled)
488 Spacer()
489 if row.isInteresting {
490 Text("Interesting")
491 .font(.system(.caption2, design: .monospaced))
492 .foregroundStyle(Color(.statusWarning))
493 .padding(.horizontal, 8)
494 .padding(.vertical, 4)
495 .background(Color(.statusWarningSurface))
496 .clipShape(Capsule())
497 }
498 }
499 }
500 if extendedLoading {
501 ProgressView("Loading extended subdomains…")
502 .appLoadingStyle()
503 .padding(.top, 4)
504 } else if extendedCount > 0 {
505 MessageRowView(text: "\(extendedCount) extended results included", isError: false)
506 .padding(.top, 4)
507 } else if let extendedError {
508 MessageRowView(text: extendedError, isError: false)
509 .padding(.top, 4)
510 } else if showsExtendedPlaceholder {
511 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
512 .padding(.top, 4)
513 }
514 }
515 }
516 }
517 }
518}
519
520struct DNSSectionView: View {
521 @Environment(\.appDensity) private var appDensity
522 @Binding var isCollapsed: Bool
523 let dnssecLabel: String?
524 let patternSummary: DNSPatternSummary?
525 let sections: [DNSRecordSectionViewData]
526 let ptrMessage: SectionMessageViewData?
527 let loading: Bool
528 let dnsProvenance: SectionProvenance?
529 let ptrProvenance: SectionProvenance?
530 let sectionError: String?
531 let history: [DNSHistoryEvent]
532 let historyLoading: Bool
533 let historyError: String?
534 let showsHistoryPlaceholder: Bool
535 let historyCreditStatus: UsageCreditStatus?
536 let onLoadHistory: (() -> Void)?
537
538 init(
539 isCollapsed: Binding<Bool>,
540 dnssecLabel: String?,
541 patternSummary: DNSPatternSummary?,
542 sections: [DNSRecordSectionViewData],
543 ptrMessage: SectionMessageViewData?,
544 loading: Bool,
545 dnsProvenance: SectionProvenance?,
546 ptrProvenance: SectionProvenance?,
547 sectionError: String?,
548 history: [DNSHistoryEvent] = [],
549 historyLoading: Bool = false,
550 historyError: String? = nil,
551 showsHistoryPlaceholder: Bool = false,
552 historyCreditStatus: UsageCreditStatus? = nil,
553 onLoadHistory: (() -> Void)? = nil
554 ) {
555 _isCollapsed = isCollapsed
556 self.dnssecLabel = dnssecLabel
557 self.patternSummary = patternSummary
558 self.sections = sections
559 self.ptrMessage = ptrMessage
560 self.loading = loading
561 self.dnsProvenance = dnsProvenance
562 self.ptrProvenance = ptrProvenance
563 self.sectionError = sectionError
564 self.history = history
565 self.historyLoading = historyLoading
566 self.historyError = historyError
567 self.showsHistoryPlaceholder = showsHistoryPlaceholder
568 self.historyCreditStatus = historyCreditStatus
569 self.onLoadHistory = onLoadHistory
570 }
571
572 var body: some View {
573 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
574 if loading {
575 LoadingCardView(text: "Querying DNS…")
576 } else if let sectionError, sections.isEmpty {
577 MessageCardView(text: sectionError, isError: true)
578 } else {
579 if dnsProvenance != nil {
580 CardView(allowsHorizontalScroll: false) {
581 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
582 if let patternSummary {
583 if !patternSummary.providers.isEmpty {
584 MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
585 }
586 if !patternSummary.patterns.isEmpty {
587 ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
588 MessageRowView(text: pattern, isError: false)
589 }
590 }
591 }
592 }
593 }
594 ForEach(sections) { section in
595 CardView {
596 Text(section.title)
597 .font(.system(.subheadline, design: .monospaced))
598 .fontWeight(.semibold)
599 .foregroundStyle(Color(.statusInfo))
600
601 if let message = section.message {
602 MessageRowView(text: message.text, isError: message.isError)
603 }
604
605 ForEach(section.rows) { row in
606 LabeledValueRow(row: row)
607 }
608
609 if let wildcardTitle = section.wildcardTitle {
610 Text(wildcardTitle)
611 .font(.system(.caption, design: .monospaced))
612 .foregroundStyle(Color(.appTextSecondary))
613 .padding(.top, 4)
614 ForEach(section.wildcardRows) { row in
615 LabeledValueRow(row: row)
616 }
617 }
618 }
619 }
620
621 if let ptrMessage {
622 CardView {
623 Text("PTR")
624 .font(.system(.subheadline, design: .monospaced))
625 .fontWeight(.semibold)
626 .foregroundStyle(Color(.statusInfo))
627 SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
628 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
629 }
630 }
631
632 CardView(allowsHorizontalScroll: false) {
633 HStack {
634 Text("History")
635 .font(appDensity.font(.subheadline, weight: .semibold))
636 .foregroundStyle(Color(.statusInfo))
637 Spacer()
638 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
639 Button("Load") {
640 onLoadHistory()
641 }
642 .buttonStyle(.bordered)
643 .font(appDensity.font(.caption2))
644 }
645 }
646 if historyLoading {
647 ProgressView("Loading DNS history…")
648 .appLoadingStyle()
649 } else if !history.isEmpty {
650 ForEach(history) { event in
651 VStack(alignment: .leading, spacing: 3) {
652 Text(event.date.formatted(date: .abbreviated, time: .omitted))
653 .font(appDensity.font(.caption2))
654 .foregroundStyle(Color(.appTextSecondary))
655 Text(event.summary)
656 .font(appDensity.font(.caption))
657 if !event.aRecords.isEmpty {
658 Text("A: \(event.aRecords.joined(separator: ", "))")
659 .font(appDensity.font(.caption2))
660 .foregroundStyle(Color(.appTextSecondary))
661 }
662 if !event.nameservers.isEmpty {
663 Text("NS: \(event.nameservers.joined(separator: ", "))")
664 .font(appDensity.font(.caption2))
665 .foregroundStyle(Color(.appTextSecondary))
666 }
667 }
668 }
669 } else if let historyError {
670 MessageRowView(text: historyError, isError: false)
671 } else if showsHistoryPlaceholder {
672 MessageRowView(text: "DNS history available in Pro+", isError: false)
673 }
674 }
675 }
676 }
677 }
678}
679
680struct WebSectionView: View {
681 @Environment(\.appDensity) private var appDensity
682 @Binding var isCollapsed: Bool
683 let certificateRows: [InfoRowViewData]
684 let sslInfo: SSLCertificateInfo?
685 let tlsSummary: WebResultSummary?
686 let sslLoading: Bool
687 let sslError: String?
688 let tlsProvenance: SectionProvenance?
689 let responseRows: [InfoRowViewData]
690 let headers: [HTTPHeader]
691 let headersLoading: Bool
692 let headersError: String?
693 let httpProvenance: SectionProvenance?
694 let redirects: [RedirectHopViewData]
695 let redirectLoading: Bool
696 let redirectError: String?
697 let redirectProvenance: SectionProvenance?
698 let finalURL: String?
699
700 var body: some View {
701 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
702 CardView {
703 HStack {
704 Text("TLS")
705 .font(appDensity.font(.subheadline, weight: .semibold))
706 .foregroundStyle(Color(.statusInfo))
707 Spacer()
708 if !sslLoading {
709 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
710 }
711 }
712 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
713 if !sslLoading, let tlsSummary {
714 LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
715 ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
716 MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
717 }
718 }
719 if sslLoading {
720 ProgressView("Checking certificate…")
721 .appLoadingStyle()
722 } else if let sslError {
723 MessageRowView(text: sslError, isError: true)
724 } else {
725 ForEach(certificateRows) { row in
726 LabeledValueRow(row: row)
727 }
728 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
729 Text("SANs")
730 .font(appDensity.font(.caption2))
731 .foregroundStyle(Color(.appTextSecondary))
732 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
733 HStack(alignment: .top, spacing: 8) {
734 Text(san)
735 .font(appDensity.font(.caption))
736 .lineLimit(nil)
737 .fixedSize(horizontal: false, vertical: true)
738 .textSelection(.enabled)
739 Spacer()
740 AppCopyButton(value: san, label: "Copy certificate SAN")
741 }
742 }
743 }
744 }
745 }
746
747 CardView {
748 Text("Headers")
749 .font(appDensity.font(.subheadline, weight: .semibold))
750 .foregroundStyle(Color(.statusInfo))
751 SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
752 if headersLoading {
753 ProgressView("Fetching headers…")
754 .appLoadingStyle()
755 } else if let headersError {
756 MessageRowView(text: headersError, isError: true)
757 } else {
758 ForEach(responseRows) { row in
759 LabeledValueRow(row: row)
760 }
761 if headers.isEmpty {
762 MessageRowView(text: "No HTTP headers returned", isError: false)
763 } else {
764 ForEach(headers) { header in
765 HStack(alignment: .top, spacing: 4) {
766 Text(header.name + ":")
767 .font(appDensity.font(.caption))
768 .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
769 Text(header.value)
770 .font(appDensity.font(.caption))
771 .foregroundStyle(.primary)
772 .textSelection(.enabled)
773 }
774 }
775 }
776 }
777 }
778
779 CardView {
780 HStack {
781 Text("Redirects")
782 .font(appDensity.font(.subheadline, weight: .semibold))
783 .foregroundStyle(Color(.statusInfo))
784 Spacer()
785 if let finalURL {
786 AppCopyButton(value: finalURL, label: "Copy redirect URL")
787 }
788 }
789 SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
790 if redirectLoading {
791 ProgressView("Tracing redirects…")
792 .appLoadingStyle()
793 } else if let redirectError {
794 MessageRowView(text: redirectError, isError: true)
795 } else if redirects.isEmpty {
796 MessageRowView(text: "No redirect data available", isError: false)
797 } else {
798 if let finalURL {
799 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
800 }
801 ForEach(redirects) { redirect in
802 HStack(alignment: .top, spacing: 6) {
803 Text(redirect.stepLabel)
804 .font(appDensity.font(.caption))
805 .foregroundStyle(Color(.appTextSecondary))
806 .frame(width: 16, alignment: .trailing)
807 Text(redirect.statusCode)
808 .font(appDensity.font(.caption))
809 .foregroundStyle(Color(.statusInfo))
810 .frame(width: 36, alignment: .leading)
811 Text(redirect.url)
812 .font(appDensity.font(.caption))
813 .textSelection(.enabled)
814 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
815 if redirect.isFinal {
816 Text("(final)")
817 .font(appDensity.font(.caption2))
818 .foregroundStyle(Color(.appTextSecondary))
819 }
820 }
821 }
822 }
823 }
824 }
825 }
826
827 private func isTLSHighlightError(_ highlight: String) -> Bool {
828 let normalized = highlight.lowercased()
829 if normalized.contains("no weak tls indicators were detected") {
830 return false
831 }
832 return normalized.contains("expires")
833 || normalized.contains("weak")
834 || normalized.contains("tls 1.0")
835 || normalized.contains("tls 1.1")
836 }
837}
838
839struct EmailSectionView: View {
840 @Environment(\.appDensity) private var appDensity
841 @Binding var isCollapsed: Bool
842 let rows: [EmailRowViewData]
843 let assessment: EmailSecuritySummary?
844 let loading: Bool
845 let provenance: SectionProvenance?
846 let confidence: ConfidenceLevel?
847 let error: String?
848
849 var body: some View {
850 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
851 CardView {
852 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
853 HStack {
854 Spacer()
855 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
856 .opacity(loading ? 0 : 1)
857 }
858 if let assessment, let grade = assessment.grade {
859 LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
860 if !assessment.reasons.isEmpty {
861 Text(assessment.reasons.joined(separator: " | "))
862 .font(appDensity.font(.caption2))
863 .foregroundStyle(Color(.appTextSecondary))
864 }
865 }
866 if loading {
867 ProgressView("Checking email records…")
868 .appLoadingStyle()
869 } else if let error {
870 MessageRowView(text: error, isError: true)
871 } else if rows.isEmpty {
872 MessageRowView(text: "No email security records found", isError: false)
873 } else {
874 ForEach(rows) { row in
875 VStack(alignment: .leading, spacing: 4) {
876 HStack(spacing: 8) {
877 Text(row.label)
878 .font(appDensity.font(.caption))
879 .foregroundStyle(Color(.statusInfo))
880 .frame(width: 76, alignment: .leading)
881 AppStatusBadgeView(model: emailRowBadge(row))
882 }
883 Text(row.detail)
884 .font(appDensity.font(.caption2))
885 .foregroundStyle(.primary)
886 .textSelection(.enabled)
887 if let auxiliaryDetail = row.auxiliaryDetail {
888 Text(auxiliaryDetail)
889 .font(appDensity.font(.caption2))
890 .foregroundStyle(Color(.appTextSecondary))
891 }
892 }
893 }
894 }
895 }
896 }
897 }
898
899 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
900 switch row.statusTone {
901 case .success:
902 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
903 case .warning:
904 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
905 case .failure:
906 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
907 case .primary, .secondary:
908 return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
909 }
910 }
911}
912
913struct NetworkSectionView: View {
914 @Environment(\.appDensity) private var appDensity
915 @Binding var isCollapsed: Bool
916 let reachabilityRows: [ReachabilityRowViewData]
917 let reachabilityLoading: Bool
918 let reachabilityError: String?
919 let reachabilityProvenance: SectionProvenance?
920 let locationRows: [InfoRowViewData]
921 let geolocation: IPGeolocation?
922 let geolocationLoading: Bool
923 let geolocationError: String?
924 let geolocationProvenance: SectionProvenance?
925 let geolocationConfidence: ConfidenceLevel?
926 let standardPortRows: [PortScanRowViewData]
927 let customPortRows: [PortScanRowViewData]
928 let portScanLoading: Bool
929 let portScanError: String?
930 let portScanProvenance: SectionProvenance?
931 let customPortScanLoading: Bool
932 let customPortScanError: String?
933 let isCloudflareProxied: Bool
934 @Binding var customPortsExpanded: Bool
935 @Binding var customPortInput: String
936 let onScanCustomPorts: () -> Void
937
938 var body: some View {
939 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
940 CardView {
941 Text("Reachability")
942 .font(appDensity.font(.subheadline, weight: .semibold))
943 .foregroundStyle(Color(.statusInfo))
944 SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
945 if reachabilityLoading {
946 ProgressView("Checking ports…")
947 .appLoadingStyle()
948 } else if let reachabilityError {
949 MessageRowView(text: reachabilityError, isError: true)
950 } else {
951 ForEach(reachabilityRows) { row in
952 HStack {
953 Text(row.portLabel)
954 .font(appDensity.font(.caption))
955 Spacer()
956 Text(row.latencyLabel)
957 .font(appDensity.font(.caption2))
958 .foregroundStyle(Color(.appTextSecondary))
959 AppStatusBadgeView(model: reachabilityBadge(row))
960 }
961 }
962 }
963 }
964
965 CardView(allowsHorizontalScroll: false) {
966 Text("Location")
967 .font(appDensity.font(.subheadline, weight: .semibold))
968 .foregroundStyle(Color(.statusInfo))
969 SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
970 if geolocationLoading {
971 ProgressView("Looking up location…")
972 .appLoadingStyle()
973 } else if let geolocationError, geolocation == nil {
974 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
975 } else if let geolocation {
976 ForEach(locationRows) { row in
977 LabeledValueRow(row: row)
978 }
979 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
980 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
981 Map(initialPosition: .region(MKCoordinateRegion(
982 center: coordinate,
983 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
984 ))) {
985 Marker(geolocation.ip, coordinate: coordinate)
986 }
987 .mapStyle(.standard)
988 .frame(maxWidth: .infinity)
989 .frame(height: 180)
990 .cornerRadius(8)
991 }
992 } else {
993 MessageRowView(text: "No location data available", isError: false)
994 }
995 }
996
997 CardView(allowsHorizontalScroll: false) {
998 Text("Port Scan")
999 .font(appDensity.font(.subheadline, weight: .semibold))
1000 .foregroundStyle(Color(.statusInfo))
1001 SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
1002
1003 if isCloudflareProxied {
1004 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
1005 .font(appDensity.font(.caption2))
1006 .foregroundStyle(Color(.statusWarning))
1007 .fixedSize(horizontal: false, vertical: true)
1008 }
1009
1010 if portScanLoading {
1011 ProgressView("Scanning ports…")
1012 .appLoadingStyle()
1013 } else if let portScanError, standardPortRows.isEmpty {
1014 MessageRowView(text: portScanError, isError: true)
1015 } else {
1016 Text("Standard Ports")
1017 .font(.system(.caption, design: .monospaced))
1018 .foregroundStyle(Color(.appTextSecondary))
1019 PortRowsView(rows: standardPortRows)
1020 }
1021
1022 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
1023 VStack(alignment: .leading, spacing: 10) {
1024 TextField("8888, 9000, 27017", text: $customPortInput)
1025 .font(appDensity.font(.caption))
1026 .textInputAutocapitalization(.never)
1027 .autocorrectionDisabled()
1028 .keyboardType(.numberPad)
1029 .padding(10)
1030 .background(Color(.appSurface))
1031 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1032
1033 Button("Scan") {
1034 AppHaptics.refresh()
1035 onScanCustomPorts()
1036 }
1037 .buttonStyle(.borderedProminent)
1038 .tint(Color(.accentFill))
1039 .disabled(customPortScanLoading)
1040
1041 if customPortScanLoading {
1042 ProgressView("Scanning custom ports…")
1043 .appLoadingStyle()
1044 } else if let customPortScanError {
1045 MessageRowView(text: customPortScanError, isError: true)
1046 } else {
1047 PortRowsView(rows: customPortRows)
1048 }
1049 }
1050 .padding(.top, 8)
1051 }
1052 .font(.system(.caption, design: .monospaced))
1053 .tint(.secondary)
1054 }
1055 }
1056 }
1057
1058 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
1059 switch row.statusTone {
1060 case .success:
1061 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1062 case .warning:
1063 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1064 case .failure:
1065 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1066 case .primary, .secondary:
1067 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1068 }
1069 }
1070}
1071
1072struct PortRowsView: View {
1073 @Environment(\.appDensity) private var appDensity
1074 let rows: [PortScanRowViewData]
1075
1076 var body: some View {
1077 if rows.isEmpty {
1078 MessageRowView(text: "No results", isError: false)
1079 } else {
1080 ForEach(rows) { row in
1081 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
1082 HStack {
1083 Text(row.portLabel)
1084 .font(appDensity.font(.caption))
1085 .frame(width: 52, alignment: .leading)
1086 Text(row.service)
1087 .font(appDensity.font(.caption))
1088 .foregroundStyle(.primary)
1089 Spacer()
1090 if let durationLabel = row.durationLabel {
1091 Text(durationLabel)
1092 .font(appDensity.font(.caption2))
1093 .foregroundStyle(Color(.appTextSecondary))
1094 }
1095 AppStatusBadgeView(model: portBadge(row))
1096 }
1097 if let banner = row.banner {
1098 Text(banner)
1099 .font(appDensity.font(.caption2))
1100 .foregroundStyle(Color(.appTextSecondary))
1101 .padding(.leading, 8)
1102 }
1103 }
1104 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
1105 }
1106 }
1107 }
1108
1109 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
1110 switch row.statusTone {
1111 case .success:
1112 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
1113 case .warning:
1114 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
1115 case .failure:
1116 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
1117 case .primary, .secondary:
1118 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
1119 }
1120 }
1121}