krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.9.0: DomainDig/ContentView.swift · raw
1import MapKit
2import SwiftUI
3import UniformTypeIdentifiers
4
5enum LookupInputMode: String, CaseIterable, Identifiable {
6 case single
7 case bulk
8
9 var id: String { rawValue }
10}
11
12enum ResultSection: String, Hashable {
13 case domain
14 case intelligence
15 case ownership
16 case dns
17 case web
18 case email
19 case network
20 case subdomains
21}
22
23private enum LookupInputField: Hashable {
24 case singleDomain
25 case bulkDomains
26}
27
28private struct WorkflowNavigationTarget: Hashable {
29 let workflowID: UUID
30}
31
32struct ContentView: View {
33 @Environment(\.appDensity) private var appDensity
34 @Bindable var viewModel: DomainViewModel
35 @State private var purchaseService = PurchaseService.shared
36 @State private var navigationPath = NavigationPath()
37 @FocusState private var focusedInputField: LookupInputField?
38 @State private var customPortInput = ""
39 @State private var customPortsExpanded = false
40 @State private var trackingNoteDraft = ""
41 @State private var editingTrackedDomain: TrackedDomain?
42 @State private var inputMode: LookupInputMode = .single
43 @State private var collapsedSections: Set<ResultSection> = [.network]
44 @State private var showingCurrentDomainWorkflowSheet = false
45 @State private var showingBatchWorkflowSheet = false
46 @State private var showingTimeline = false
47 @State private var showingAuditTimeline = false
48 @State private var auditStartInFlight = false
49
50 var body: some View {
51 let _ = purchaseService.currentTier
52
53 NavigationStack(path: $navigationPath) {
54 ScrollView(.vertical) {
55 VStack(spacing: 0) {
56 inputSection
57 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
58 batchSection
59 .padding(.top, appDensity.metrics.cardSpacing)
60 }
61 if viewModel.hasRun {
62 actionButtons
63 if !viewModel.resultsLoaded {
64 LookupProgressOverviewView(steps: viewModel.activeLoadingLabels)
65 .padding(.top, appDensity.metrics.cardSpacing)
66 } else if let statusMessage = resultStatusMessage {
67 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
68 .padding(.top, appDensity.metrics.cardSpacing)
69 }
70 if viewModel.resultsLoaded {
71 SummaryView(fields: viewModel.summaryFields)
72 .padding(.top, appDensity.metrics.cardSpacing)
73 if let report = viewModel.currentReport {
74 RiskSummaryCardView(report: report)
75 .padding(.top, appDensity.metrics.cardSpacing)
76 InsightsSummaryCardView(insights: report.insights)
77 .padding(.top, appDensity.metrics.cardSpacing)
78 }
79 if let changeSummary = viewModel.currentChangeSummary {
80 DomainChangeSummaryView(summary: changeSummary)
81 .padding(.top, appDensity.metrics.cardSpacing)
82 }
83 }
84 if let report = viewModel.currentReport {
85 intelligenceSection(report: report)
86 .padding(.top, appDensity.metrics.sectionSpacing)
87 }
88 domainOverviewSection
89 .padding(.top, appDensity.metrics.sectionSpacing)
90 ownershipSection
91 .padding(.top, appDensity.metrics.sectionSpacing)
92 subdomainsSection
93 .padding(.top, appDensity.metrics.sectionSpacing)
94 if !viewModel.currentDiffSections.isEmpty {
95 DomainDiffView(
96 title: "Latest Changes",
97 sections: viewModel.currentDiffSections,
98 contextNote: viewModel.currentChangeSummary?.contextNote,
99 showsUnchanged: false,
100 highlightedSectionID: nil
101 )
102 .padding(.top, appDensity.metrics.sectionSpacing)
103 }
104 dnsSection
105 .padding(.top, appDensity.metrics.sectionSpacing)
106 WebSectionView(
107 isCollapsed: sectionCollapsedBinding(.web),
108 certificateRows: viewModel.webCertificateRows,
109 sslInfo: viewModel.sslInfo,
110 tlsSummary: viewModel.currentTLSSummary,
111 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
112 sslError: viewModel.sslError,
113 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
114 responseRows: viewModel.webResponseRows,
115 headers: viewModel.httpHeaders,
116 headersLoading: viewModel.httpHeadersLoading,
117 headersError: viewModel.httpHeadersError,
118 httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
119 redirects: viewModel.redirectRows,
120 redirectLoading: viewModel.redirectChainLoading,
121 redirectError: viewModel.redirectChainError,
122 redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
123 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
124 )
125 .padding(.top, appDensity.metrics.sectionSpacing)
126 EmailSectionView(
127 isCollapsed: sectionCollapsedBinding(.email),
128 rows: viewModel.emailRows,
129 assessment: viewModel.currentEmailAssessment,
130 loading: viewModel.emailSecurityLoading,
131 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
132 confidence: viewModel.currentSnapshot.emailSecurityConfidence,
133 error: viewModel.emailSecurityError
134 )
135 .padding(.top, appDensity.metrics.sectionSpacing)
136 NetworkSectionView(
137 isCollapsed: sectionCollapsedBinding(.network),
138 reachabilityRows: viewModel.reachabilityRows,
139 reachabilityLoading: viewModel.reachabilityLoading,
140 reachabilityError: viewModel.reachabilityError,
141 reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
142 locationRows: viewModel.locationRows,
143 geolocation: viewModel.ipGeolocation,
144 geolocationLoading: viewModel.ipGeolocationLoading,
145 geolocationError: viewModel.ipGeolocationError,
146 geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
147 geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
148 standardPortRows: viewModel.standardPortRows,
149 customPortRows: viewModel.customPortRows,
150 portScanLoading: viewModel.portScanLoading,
151 portScanError: viewModel.portScanError,
152 portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
153 customPortScanLoading: viewModel.customPortScanLoading,
154 customPortScanError: viewModel.customPortScanError,
155 isCloudflareProxied: viewModel.isCloudflareProxied,
156 customPortsExpanded: $customPortsExpanded,
157 customPortInput: $customPortInput,
158 onScanCustomPorts: runCustomPortScan
159 )
160 .padding(.top, appDensity.metrics.sectionSpacing)
161 } else if !viewModel.recentSearches.isEmpty {
162 recentSearchesSection
163 }
164 }
165 .padding(.horizontal)
166 .padding(.bottom, 32)
167 }
168 .background(
169 LinearGradient(
170 colors: [Color(.appBackground), Color(.appSurface)],
171 startPoint: .top,
172 endPoint: .bottom
173 )
174 )
175 .navigationTitle("DomainDig")
176 .toolbar {
177 ToolbarItem(placement: .topBarTrailing) {
178 if viewModel.hasRun {
179 Button {
180 viewModel.reset()
181 } label: {
182 Image(systemName: "xmark.circle")
183 .foregroundStyle(Color(.appTextSecondary))
184 }
185 .accessibilityLabel("Clear results")
186 }
187 }
188 }
189 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
190 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
191 }
192 }
193 .task {
194 await viewModel.refreshUsageCredits()
195 }
196 .onChange(of: viewModel.searchedDomain) { _, _ in
197 collapsedSections = defaultCollapsedSections
198 }
199 .onChange(of: viewModel.resultsLoaded) { wasLoaded, isLoaded in
200 // Single-lookup completion has no single view-model moment
201 // (`resultsLoaded` is derived from many loading flags), so the
202 // announcement is posted from the view where the transition is
203 // observable. The batch path announces from the view model directly.
204 guard !wasLoaded, isLoaded, viewModel.hasRun else { return }
205 let summary = AppStatusFactory.availability(viewModel.availabilityResult?.status).title
206 AppAccessibility.announce("Lookup complete for \(viewModel.searchedDomain). \(summary).")
207 }
208 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
209 navigationPath = NavigationPath()
210 focusedInputField = nil
211 }
212 .onChange(of: inputMode) { _, newValue in
213 viewModel.clearPresentedResults()
214 focusedInputField = newValue == .single ? .singleDomain : .bulkDomains
215 }
216 .onChange(of: viewModel.domain) { _, newValue in
217 guard inputMode == .single else { return }
218 let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
219 guard normalized != viewModel.searchedDomain else { return }
220 guard viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
221 viewModel.clearPresentedResults()
222 }
223 .onChange(of: viewModel.bulkInput) { _, newValue in
224 guard inputMode == .bulk else { return }
225 let normalized = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
226 guard !normalized.isEmpty || viewModel.hasRun || !viewModel.batchResults.isEmpty else { return }
227 viewModel.clearPresentedResults()
228 }
229 .sheet(item: $editingTrackedDomain) { trackedDomain in
230 NavigationStack {
231 Form {
232 Section("Tracking Note") {
233 TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
234 .lineLimit(3...6)
235 .textInputAutocapitalization(.never)
236 .autocorrectionDisabled()
237 }
238 }
239 .navigationTitle(trackedDomain.domain)
240 .toolbar {
241 ToolbarItem(placement: .cancellationAction) {
242 Button("Cancel") {
243 editingTrackedDomain = nil
244 }
245 }
246 ToolbarItem(placement: .confirmationAction) {
247 Button("Save") {
248 viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
249 editingTrackedDomain = nil
250 }
251 }
252 }
253 }
254 }
255 .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
256 WorkflowBulkAddSheet(
257 viewModel: viewModel,
258 title: "Add Domain to Workflow",
259 availableDomains: [viewModel.searchedDomain]
260 )
261 }
262 .sheet(isPresented: $showingBatchWorkflowSheet) {
263 WorkflowBulkAddSheet(
264 viewModel: viewModel,
265 title: "Add Batch Domains",
266 availableDomains: viewModel.batchResults.map(\.domain)
267 )
268 }
269 .sheet(item: manualBatchSummaryBinding) { summary in
270 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
271 }
272 .sheet(isPresented: $showingTimeline) {
273 NavigationStack {
274 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
275 }
276 }
277 .sheet(isPresented: $showingAuditTimeline) {
278 NavigationStack {
279 AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
280 }
281 }
282 }
283
284 private var manualBatchSummaryBinding: Binding<BatchSweepSummary?> {
285 Binding(
286 get: {
287 guard let summary = viewModel.latestBatchSweepSummary,
288 summary.source == .manual else {
289 return nil
290 }
291 return summary
292 },
293 set: { viewModel.latestBatchSweepSummary = $0 }
294 )
295 }
296
297 private var inputSection: some View {
298 VStack(spacing: appDensity.metrics.cardSpacing + 2) {
299 Picker("Mode", selection: $inputMode) {
300 Text("Single").tag(LookupInputMode.single)
301 Text("Bulk").tag(LookupInputMode.bulk)
302 }
303 .pickerStyle(.segmented)
304
305 if inputMode == .single {
306 TextField("e.g. cleberg.net", text: $viewModel.domain)
307 .font(appDensity.font(.title3, design: .monospaced))
308 .textInputAutocapitalization(.never)
309 .autocorrectionDisabled()
310 .keyboardType(.URL)
311 .padding(.horizontal, 12)
312 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
313 .background(Color(.appSurfaceElevated))
314 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
315 .focused($focusedInputField, equals: .singleDomain)
316 .onSubmit {
317 focusedInputField = nil
318 viewModel.run()
319 }
320
321 Button {
322 focusedInputField = nil
323 viewModel.run()
324 } label: {
325 Text("Run")
326 .font(appDensity.font(.headline, design: .default, weight: .semibold))
327 .frame(maxWidth: .infinity)
328 .frame(minHeight: appDensity.metrics.controlMinHeight)
329 }
330 .buttonStyle(.borderedProminent)
331 .tint(Color(.accentFill))
332 .disabled(viewModel.trimmedDomain.isEmpty)
333 } else {
334 if FeatureAccessService.hasAccess(to: .batchOperations) {
335 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
336 Text("Paste domains separated by new lines or commas.")
337 .font(appDensity.font(.caption))
338 .foregroundStyle(Color(.appTextSecondary))
339
340 if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
341 Text(batchAllowanceSummary)
342 .font(appDensity.font(.caption2))
343 .foregroundStyle(Color(.appTextSecondary))
344 }
345
346 TextField(
347 "example.com\napple.com, openai.com",
348 text: $viewModel.bulkInput,
349 axis: .vertical
350 )
351 .font(appDensity.font(.body))
352 .textInputAutocapitalization(.never)
353 .autocorrectionDisabled()
354 .keyboardType(.URL)
355 .lineLimit(4...10)
356 .padding(.horizontal, 12)
357 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
358 .background(Color(.appSurfaceElevated))
359 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
360 .focused($focusedInputField, equals: .bulkDomains)
361
362 Button {
363 focusedInputField = nil
364 viewModel.runBulkLookup()
365 } label: {
366 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
367 .font(appDensity.font(.headline, design: .default, weight: .semibold))
368 .frame(maxWidth: .infinity)
369 .frame(minHeight: appDensity.metrics.controlMinHeight)
370 }
371 .buttonStyle(.borderedProminent)
372 .tint(Color(.accentFill))
373 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
374 }
375 } else {
376 lockedFeatureCard(
377 title: "Batch Operations",
378 message: FeatureAccessService.upgradeMessage(for: .batchOperations)
379 )
380 }
381 }
382 }
383 .padding(.vertical, appDensity.metrics.sectionSpacing)
384 }
385
386 private var domainOverviewSection: some View {
387 let trackedDomain = viewModel.currentTrackedDomain
388 let workflows = viewModel.currentDomainWorkflows
389
390 return DomainSectionView(
391 isCollapsed: sectionCollapsedBinding(.domain),
392 rows: viewModel.domainRows,
393 suggestions: viewModel.suggestionRows,
394 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
395 availabilityLoading: viewModel.availabilityLoading,
396 suggestionsLoading: viewModel.suggestionsLoading,
397 provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
398 confidence: viewModel.currentSnapshot.availabilityConfidence,
399 snapshotNote: viewModel.currentSnapshot.note,
400 trackedDomain: trackedDomain,
401 workflows: workflows,
402 trackingLimitMessage: viewModel.trackingLimitMessage,
403 pricingLoading: viewModel.domainPricingLoading,
404 pricingError: viewModel.domainPricingError,
405 showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing),
406 onTrack: {
407 _ = viewModel.trackCurrentDomain()
408 },
409 onTogglePinned: {
410 guard let trackedDomain else { return }
411 viewModel.togglePinned(for: trackedDomain)
412 },
413 onEditNote: {
414 guard let trackedDomain else { return }
415 trackingNoteDraft = trackedDomain.note ?? ""
416 editingTrackedDomain = trackedDomain
417 },
418 onAddToWorkflow: {
419 showingCurrentDomainWorkflowSheet = true
420 },
421 onOpenWorkflow: { workflow in
422 navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
423 },
424 onRunWorkflow: { workflow in
425 viewModel.rerunCurrentDomain(in: workflow)
426 }
427 )
428 }
429
430 private func intelligenceSection(report: DomainReport) -> some View {
431 IntelligenceSectionView(
432 isCollapsed: sectionCollapsedBinding(.intelligence),
433 report: report,
434 showsPlaceholder: FeatureAccessService.currentTier != .proPlus
435 )
436 }
437
438 private var ownershipSection: some View {
439 OwnershipSectionView(
440 isCollapsed: sectionCollapsedBinding(.ownership),
441 rows: viewModel.ownershipRows,
442 loading: viewModel.ownershipLoading,
443 error: viewModel.ownershipError,
444 provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
445 confidence: viewModel.currentSnapshot.ownershipConfidence,
446 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory),
447 history: viewModel.ownershipHistory,
448 historyLoading: viewModel.ownershipHistoryLoading,
449 historyError: viewModel.ownershipHistoryError,
450 historyCreditStatus: viewModel.ownershipHistoryCreditStatus,
451 onLoadHistory: {
452 Task {
453 await viewModel.loadOwnershipHistory()
454 }
455 }
456 )
457 }
458
459 private var subdomainsSection: some View {
460 SubdomainsSectionView(
461 isCollapsed: sectionCollapsedBinding(.subdomains),
462 rows: viewModel.subdomainRows,
463 groups: viewModel.currentSubdomainGroups,
464 loading: viewModel.subdomainsLoading,
465 error: viewModel.subdomainsError,
466 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
467 confidence: viewModel.currentSnapshot.subdomainConfidence,
468 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains),
469 extendedCount: viewModel.extendedSubdomains.count,
470 extendedLoading: viewModel.extendedSubdomainsLoading,
471 extendedError: viewModel.extendedSubdomainsError,
472 extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus,
473 onLoadExtended: {
474 Task {
475 await viewModel.loadExtendedSubdomains()
476 }
477 }
478 )
479 }
480
481 private var dnsSection: some View {
482 DNSSectionView(
483 isCollapsed: sectionCollapsedBinding(.dns),
484 dnssecLabel: viewModel.dnssecLabel,
485 patternSummary: viewModel.currentDNSPatterns,
486 sections: viewModel.dnsRows,
487 ptrMessage: viewModel.ptrMessage,
488 loading: viewModel.dnsLoading || viewModel.ptrLoading,
489 dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
490 ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
491 sectionError: viewModel.dnsError,
492 history: viewModel.dnsHistory,
493 historyLoading: viewModel.dnsHistoryLoading,
494 historyError: viewModel.dnsHistoryError,
495 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory),
496 historyCreditStatus: viewModel.dnsHistoryCreditStatus,
497 onLoadHistory: {
498 Task {
499 await viewModel.loadDNSHistory()
500 }
501 }
502 )
503 }
504
505 private var actionButtons: some View {
506 HStack {
507 Spacer()
508 if viewModel.resultsLoaded {
509 Menu {
510 if !viewModel.isCurrentDomainTracked {
511 Button("Track this domain") {
512 _ = viewModel.trackCurrentDomain()
513 }
514 }
515 Button("Add to workflow") {
516 showingCurrentDomainWorkflowSheet = true
517 }
518 Button(auditStartInFlight ? "Starting audit…" : "Start audit") {
519 Task {
520 auditStartInFlight = true
521 if await viewModel.startAudit(for: viewModel.searchedDomain) != nil {
522 showingAuditTimeline = true
523 }
524 auditStartInFlight = false
525 }
526 }
527 .disabled(auditStartInFlight)
528 if !viewModel.audits(for: viewModel.searchedDomain).isEmpty {
529 Button("View audits") {
530 showingAuditTimeline = true
531 }
532 }
533 if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
534 Button("Open timeline") {
535 showingTimeline = true
536 }
537 }
538 if FeatureAccessService.hasAccess(to: .advancedExports) {
539 Button("Copy report JSON") {
540 guard let json = viewModel.exportJSONString() else { return }
541 AppClipboard.copy(json)
542 AppHaptics.copy()
543 }
544 } else {
545 Button("Copy report JSON") {
546 viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
547 }
548 }
549 Button("Export report") {
550 shareSingleResults(format: .text)
551 }
552 } label: {
553 Image(systemName: "bolt.circle")
554 .font(appDensity.font(.body, design: .default))
555 .foregroundStyle(Color(.appTextSecondary))
556 }
557 .accessibilityLabel("Actions")
558 Button {
559 viewModel.toggleSavedDomain()
560 } label: {
561 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
562 .font(appDensity.font(.body, design: .default))
563 .foregroundStyle(viewModel.isCurrentDomainSaved ? Color(.statusWarning) : .secondary)
564 }
565 .accessibilityLabel("Save domain")
566 .accessibilityValue(viewModel.isCurrentDomainSaved ? "Saved" : "Not saved")
567 .accessibilityAddTraits(viewModel.isCurrentDomainSaved ? .isSelected : [])
568 Menu {
569 Button("Export TXT") {
570 shareSingleResults(format: .text)
571 }
572 if FeatureAccessService.hasAccess(to: .advancedExports) {
573 Button("Export CSV") {
574 shareSingleResults(format: .csv)
575 }
576 Button("Export JSON") {
577 shareSingleResults(format: .json)
578 }
579 Button("Export Markdown") {
580 shareSingleResults(format: .markdown)
581 }
582 Button("Export PDF") {
583 shareSingleResults(format: .pdf)
584 }
585 } else {
586 Button("CSV Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
587 .disabled(true)
588 Button("JSON Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
589 .disabled(true)
590 Button("Markdown Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
591 .disabled(true)
592 Button("PDF Export • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
593 .disabled(true)
594 }
595 } label: {
596 Image(systemName: "square.and.arrow.up")
597 .font(appDensity.font(.body, design: .default))
598 .foregroundStyle(Color(.appTextSecondary))
599 }
600 .accessibilityLabel("Export")
601 }
602 }
603 }
604
605 private var batchSection: some View {
606 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
607 HStack {
608 Spacer()
609 if viewModel.batchLookupRunning {
610 Button("Cancel") {
611 viewModel.cancelBatchLookup()
612 }
613 .buttonStyle(.bordered)
614 .font(appDensity.font(.caption))
615 }
616 if !viewModel.currentBatchResultEntries.isEmpty {
617 Menu {
618 Button("Add to Workflow") {
619 showingBatchWorkflowSheet = true
620 }
621 Divider()
622 Button("Export Batch TXT") {
623 shareBatchResults(format: .text)
624 }
625 if FeatureAccessService.hasAccess(to: .advancedExports) {
626 Button("Export Batch CSV") {
627 shareBatchResults(format: .csv)
628 }
629 Button("Export Batch JSON") {
630 shareBatchResults(format: .json)
631 }
632 Button("Export Batch Markdown") {
633 shareBatchResults(format: .markdown)
634 }
635 Button("Export Batch PDF") {
636 shareBatchResults(format: .pdf)
637 }
638 } else {
639 Button("Batch CSV • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
640 .disabled(true)
641 Button("Batch JSON • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
642 .disabled(true)
643 Button("Batch Markdown • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
644 .disabled(true)
645 Button("Batch PDF • Available in Pro") { /* Inert: disabled Pro upsell affordance. */ }
646 .disabled(true)
647 }
648 } label: {
649 Label("Export", systemImage: "square.and.arrow.up")
650 .font(appDensity.font(.caption))
651 }
652 .buttonStyle(.bordered)
653 }
654 }
655
656 BatchResultsView(
657 viewModel: viewModel,
658 title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
659 )
660 }
661 }
662
663 private var recentSearchesSection: some View {
664 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
665 HStack {
666 Text("RECENT")
667 .font(appDensity.font(.caption2))
668 .foregroundStyle(Color(.appTextSecondary))
669 Spacer()
670 Button("Clear") {
671 viewModel.clearRecentSearches()
672 }
673 .font(appDensity.font(.caption2))
674 .foregroundStyle(Color(.appTextSecondary))
675 }
676
677 ForEach(viewModel.recentSearches, id: \.self) { domain in
678 Button {
679 viewModel.domain = domain
680 focusedInputField = nil
681 viewModel.run()
682 } label: {
683 Text(domain)
684 .font(appDensity.font(.callout))
685 .foregroundStyle(.primary)
686 .frame(maxWidth: .infinity, alignment: .leading)
687 .padding(.vertical, 8)
688 .padding(.horizontal, 10)
689 .background(Color(.appSurface))
690 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
691 }
692 }
693 }
694 .padding(.top, appDensity.metrics.cardSpacing)
695 }
696
697 private func runCustomPortScan() {
698 let ports = parsedCustomPorts(from: customPortInput)
699 Task {
700 await viewModel.runCustomPortScan(ports: ports)
701 }
702 }
703
704 private func lockedFeatureCard(title: String, message: String) -> some View {
705 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
706 Text(title)
707 .font(appDensity.font(.headline, design: .default, weight: .semibold))
708 Text(message)
709 .font(appDensity.font(.callout, design: .default))
710 .foregroundStyle(Color(.appTextSecondary))
711 }
712 .frame(maxWidth: .infinity, alignment: .leading)
713 .padding(appDensity.metrics.cardPadding)
714 .background(Color(.appSurfaceElevated))
715 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
716 }
717
718 private func parsedCustomPorts(from input: String) -> [UInt16] {
719 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
720 var seen = Set<UInt16>()
721 var ports: [UInt16] = []
722
723 for part in parts {
724 let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines)
725 guard let value = UInt16(trimmed), seen.insert(value).inserted else {
726 continue
727 }
728 ports.append(value)
729 if ports.count == 20 {
730 break
731 }
732 }
733
734 return ports
735 }
736
737 private func shareSingleResults(format: DomainExportFormat) {
738 guard let data = viewModel.exportSingleReportData(format: format) else { return }
739 ExportPresenter.share(filename: exportFilename(prefix: "domaindig_single", format: format), data: data)
740 }
741
742 private func shareBatchResults(format: DomainExportFormat) {
743 guard let data = viewModel.exportBatchReportData(format: format) else { return }
744 ExportPresenter.share(filename: exportFilename(prefix: "domaindig_batch", format: format), data: data)
745 }
746
747 private func exportFilename(prefix: String, format: DomainExportFormat) -> String {
748 let formatter = DateFormatter()
749 formatter.dateFormat = "yyyyMMdd_HHmmss"
750 let timestamp = formatter.string(from: Date())
751 return "\(timestamp)_\(prefix).\(format.fileExtension)"
752 }
753
754 private var defaultCollapsedSections: Set<ResultSection> {
755 []
756 }
757
758 private var currentPrimaryIP: String? {
759 viewModel.currentSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
760 }
761
762 private var resultStatusMessage: String? {
763 if let currentStatusMessage = viewModel.currentStatusMessage {
764 return currentStatusMessage
765 }
766
767 if viewModel.currentResultSource != .live {
768 return viewModel.currentResultSource.label
769 }
770
771 return nil
772 }
773
774 private func sectionCollapsedBinding(_ section: ResultSection) -> Binding<Bool> {
775 Binding(
776 get: { collapsedSections.contains(section) },
777 set: { isCollapsed in
778 if isCollapsed {
779 collapsedSections.insert(section)
780 } else {
781 collapsedSections.remove(section)
782 }
783 }
784 )
785 }
786}
787
788struct SummaryView: View {
789 @Environment(\.appDensity) private var appDensity
790 let fields: [SummaryFieldViewData]
791
792 var body: some View {
793 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
794 SectionTitleView(title: "Summary")
795 LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: appDensity.metrics.cardSpacing) {
796 ForEach(fields) { field in
797 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing) {
798 Text(field.label)
799 .font(appDensity.font(.caption2))
800 .foregroundStyle(Color(.appTextSecondary))
801 Text(field.value)
802 .font(appDensity.font(.caption))
803 .foregroundStyle(ResultColors.color(for: field.tone))
804 .lineLimit(2)
805 .textSelection(.enabled)
806 }
807 .frame(minHeight: appDensity.metrics.rowMinHeight + 12, alignment: .topLeading)
808 .frame(maxWidth: .infinity, alignment: .leading)
809 .padding(appDensity.metrics.cardPadding)
810 .background(Color(.appSurface))
811 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
812 }
813 }
814 }
815 }
816}
817
818struct RiskSummaryCardView: View {
819 @Environment(\.appDensity) private var appDensity
820 let report: DomainReport
821
822 private var topFactors: [RiskFactor] {
823 Array(report.riskAssessment.factors.prefix(3))
824 }
825
826 var body: some View {
827 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
828 SectionTitleView(title: "Risk")
829 CardView(allowsHorizontalScroll: false) {
830 HStack(alignment: .firstTextBaseline) {
831 VStack(alignment: .leading, spacing: 4) {
832 Text("\(report.riskAssessment.score)")
833 .font(appDensity.font(.largeTitle, weight: .bold))
834 .foregroundStyle(levelColor)
835 Text(report.riskAssessment.level.title)
836 .font(appDensity.font(.caption))
837 .foregroundStyle(levelColor)
838 }
839 Spacer()
840 Text("Deterministic")
841 .font(appDensity.font(.caption2))
842 .foregroundStyle(Color(.appTextSecondary))
843 }
844
845 if topFactors.isEmpty {
846 Text("No major risk factors identified")
847 .font(appDensity.font(.caption))
848 .foregroundStyle(Color(.appTextSecondary))
849 } else {
850 ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
851 HStack(alignment: .top, spacing: 8) {
852 Circle()
853 .fill(factorColor(factor.impact))
854 .frame(width: 8, height: 8)
855 .padding(.top, 5)
856 Text(factor.description)
857 .font(appDensity.font(.caption))
858 .foregroundStyle(.primary)
859 }
860 }
861 }
862 }
863 }
864 }
865
866 private var levelColor: Color {
867 switch report.riskAssessment.level {
868 case .low:
869 return Color(.statusPositive)
870 case .medium:
871 return Color(.statusWarning)
872 case .high:
873 return Color(.statusCritical)
874 }
875 }
876
877 private func factorColor(_ impact: RiskImpact) -> Color {
878 switch impact {
879 case .positive:
880 return Color(.statusPositive)
881 case .neutral:
882 return .secondary
883 case .negative:
884 return Color(.statusCritical)
885 }
886 }
887}
888
889struct InsightsSummaryCardView: View {
890 @Environment(\.appDensity) private var appDensity
891 let insights: [String]
892
893 var body: some View {
894 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
895 SectionTitleView(title: "Insights")
896 CardView(allowsHorizontalScroll: false) {
897 if insights.isEmpty {
898 Text("No deterministic insights triggered")
899 .font(appDensity.font(.caption))
900 .foregroundStyle(Color(.appTextSecondary))
901 } else {
902 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
903 HStack(alignment: .top, spacing: 8) {
904 Image(systemName: "chart.line.uptrend.xyaxis")
905 .font(appDensity.font(.caption2))
906 .foregroundStyle(Color(.statusInfo))
907 .padding(.top, 2)
908 Text(insight)
909 .font(appDensity.font(.caption))
910 .foregroundStyle(.primary)
911 }
912 }
913 }
914 }
915 }
916 }
917}
918
919struct StickyLookupSummaryView: View {
920 @Environment(\.appDensity) private var appDensity
921
922 let domain: String
923 let availability: DomainAvailabilityStatus?
924 let primaryIP: String?
925 let sslInfo: SSLCertificateInfo?
926 let sslError: String?
927 let emailSecurity: EmailSecurityResult?
928 let emailError: String?
929 let changeSummary: DomainChangeSummary?
930
931 var body: some View {
932 CardView(allowsHorizontalScroll: false) {
933 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
934 HStack(alignment: .center, spacing: 10) {
935 Text(domain)
936 .font(appDensity.font(.headline, weight: .semibold))
937 .foregroundStyle(.primary)
938 .lineLimit(1)
939 Spacer(minLength: 6)
940 AppCopyButton(value: domain, label: "Copy domain")
941 }
942
943 ScrollView(.horizontal, showsIndicators: false) {
944 HStack(spacing: 8) {
945 AppStatusBadgeView(model: AppStatusFactory.availability(availability))
946 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
947 AppStatusBadgeView(model: AppStatusFactory.email(emailSecurity, error: emailError))
948 AppStatusBadgeView(model: AppStatusFactory.change(changeSummary))
949 }
950 }
951
952 if let primaryIP {
953 HStack(spacing: 8) {
954 Label(primaryIP, systemImage: "network")
955 .font(appDensity.font(.caption))
956 .foregroundStyle(Color(.appTextSecondary))
957 Spacer(minLength: 6)
958 AppCopyButton(value: primaryIP, label: "Copy IP")
959 }
960 }
961 }
962 }
963 .shadow(color: .black.opacity(0.12), radius: 14, y: 6)
964 }
965}
966
967struct LookupProgressOverviewView: View {
968 @Environment(\.appDensity) private var appDensity
969 let steps: [String]
970
971 var body: some View {
972 CardView(allowsHorizontalScroll: false) {
973 HStack(spacing: 8) {
974 ProgressView()
975 .controlSize(.small)
976 VStack(alignment: .leading, spacing: 4) {
977 Text("Running lookup…")
978 .font(appDensity.font(.caption))
979 .foregroundStyle(.primary)
980 Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: " • "))
981 .font(appDensity.font(.caption2))
982 .foregroundStyle(Color(.appTextSecondary))
983 }
984 Spacer()
985 }
986 }
987 }
988}
989
990struct LookupStatusBannerView: View {
991 @Environment(\.appDensity) private var appDensity
992 let message: String
993 let resultSource: LookupResultSource
994
995 var body: some View {
996 HStack(spacing: 8) {
997 Image(systemName: iconName)
998 .font(.caption)
999 Text(message)
1000 .font(appDensity.font(.caption))
1001 Spacer()
1002 }
1003 .foregroundStyle(color)
1004 .padding(appDensity.metrics.cardPadding - 2)
1005 .frame(maxWidth: .infinity, alignment: .leading)
1006 .background(color.opacity(0.12))
1007 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
1008 }
1009
1010 private var color: Color {
1011 switch resultSource {
1012 case .live:
1013 return Color(.statusPositive)
1014 case .cached:
1015 return .secondary
1016 case .mixed:
1017 return Color(.statusWarning)
1018 case .snapshot:
1019 return Color(.statusWarning)
1020 }
1021 }
1022
1023 private var iconName: String {
1024 switch resultSource {
1025 case .live:
1026 return "bolt.horizontal"
1027 case .cached:
1028 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
1029 case .mixed:
1030 return "arrow.triangle.branch"
1031 case .snapshot:
1032 return "archivebox"
1033 }
1034 }
1035}
1036
1037struct DomainChangeSummaryView: View {
1038 @Environment(\.appDensity) private var appDensity
1039 let summary: DomainChangeSummary
1040 @State private var showsDetails = false
1041
1042 var body: some View {
1043 CardView(allowsHorizontalScroll: false) {
1044 HStack {
1045 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
1046 .font(appDensity.font(.caption))
1047 .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.statusPositive))
1048 Spacer()
1049 Text(summary.severity.title.uppercased())
1050 .font(appDensity.font(.caption2))
1051 .foregroundStyle(summary.hasChanges ? severityTone(summary.severity).foreground : Color(.appTextSecondary))
1052 .padding(.horizontal, 8)
1053 .padding(.vertical, 4)
1054 .background((summary.hasChanges ? severityTone(summary.severity) : AppStatusTone.neutral).surface)
1055 .clipShape(Capsule())
1056 Text(summary.impactClassification.title.uppercased())
1057 .font(appDensity.font(.caption2))
1058 .foregroundStyle(summary.impactClassification.tone.foreground)
1059 .padding(.horizontal, 8)
1060 .padding(.vertical, 4)
1061 .background(summary.impactClassification.tone.surface)
1062 .clipShape(Capsule())
1063 Text(summary.generatedAt, style: .time)
1064 .font(appDensity.font(.caption2))
1065 .foregroundStyle(Color(.appTextSecondary))
1066 }
1067
1068 VStack(alignment: .leading, spacing: 4) {
1069 Text("Inference")
1070 .font(appDensity.font(.caption2))
1071 .foregroundStyle(Color(.appTextSecondary))
1072 Text(summary.message)
1073 .font(appDensity.font(.caption))
1074 .foregroundStyle(.primary)
1075 .lineLimit(2)
1076 }
1077
1078 if !summary.observedFacts.isEmpty || summary.contextNote != nil {
1079 DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) {
1080 VStack(alignment: .leading, spacing: 8) {
1081 if !summary.observedFacts.isEmpty {
1082 VStack(alignment: .leading, spacing: 4) {
1083 Text("Observed")
1084 .font(appDensity.font(.caption2))
1085 .foregroundStyle(Color(.appTextSecondary))
1086 ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in
1087 Text(fact)
1088 .font(appDensity.font(.caption))
1089 .foregroundStyle(.primary)
1090 }
1091 }
1092 }
1093
1094 if let riskScoreDelta = summary.riskScoreDelta {
1095 Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
1096 .font(appDensity.font(.caption2))
1097 .foregroundStyle(riskScoreDelta > 0 ? Color(.statusWarning) : .secondary)
1098 }
1099
1100 if let contextNote = summary.contextNote {
1101 Text(contextNote)
1102 .font(appDensity.font(.caption2))
1103 .foregroundStyle(Color(.statusWarning))
1104 }
1105 }
1106 .padding(.top, 4)
1107 }
1108 .font(appDensity.font(.caption))
1109 .tint(.secondary)
1110 }
1111 }
1112 }
1113
1114 private func severityTone(_ severity: ChangeSeverity) -> AppStatusTone {
1115 switch severity {
1116 case .low:
1117 return .neutral
1118 case .medium:
1119 return .warning
1120 case .high:
1121 return .critical
1122 }
1123 }
1124
1125}
1126
1127struct DomainDiffView: View {
1128 let title: String
1129 let sections: [DomainDiffSection]
1130 let contextNote: String?
1131 let showsUnchanged: Bool
1132 let highlightedSectionID: String?
1133
1134 @State private var collapsedSections = Set<String>()
1135 @State private var showsLowSeverity = false
1136
1137 private var filteredSections: [DomainDiffSection] {
1138 sections
1139 .map { section in
1140 let items = section.items.filter { item in
1141 if !showsUnchanged, !item.hasChanges {
1142 return false
1143 }
1144 if showsLowSeverity {
1145 return true
1146 }
1147 return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
1148 }
1149 return DomainDiffSection(id: section.id, title: section.title, items: items)
1150 }
1151 .filter { !$0.items.isEmpty }
1152 }
1153
1154 private var hasLowSeverityChanges: Bool {
1155 sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
1156 }
1157
1158 var body: some View {
1159 VStack(alignment: .leading, spacing: 12) {
1160 HStack {
1161 SectionTitleView(title: title)
1162 Spacer()
1163 if hasLowSeverityChanges {
1164 Button(showsLowSeverity ? "Hide Low" : "Show Low") {
1165 showsLowSeverity.toggle()
1166 }
1167 .buttonStyle(.bordered)
1168 .font(.system(.caption, design: .monospaced))
1169 }
1170 }
1171 if let contextNote {
1172 MessageCardView(text: contextNote, isError: false)
1173 }
1174 if filteredSections.isEmpty {
1175 MessageCardView(text: "No comparison data available", isError: false)
1176 } else {
1177 ForEach(filteredSections) { section in
1178 CardView(allowsHorizontalScroll: false) {
1179 DisclosureGroup(isExpanded: binding(for: section)) {
1180 let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
1181
1182 ForEach(visibleItems) { item in
1183 VStack(alignment: .leading, spacing: 6) {
1184 HStack {
1185 Text(item.label)
1186 .font(.system(.caption, design: .monospaced))
1187 .foregroundStyle(Color(.appTextSecondary))
1188 Spacer()
1189 Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))")
1190 .font(.system(.caption2, design: .monospaced))
1191 .foregroundStyle(changeTone(for: item).foreground)
1192 .padding(.horizontal, 8)
1193 .padding(.vertical, 4)
1194 .background(changeTone(for: item).surface)
1195 .clipShape(Capsule())
1196 }
1197
1198 if let oldValue = item.oldValue {
1199 VStack(alignment: .leading, spacing: 2) {
1200 Text("Old")
1201 .font(.system(.caption2, design: .monospaced))
1202 .foregroundStyle(Color(.appTextSecondary))
1203 Text(oldValue)
1204 .font(.system(.caption2, design: .monospaced))
1205 .foregroundStyle(Color(.appTextSecondary))
1206 .textSelection(.enabled)
1207 }
1208 }
1209
1210 if let newValue = item.newValue {
1211 VStack(alignment: .leading, spacing: 2) {
1212 Text("New")
1213 .font(.system(.caption2, design: .monospaced))
1214 .foregroundStyle(Color(.appTextSecondary))
1215 Text(newValue)
1216 .font(.system(.caption, design: .monospaced))
1217 .foregroundStyle(item.hasChanges ? .primary : .secondary)
1218 .textSelection(.enabled)
1219 }
1220 }
1221 }
1222 .padding(10)
1223 .background(item.hasChanges ? changeTone(for: item).surface : Color(.appSurface))
1224 .cornerRadius(8)
1225 }
1226 } label: {
1227 HStack {
1228 Text(section.title)
1229 .font(.system(.subheadline, design: .monospaced))
1230 .fontWeight(.semibold)
1231 .foregroundStyle(sectionColor(section))
1232 Spacer()
1233 Text(section.severity.title)
1234 .font(.system(.caption2, design: .monospaced))
1235 .foregroundStyle(sectionColor(section))
1236 }
1237 }
1238 }
1239 .id(section.id)
1240 .overlay {
1241 if highlightedSectionID == section.id {
1242 RoundedRectangle(cornerRadius: 12)
1243 .stroke(Color(.statusInfo).opacity(0.55), lineWidth: 1)
1244 }
1245 }
1246 }
1247 }
1248 }
1249 .onAppear {
1250 collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1251 }
1252 }
1253
1254 private func changeLabel(for changeType: DiffChangeType) -> String {
1255 switch changeType {
1256 case .added:
1257 return "Added"
1258 case .removed:
1259 return "Removed"
1260 case .changed:
1261 return "Changed"
1262 case .unchanged:
1263 return "Unchanged"
1264 }
1265 }
1266
1267 private func changeTone(for item: DomainDiffItem) -> AppStatusTone {
1268 if item.changeType == .unchanged {
1269 return .neutral
1270 }
1271
1272 switch item.severity {
1273 case .low:
1274 return .info
1275 case .medium:
1276 return .warning
1277 case .high:
1278 return .critical
1279 }
1280 }
1281
1282 private func sectionColor(_ section: DomainDiffSection) -> Color {
1283 switch section.severity {
1284 case .low:
1285 return .blue
1286 case .medium:
1287 return Color(.statusWarning)
1288 case .high:
1289 return Color(.statusCritical)
1290 }
1291 }
1292
1293 private func binding(for section: DomainDiffSection) -> Binding<Bool> {
1294 Binding(
1295 get: { !collapsedSections.contains(section.id) },
1296 set: { isExpanded in
1297 if isExpanded {
1298 collapsedSections.remove(section.id)
1299 } else {
1300 collapsedSections.insert(section.id)
1301 }
1302 }
1303 )
1304 }
1305}
1306
1307struct TrackedDomainDetailHeaderView: View {
1308 let trackedDomain: TrackedDomain
1309
1310 var body: some View {
1311 VStack(alignment: .leading, spacing: 4) {
1312 if let note = trackedDomain.note?.nilIfEmpty {
1313 LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
1314 }
1315 HStack(spacing: 8) {
1316 if trackedDomain.isPinned {
1317 Label("Pinned", systemImage: "pin.fill")
1318 }
1319 Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
1320 }
1321 .font(.system(.caption2, design: .monospaced))
1322 .foregroundStyle(Color(.appTextSecondary))
1323 }
1324 }
1325}
1326
1327struct DomainSectionView: View {
1328 @Environment(\.appDensity) private var appDensity
1329 @Binding var isCollapsed: Bool
1330 let rows: [InfoRowViewData]
1331 let suggestions: [DomainSuggestionViewData]
1332 let showSuggestions: Bool
1333 let availabilityLoading: Bool
1334 let suggestionsLoading: Bool
1335 let provenance: SectionProvenance?
1336 let confidence: ConfidenceLevel?
1337 let snapshotNote: String?
1338 let trackedDomain: TrackedDomain?
1339 let workflows: [DomainWorkflow]
1340 let trackingLimitMessage: String?
1341 let pricingLoading: Bool
1342 let pricingError: String?
1343 let showsPricingPlaceholder: Bool
1344 let onTrack: () -> Void
1345 let onTogglePinned: () -> Void
1346 let onEditNote: (() -> Void)?
1347 let onAddToWorkflow: (() -> Void)?
1348 let onOpenWorkflow: ((DomainWorkflow) -> Void)?
1349 let onRunWorkflow: ((DomainWorkflow) -> Void)?
1350
1351 var body: some View {
1352 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
1353 if let trackedDomain {
1354 HStack(spacing: 8) {
1355 // Icon-only: the header also carries Pin and Note, and the
1356 // full "Tracked" pill compresses at larger text sizes.
1357 // VoiceOver still hears the word via the label.
1358 Image(systemName: "eye.fill")
1359 .font(appDensity.font(.caption))
1360 .foregroundStyle(Color(.statusPositive))
1361 .padding(6)
1362 .background(Color(.statusPositiveSurface), in: Circle())
1363 .fixedSize()
1364 .accessibilityLabel("Tracked")
1365 Button {
1366 onTogglePinned()
1367 } label: {
1368 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
1369 }
1370 .buttonStyle(.bordered)
1371 .font(appDensity.font(.caption))
1372 .accessibilityLabel("Pin domain")
1373 .accessibilityValue(trackedDomain.isPinned ? "Pinned" : "Not pinned")
1374 .accessibilityAddTraits(trackedDomain.isPinned ? .isSelected : [])
1375 if let onEditNote {
1376 Button("Note") {
1377 onEditNote()
1378 }
1379 .buttonStyle(.bordered)
1380 .font(appDensity.font(.caption))
1381 // Never compress into a vertical letter column.
1382 .fixedSize()
1383 }
1384 }
1385 } else {
1386 Button("Track") {
1387 AppHaptics.track()
1388 onTrack()
1389 }
1390 .buttonStyle(.bordered)
1391 .font(appDensity.font(.caption))
1392 .fixedSize()
1393 }
1394 } content: {
1395 CardView(allowsHorizontalScroll: false) {
1396 SectionTrustMetadataView(
1397 provenance: provenance,
1398 confidence: confidence,
1399 note: snapshotNote == nil ? nil : "Audit note present"
1400 )
1401 ForEach(rows) { row in
1402 LabeledValueRow(row: row)
1403 }
1404 if let trackedDomain {
1405 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
1406 .padding(.top, 4)
1407 } else if let trackingLimitMessage {
1408 MessageRowView(text: trackingLimitMessage, isError: false)
1409 .padding(.top, 4)
1410 }
1411 if let onAddToWorkflow {
1412 Button {
1413 onAddToWorkflow()
1414 } label: {
1415 Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
1416 .font(appDensity.font(.caption))
1417 }
1418 .buttonStyle(.bordered)
1419 .padding(.top, 4)
1420 }
1421 if !workflows.isEmpty {
1422 VStack(alignment: .leading, spacing: 8) {
1423 Text("Part of workflow")
1424 .font(appDensity.font(.caption))
1425 .foregroundStyle(Color(.appTextSecondary))
1426
1427 ForEach(workflows) { workflow in
1428 HStack {
1429 Text(workflow.name)
1430 .font(appDensity.font(.caption))
1431 .foregroundStyle(.primary)
1432 Spacer()
1433 if let onOpenWorkflow {
1434 Button("Open") {
1435 onOpenWorkflow(workflow)
1436 }
1437 .buttonStyle(.bordered)
1438 .font(appDensity.font(.caption2))
1439 }
1440 if let onRunWorkflow {
1441 Button("Run") {
1442 onRunWorkflow(workflow)
1443 }
1444 .buttonStyle(.bordered)
1445 .font(appDensity.font(.caption2))
1446 }
1447 }
1448 }
1449 }
1450 .padding(.top, 4)
1451 }
1452 if availabilityLoading {
1453 ProgressView("Checking availability…")
1454 .appLoadingStyle()
1455 .padding(.top, 4)
1456 }
1457 if showSuggestions {
1458 Text("Suggestions")
1459 .font(appDensity.font(.caption))
1460 .foregroundStyle(Color(.appTextSecondary))
1461 .padding(.top, 4)
1462 if suggestionsLoading {
1463 ProgressView("Checking alternatives…")
1464 .appLoadingStyle()
1465 } else if suggestions.isEmpty {
1466 MessageRowView(text: "No suggestions", isError: false)
1467 } else {
1468 ForEach(suggestions) { suggestion in
1469 HStack {
1470 Text(suggestion.domain)
1471 .font(appDensity.font(.caption))
1472 .foregroundStyle(.primary)
1473 .textSelection(.enabled)
1474 Spacer()
1475 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
1476 }
1477 }
1478 }
1479 }
1480 if pricingLoading {
1481 ProgressView("Loading external pricing…")
1482 .appLoadingStyle()
1483 .padding(.top, 4)
1484 } else if let pricingError {
1485 MessageRowView(text: pricingError, isError: false)
1486 .padding(.top, 4)
1487 } else if showsPricingPlaceholder {
1488 MessageRowView(text: "Pricing signals available in Pro+", isError: false)
1489 .padding(.top, 4)
1490 }
1491 }
1492 }
1493 }
1494}
1495
1496struct OwnershipSectionView: View {
1497 @Environment(\.appDensity) private var appDensity
1498 @Binding var isCollapsed: Bool
1499 let rows: [InfoRowViewData]
1500 let loading: Bool
1501 let error: String?
1502 let provenance: SectionProvenance?
1503 let confidence: ConfidenceLevel?
1504 let showsHistoryPlaceholder: Bool
1505 let history: [DomainOwnershipHistoryEvent]
1506 let historyLoading: Bool
1507 let historyError: String?
1508 let historyCreditStatus: UsageCreditStatus?
1509 let onLoadHistory: (() -> Void)?
1510
1511 init(
1512 isCollapsed: Binding<Bool>,
1513 rows: [InfoRowViewData],
1514 loading: Bool,
1515 error: String?,
1516 provenance: SectionProvenance?,
1517 confidence: ConfidenceLevel?,
1518 showsHistoryPlaceholder: Bool,
1519 history: [DomainOwnershipHistoryEvent] = [],
1520 historyLoading: Bool = false,
1521 historyError: String? = nil,
1522 historyCreditStatus: UsageCreditStatus? = nil,
1523 onLoadHistory: (() -> Void)? = nil
1524 ) {
1525 _isCollapsed = isCollapsed
1526 self.rows = rows
1527 self.loading = loading
1528 self.error = error
1529 self.provenance = provenance
1530 self.confidence = confidence
1531 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1532 self.history = history
1533 self.historyLoading = historyLoading
1534 self.historyError = historyError
1535 self.historyCreditStatus = historyCreditStatus
1536 self.onLoadHistory = onLoadHistory
1537 }
1538
1539 var body: some View {
1540 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
1541 CardView(allowsHorizontalScroll: false) {
1542 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1543 if loading {
1544 ProgressView("Fetching RDAP ownership…")
1545 .appLoadingStyle()
1546 } else {
1547 ForEach(rows) { row in
1548 LabeledValueRow(row: row)
1549 }
1550 if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
1551 MessageRowView(text: error, isError: error != "Unavailable")
1552 .padding(.top, 4)
1553 }
1554 VStack(alignment: .leading, spacing: 8) {
1555 HStack {
1556 Text("History")
1557 .font(appDensity.font(.caption))
1558 .foregroundStyle(Color(.appTextSecondary))
1559 Spacer()
1560 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1561 Button("Load") {
1562 onLoadHistory()
1563 }
1564 .buttonStyle(.bordered)
1565 .font(appDensity.font(.caption2))
1566 }
1567 }
1568 if historyLoading {
1569 ProgressView("Loading history…")
1570 .appLoadingStyle()
1571 } else if !history.isEmpty {
1572 ForEach(history) { event in
1573 VStack(alignment: .leading, spacing: 3) {
1574 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1575 .font(appDensity.font(.caption2))
1576 .foregroundStyle(Color(.appTextSecondary))
1577 Text(event.summary)
1578 .font(appDensity.font(.caption))
1579 Text(event.source)
1580 .font(appDensity.font(.caption2))
1581 .foregroundStyle(Color(.appTextSecondary))
1582 }
1583 }
1584 } else if let historyError {
1585 MessageRowView(text: historyError, isError: false)
1586 } else if showsHistoryPlaceholder {
1587 MessageRowView(text: "Ownership history available in Pro+", isError: false)
1588 }
1589 }
1590 }
1591 }
1592 }
1593 }
1594}
1595
1596struct IntelligenceSectionView: View {
1597 @Environment(\.appDensity) private var appDensity
1598 @Binding var isCollapsed: Bool
1599 let report: DomainReport
1600 let showsPlaceholder: Bool
1601
1602 var body: some View {
1603 CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) {
1604 CardView(allowsHorizontalScroll: false) {
1605 if showsPlaceholder {
1606 MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false)
1607 } else {
1608 if let provider = report.inferredProvider {
1609 intelligenceBlock(title: "Infrastructure") {
1610 LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary))
1611 if !provider.evidence.isEmpty {
1612 MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false)
1613 }
1614 if !report.priorProviders.isEmpty {
1615 LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary))
1616 }
1617 }
1618 }
1619 if let classification = report.domainClassification {
1620 intelligenceBlock(title: "Classification") {
1621 LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary))
1622 MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false)
1623 }
1624 }
1625 intelligenceBlock(title: "Risk Signals") {
1626 if report.riskSignals.isEmpty {
1627 MessageRowView(text: "No material historical risk signals detected", isError: false)
1628 } else {
1629 ForEach(report.riskSignals.prefix(4)) { signal in
1630 VStack(alignment: .leading, spacing: 3) {
1631 Text(signal.title)
1632 .font(appDensity.font(.caption, weight: .semibold))
1633 Text(signal.detail)
1634 .font(appDensity.font(.caption2))
1635 .foregroundStyle(Color(.appTextSecondary))
1636 }
1637 }
1638 }
1639 }
1640 intelligenceBlock(title: "Ownership History") {
1641 if report.ownershipTransitions.isEmpty {
1642 MessageRowView(text: "No ownership transitions observed locally", isError: false)
1643 } else {
1644 ForEach(report.ownershipTransitions.prefix(4)) { event in
1645 intelligenceEventRow(date: event.date, title: event.summary)
1646 }
1647 }
1648 }
1649 intelligenceBlock(title: "Hosting History") {
1650 if report.hostingTransitions.isEmpty {
1651 MessageRowView(text: "No hosting transitions observed locally", isError: false)
1652 } else {
1653 ForEach(report.hostingTransitions.prefix(4)) { event in
1654 intelligenceEventRow(date: event.date, title: event.summary)
1655 }
1656 }
1657 }
1658 intelligenceBlock(title: "Subdomain Intelligence") {
1659 if report.subdomainHistory.isEmpty {
1660 MessageRowView(text: "No subdomain history available", isError: false)
1661 } else {
1662 ForEach(report.subdomainHistory.prefix(5)) { item in
1663 VStack(alignment: .leading, spacing: 3) {
1664 HStack {
1665 Text(item.hostname)
1666 .font(appDensity.font(.caption))
1667 Spacer()
1668 if item.isEphemeral {
1669 Text("Ephemeral")
1670 .font(appDensity.font(.caption2))
1671 .foregroundStyle(Color(.statusWarning))
1672 }
1673 }
1674 Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x")
1675 .font(appDensity.font(.caption2))
1676 .foregroundStyle(Color(.appTextSecondary))
1677 }
1678 }
1679 }
1680 }
1681 intelligenceBlock(title: "Timeline") {
1682 if report.intelligenceTimeline.isEmpty {
1683 MessageRowView(text: "No inferred intelligence events yet", isError: false)
1684 } else {
1685 ForEach(report.intelligenceTimeline.prefix(5)) { event in
1686 intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)")
1687 }
1688 }
1689 }
1690 }
1691 }
1692 }
1693 }
1694
1695 @ViewBuilder
1696 private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
1697 VStack(alignment: .leading, spacing: 8) {
1698 Text(title)
1699 .font(appDensity.font(.subheadline, weight: .semibold))
1700 .foregroundStyle(Color(.statusInfo))
1701 content()
1702 }
1703 }
1704
1705 private func intelligenceEventRow(date: Date, title: String) -> some View {
1706 VStack(alignment: .leading, spacing: 3) {
1707 Text(date.formatted(date: .abbreviated, time: .omitted))
1708 .font(appDensity.font(.caption2))
1709 .foregroundStyle(Color(.appTextSecondary))
1710 Text(title)
1711 .font(appDensity.font(.caption))
1712 }
1713 }
1714}
1715
1716struct SubdomainsSectionView: View {
1717 @Environment(\.appDensity) private var appDensity
1718 @Binding var isCollapsed: Bool
1719 let rows: [SubdomainRowViewData]
1720 let groups: [SubdomainGroup]
1721 let loading: Bool
1722 let error: String?
1723 let provenance: SectionProvenance?
1724 let confidence: ConfidenceLevel?
1725 let showsExtendedPlaceholder: Bool
1726 let extendedCount: Int
1727 let extendedLoading: Bool
1728 let extendedError: String?
1729 let extendedCreditStatus: UsageCreditStatus?
1730 let onLoadExtended: (() -> Void)?
1731
1732 init(
1733 isCollapsed: Binding<Bool>,
1734 rows: [SubdomainRowViewData],
1735 groups: [SubdomainGroup],
1736 loading: Bool,
1737 error: String?,
1738 provenance: SectionProvenance?,
1739 confidence: ConfidenceLevel?,
1740 showsExtendedPlaceholder: Bool,
1741 extendedCount: Int = 0,
1742 extendedLoading: Bool = false,
1743 extendedError: String? = nil,
1744 extendedCreditStatus: UsageCreditStatus? = nil,
1745 onLoadExtended: (() -> Void)? = nil
1746 ) {
1747 _isCollapsed = isCollapsed
1748 self.rows = rows
1749 self.groups = groups
1750 self.loading = loading
1751 self.error = error
1752 self.provenance = provenance
1753 self.confidence = confidence
1754 self.showsExtendedPlaceholder = showsExtendedPlaceholder
1755 self.extendedCount = extendedCount
1756 self.extendedLoading = extendedLoading
1757 self.extendedError = extendedError
1758 self.extendedCreditStatus = extendedCreditStatus
1759 self.onLoadExtended = onLoadExtended
1760 }
1761
1762 var body: some View {
1763 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
1764 CardView(allowsHorizontalScroll: false) {
1765 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
1766 if loading {
1767 ProgressView("Checking certificate transparency…")
1768 .appLoadingStyle()
1769 } else if rows.isEmpty {
1770 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
1771 if showsExtendedPlaceholder {
1772 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1773 .padding(.top, 4)
1774 }
1775 } else {
1776 if let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder {
1777 Button("Load extended results") {
1778 onLoadExtended()
1779 }
1780 .buttonStyle(.bordered)
1781 .font(appDensity.font(.caption2))
1782 }
1783 if !groups.isEmpty {
1784 Text("Groups")
1785 .font(appDensity.font(.caption2))
1786 .foregroundStyle(Color(.appTextSecondary))
1787 ForEach(groups) { group in
1788 HStack {
1789 Text("\(group.label).*")
1790 .font(appDensity.font(.caption))
1791 .foregroundStyle(Color(.statusInfo))
1792 Spacer()
1793 Text("\(group.subdomains.count)")
1794 .font(appDensity.font(.caption2))
1795 .foregroundStyle(Color(.appTextSecondary))
1796 }
1797 }
1798 }
1799 ForEach(rows) { row in
1800 HStack(spacing: 8) {
1801 Text(row.hostname)
1802 .font(appDensity.font(.caption))
1803 .foregroundStyle(.primary)
1804 .textSelection(.enabled)
1805 Spacer()
1806 if row.isInteresting {
1807 Text("Interesting")
1808 .font(.system(.caption2, design: .monospaced))
1809 .foregroundStyle(Color(.statusWarning))
1810 .padding(.horizontal, 8)
1811 .padding(.vertical, 4)
1812 .background(Color(.statusWarningSurface))
1813 .clipShape(Capsule())
1814 }
1815 }
1816 }
1817 if extendedLoading {
1818 ProgressView("Loading extended subdomains…")
1819 .appLoadingStyle()
1820 .padding(.top, 4)
1821 } else if extendedCount > 0 {
1822 MessageRowView(text: "\(extendedCount) extended results included", isError: false)
1823 .padding(.top, 4)
1824 } else if let extendedError {
1825 MessageRowView(text: extendedError, isError: false)
1826 .padding(.top, 4)
1827 } else if showsExtendedPlaceholder {
1828 MessageRowView(text: "Extended subdomain discovery available in Pro+", isError: false)
1829 .padding(.top, 4)
1830 }
1831 }
1832 }
1833 }
1834 }
1835}
1836
1837struct DNSSectionView: View {
1838 @Environment(\.appDensity) private var appDensity
1839 @Binding var isCollapsed: Bool
1840 let dnssecLabel: String?
1841 let patternSummary: DNSPatternSummary?
1842 let sections: [DNSRecordSectionViewData]
1843 let ptrMessage: SectionMessageViewData?
1844 let loading: Bool
1845 let dnsProvenance: SectionProvenance?
1846 let ptrProvenance: SectionProvenance?
1847 let sectionError: String?
1848 let history: [DNSHistoryEvent]
1849 let historyLoading: Bool
1850 let historyError: String?
1851 let showsHistoryPlaceholder: Bool
1852 let historyCreditStatus: UsageCreditStatus?
1853 let onLoadHistory: (() -> Void)?
1854
1855 init(
1856 isCollapsed: Binding<Bool>,
1857 dnssecLabel: String?,
1858 patternSummary: DNSPatternSummary?,
1859 sections: [DNSRecordSectionViewData],
1860 ptrMessage: SectionMessageViewData?,
1861 loading: Bool,
1862 dnsProvenance: SectionProvenance?,
1863 ptrProvenance: SectionProvenance?,
1864 sectionError: String?,
1865 history: [DNSHistoryEvent] = [],
1866 historyLoading: Bool = false,
1867 historyError: String? = nil,
1868 showsHistoryPlaceholder: Bool = false,
1869 historyCreditStatus: UsageCreditStatus? = nil,
1870 onLoadHistory: (() -> Void)? = nil
1871 ) {
1872 _isCollapsed = isCollapsed
1873 self.dnssecLabel = dnssecLabel
1874 self.patternSummary = patternSummary
1875 self.sections = sections
1876 self.ptrMessage = ptrMessage
1877 self.loading = loading
1878 self.dnsProvenance = dnsProvenance
1879 self.ptrProvenance = ptrProvenance
1880 self.sectionError = sectionError
1881 self.history = history
1882 self.historyLoading = historyLoading
1883 self.historyError = historyError
1884 self.showsHistoryPlaceholder = showsHistoryPlaceholder
1885 self.historyCreditStatus = historyCreditStatus
1886 self.onLoadHistory = onLoadHistory
1887 }
1888
1889 var body: some View {
1890 CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) {
1891 if loading {
1892 LoadingCardView(text: "Querying DNS…")
1893 } else if let sectionError, sections.isEmpty {
1894 MessageCardView(text: sectionError, isError: true)
1895 } else {
1896 if dnsProvenance != nil {
1897 CardView(allowsHorizontalScroll: false) {
1898 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1899 if let patternSummary {
1900 if !patternSummary.providers.isEmpty {
1901 MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
1902 }
1903 if !patternSummary.patterns.isEmpty {
1904 ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
1905 MessageRowView(text: pattern, isError: false)
1906 }
1907 }
1908 }
1909 }
1910 }
1911 ForEach(sections) { section in
1912 CardView {
1913 Text(section.title)
1914 .font(.system(.subheadline, design: .monospaced))
1915 .fontWeight(.semibold)
1916 .foregroundStyle(Color(.statusInfo))
1917
1918 if let message = section.message {
1919 MessageRowView(text: message.text, isError: message.isError)
1920 }
1921
1922 ForEach(section.rows) { row in
1923 LabeledValueRow(row: row)
1924 }
1925
1926 if let wildcardTitle = section.wildcardTitle {
1927 Text(wildcardTitle)
1928 .font(.system(.caption, design: .monospaced))
1929 .foregroundStyle(Color(.appTextSecondary))
1930 .padding(.top, 4)
1931 ForEach(section.wildcardRows) { row in
1932 LabeledValueRow(row: row)
1933 }
1934 }
1935 }
1936 }
1937
1938 if let ptrMessage {
1939 CardView {
1940 Text("PTR")
1941 .font(.system(.subheadline, design: .monospaced))
1942 .fontWeight(.semibold)
1943 .foregroundStyle(Color(.statusInfo))
1944 SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
1945 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
1946 }
1947 }
1948
1949 CardView(allowsHorizontalScroll: false) {
1950 HStack {
1951 Text("History")
1952 .font(appDensity.font(.subheadline, weight: .semibold))
1953 .foregroundStyle(Color(.statusInfo))
1954 Spacer()
1955 if let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder {
1956 Button("Load") {
1957 onLoadHistory()
1958 }
1959 .buttonStyle(.bordered)
1960 .font(appDensity.font(.caption2))
1961 }
1962 }
1963 if historyLoading {
1964 ProgressView("Loading DNS history…")
1965 .appLoadingStyle()
1966 } else if !history.isEmpty {
1967 ForEach(history) { event in
1968 VStack(alignment: .leading, spacing: 3) {
1969 Text(event.date.formatted(date: .abbreviated, time: .omitted))
1970 .font(appDensity.font(.caption2))
1971 .foregroundStyle(Color(.appTextSecondary))
1972 Text(event.summary)
1973 .font(appDensity.font(.caption))
1974 if !event.aRecords.isEmpty {
1975 Text("A: \(event.aRecords.joined(separator: ", "))")
1976 .font(appDensity.font(.caption2))
1977 .foregroundStyle(Color(.appTextSecondary))
1978 }
1979 if !event.nameservers.isEmpty {
1980 Text("NS: \(event.nameservers.joined(separator: ", "))")
1981 .font(appDensity.font(.caption2))
1982 .foregroundStyle(Color(.appTextSecondary))
1983 }
1984 }
1985 }
1986 } else if let historyError {
1987 MessageRowView(text: historyError, isError: false)
1988 } else if showsHistoryPlaceholder {
1989 MessageRowView(text: "DNS history available in Pro+", isError: false)
1990 }
1991 }
1992 }
1993 }
1994 }
1995}
1996
1997struct WebSectionView: View {
1998 @Environment(\.appDensity) private var appDensity
1999 @Binding var isCollapsed: Bool
2000 let certificateRows: [InfoRowViewData]
2001 let sslInfo: SSLCertificateInfo?
2002 let tlsSummary: WebResultSummary?
2003 let sslLoading: Bool
2004 let sslError: String?
2005 let tlsProvenance: SectionProvenance?
2006 let responseRows: [InfoRowViewData]
2007 let headers: [HTTPHeader]
2008 let headersLoading: Bool
2009 let headersError: String?
2010 let httpProvenance: SectionProvenance?
2011 let redirects: [RedirectHopViewData]
2012 let redirectLoading: Bool
2013 let redirectError: String?
2014 let redirectProvenance: SectionProvenance?
2015 let finalURL: String?
2016
2017 var body: some View {
2018 CollapsibleSectionView(title: "Web", isCollapsed: $isCollapsed) {
2019 CardView {
2020 HStack {
2021 Text("TLS")
2022 .font(appDensity.font(.subheadline, weight: .semibold))
2023 .foregroundStyle(Color(.statusInfo))
2024 Spacer()
2025 if !sslLoading {
2026 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
2027 }
2028 }
2029 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
2030 if !sslLoading, let tlsSummary {
2031 LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade.tone))
2032 ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
2033 MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
2034 }
2035 }
2036 if sslLoading {
2037 ProgressView("Checking certificate…")
2038 .appLoadingStyle()
2039 } else if let sslError {
2040 MessageRowView(text: sslError, isError: true)
2041 } else {
2042 ForEach(certificateRows) { row in
2043 LabeledValueRow(row: row)
2044 }
2045 if let sslInfo, !sslInfo.subjectAltNames.isEmpty {
2046 Text("SANs")
2047 .font(appDensity.font(.caption2))
2048 .foregroundStyle(Color(.appTextSecondary))
2049 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
2050 HStack(alignment: .top, spacing: 8) {
2051 Text(san)
2052 .font(appDensity.font(.caption))
2053 .lineLimit(nil)
2054 .fixedSize(horizontal: false, vertical: true)
2055 .textSelection(.enabled)
2056 Spacer()
2057 AppCopyButton(value: san, label: "Copy certificate SAN")
2058 }
2059 }
2060 }
2061 }
2062 }
2063
2064 CardView {
2065 Text("Headers")
2066 .font(appDensity.font(.subheadline, weight: .semibold))
2067 .foregroundStyle(Color(.statusInfo))
2068 SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
2069 if headersLoading {
2070 ProgressView("Fetching headers…")
2071 .appLoadingStyle()
2072 } else if let headersError {
2073 MessageRowView(text: headersError, isError: true)
2074 } else {
2075 ForEach(responseRows) { row in
2076 LabeledValueRow(row: row)
2077 }
2078 if headers.isEmpty {
2079 MessageRowView(text: "No HTTP headers returned", isError: false)
2080 } else {
2081 ForEach(headers) { header in
2082 HStack(alignment: .top, spacing: 4) {
2083 Text(header.name + ":")
2084 .font(appDensity.font(.caption))
2085 .foregroundStyle(header.isSecurityHeader ? Color(.statusWarning) : Color(.statusInfo))
2086 Text(header.value)
2087 .font(appDensity.font(.caption))
2088 .foregroundStyle(.primary)
2089 .textSelection(.enabled)
2090 }
2091 }
2092 }
2093 }
2094 }
2095
2096 CardView {
2097 HStack {
2098 Text("Redirects")
2099 .font(appDensity.font(.subheadline, weight: .semibold))
2100 .foregroundStyle(Color(.statusInfo))
2101 Spacer()
2102 if let finalURL {
2103 AppCopyButton(value: finalURL, label: "Copy redirect URL")
2104 }
2105 }
2106 SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
2107 if redirectLoading {
2108 ProgressView("Tracing redirects…")
2109 .appLoadingStyle()
2110 } else if let redirectError {
2111 MessageRowView(text: redirectError, isError: true)
2112 } else if redirects.isEmpty {
2113 MessageRowView(text: "No redirect data available", isError: false)
2114 } else {
2115 if let finalURL {
2116 LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary))
2117 }
2118 ForEach(redirects) { redirect in
2119 HStack(alignment: .top, spacing: 6) {
2120 Text(redirect.stepLabel)
2121 .font(appDensity.font(.caption))
2122 .foregroundStyle(Color(.appTextSecondary))
2123 .frame(width: 16, alignment: .trailing)
2124 Text(redirect.statusCode)
2125 .font(appDensity.font(.caption))
2126 .foregroundStyle(Color(.statusInfo))
2127 .frame(width: 36, alignment: .leading)
2128 Text(redirect.url)
2129 .font(appDensity.font(.caption))
2130 .textSelection(.enabled)
2131 AppCopyButton(value: redirect.url, label: "Copy redirect URL")
2132 if redirect.isFinal {
2133 Text("(final)")
2134 .font(appDensity.font(.caption2))
2135 .foregroundStyle(Color(.appTextSecondary))
2136 }
2137 }
2138 }
2139 }
2140 }
2141 }
2142 }
2143
2144 private func isTLSHighlightError(_ highlight: String) -> Bool {
2145 let normalized = highlight.lowercased()
2146 if normalized.contains("no weak tls indicators were detected") {
2147 return false
2148 }
2149 return normalized.contains("expires")
2150 || normalized.contains("weak")
2151 || normalized.contains("tls 1.0")
2152 || normalized.contains("tls 1.1")
2153 }
2154}
2155
2156struct EmailSectionView: View {
2157 @Environment(\.appDensity) private var appDensity
2158 @Binding var isCollapsed: Bool
2159 let rows: [EmailRowViewData]
2160 let assessment: EmailSecuritySummary?
2161 let loading: Bool
2162 let provenance: SectionProvenance?
2163 let confidence: ConfidenceLevel?
2164 let error: String?
2165
2166 var body: some View {
2167 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
2168 CardView {
2169 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
2170 HStack {
2171 Spacer()
2172 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
2173 .opacity(loading ? 0 : 1)
2174 }
2175 if let assessment, let grade = assessment.grade {
2176 LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade.tone))
2177 if !assessment.reasons.isEmpty {
2178 Text(assessment.reasons.joined(separator: " | "))
2179 .font(appDensity.font(.caption2))
2180 .foregroundStyle(Color(.appTextSecondary))
2181 }
2182 }
2183 if loading {
2184 ProgressView("Checking email records…")
2185 .appLoadingStyle()
2186 } else if let error {
2187 MessageRowView(text: error, isError: true)
2188 } else if rows.isEmpty {
2189 MessageRowView(text: "No email security records found", isError: false)
2190 } else {
2191 ForEach(rows) { row in
2192 VStack(alignment: .leading, spacing: 4) {
2193 HStack(spacing: 8) {
2194 Text(row.label)
2195 .font(appDensity.font(.caption))
2196 .foregroundStyle(Color(.statusInfo))
2197 .frame(width: 76, alignment: .leading)
2198 AppStatusBadgeView(model: emailRowBadge(row))
2199 }
2200 Text(row.detail)
2201 .font(appDensity.font(.caption2))
2202 .foregroundStyle(.primary)
2203 .textSelection(.enabled)
2204 if let auxiliaryDetail = row.auxiliaryDetail {
2205 Text(auxiliaryDetail)
2206 .font(appDensity.font(.caption2))
2207 .foregroundStyle(Color(.appTextSecondary))
2208 }
2209 }
2210 }
2211 }
2212 }
2213 }
2214 }
2215
2216 private func emailRowBadge(_ row: EmailRowViewData) -> AppStatusBadgeModel {
2217 switch row.statusTone {
2218 case .success:
2219 return .init(title: row.status, systemImage: "checkmark.shield.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2220 case .warning:
2221 return .init(title: row.status, systemImage: "shield.lefthalf.filled", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2222 case .failure:
2223 return .init(title: row.status, systemImage: "minus.circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2224 case .primary, .secondary:
2225 return .init(title: row.status, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2226 }
2227 }
2228}
2229
2230struct NetworkSectionView: View {
2231 @Environment(\.appDensity) private var appDensity
2232 @Binding var isCollapsed: Bool
2233 let reachabilityRows: [ReachabilityRowViewData]
2234 let reachabilityLoading: Bool
2235 let reachabilityError: String?
2236 let reachabilityProvenance: SectionProvenance?
2237 let locationRows: [InfoRowViewData]
2238 let geolocation: IPGeolocation?
2239 let geolocationLoading: Bool
2240 let geolocationError: String?
2241 let geolocationProvenance: SectionProvenance?
2242 let geolocationConfidence: ConfidenceLevel?
2243 let standardPortRows: [PortScanRowViewData]
2244 let customPortRows: [PortScanRowViewData]
2245 let portScanLoading: Bool
2246 let portScanError: String?
2247 let portScanProvenance: SectionProvenance?
2248 let customPortScanLoading: Bool
2249 let customPortScanError: String?
2250 let isCloudflareProxied: Bool
2251 @Binding var customPortsExpanded: Bool
2252 @Binding var customPortInput: String
2253 let onScanCustomPorts: () -> Void
2254
2255 var body: some View {
2256 CollapsibleSectionView(title: "Network", isCollapsed: $isCollapsed) {
2257 CardView {
2258 Text("Reachability")
2259 .font(appDensity.font(.subheadline, weight: .semibold))
2260 .foregroundStyle(Color(.statusInfo))
2261 SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
2262 if reachabilityLoading {
2263 ProgressView("Checking ports…")
2264 .appLoadingStyle()
2265 } else if let reachabilityError {
2266 MessageRowView(text: reachabilityError, isError: true)
2267 } else {
2268 ForEach(reachabilityRows) { row in
2269 HStack {
2270 Text(row.portLabel)
2271 .font(appDensity.font(.caption))
2272 Spacer()
2273 Text(row.latencyLabel)
2274 .font(appDensity.font(.caption2))
2275 .foregroundStyle(Color(.appTextSecondary))
2276 AppStatusBadgeView(model: reachabilityBadge(row))
2277 }
2278 }
2279 }
2280 }
2281
2282 CardView(allowsHorizontalScroll: false) {
2283 Text("Location")
2284 .font(appDensity.font(.subheadline, weight: .semibold))
2285 .foregroundStyle(Color(.statusInfo))
2286 SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
2287 if geolocationLoading {
2288 ProgressView("Looking up location…")
2289 .appLoadingStyle()
2290 } else if let geolocationError, geolocation == nil {
2291 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
2292 } else if let geolocation {
2293 ForEach(locationRows) { row in
2294 LabeledValueRow(row: row)
2295 }
2296 if let latitude = geolocation.latitude, let longitude = geolocation.longitude {
2297 let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
2298 Map(initialPosition: .region(MKCoordinateRegion(
2299 center: coordinate,
2300 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
2301 ))) {
2302 Marker(geolocation.ip, coordinate: coordinate)
2303 }
2304 .mapStyle(.standard)
2305 .frame(maxWidth: .infinity)
2306 .frame(height: 180)
2307 .cornerRadius(8)
2308 }
2309 } else {
2310 MessageRowView(text: "No location data available", isError: false)
2311 }
2312 }
2313
2314 CardView(allowsHorizontalScroll: false) {
2315 Text("Port Scan")
2316 .font(appDensity.font(.subheadline, weight: .semibold))
2317 .foregroundStyle(Color(.statusInfo))
2318 SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
2319
2320 if isCloudflareProxied {
2321 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
2322 .font(appDensity.font(.caption2))
2323 .foregroundStyle(Color(.statusWarning))
2324 .fixedSize(horizontal: false, vertical: true)
2325 }
2326
2327 if portScanLoading {
2328 ProgressView("Scanning ports…")
2329 .appLoadingStyle()
2330 } else if let portScanError, standardPortRows.isEmpty {
2331 MessageRowView(text: portScanError, isError: true)
2332 } else {
2333 Text("Standard Ports")
2334 .font(.system(.caption, design: .monospaced))
2335 .foregroundStyle(Color(.appTextSecondary))
2336 PortRowsView(rows: standardPortRows)
2337 }
2338
2339 DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) {
2340 VStack(alignment: .leading, spacing: 10) {
2341 TextField("8888, 9000, 27017", text: $customPortInput)
2342 .font(appDensity.font(.caption))
2343 .textInputAutocapitalization(.never)
2344 .autocorrectionDisabled()
2345 .keyboardType(.numberPad)
2346 .padding(10)
2347 .background(Color(.appSurface))
2348 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2349
2350 Button("Scan") {
2351 AppHaptics.refresh()
2352 onScanCustomPorts()
2353 }
2354 .buttonStyle(.borderedProminent)
2355 .tint(Color(.accentFill))
2356 .disabled(customPortScanLoading)
2357
2358 if customPortScanLoading {
2359 ProgressView("Scanning custom ports…")
2360 .appLoadingStyle()
2361 } else if let customPortScanError {
2362 MessageRowView(text: customPortScanError, isError: true)
2363 } else {
2364 PortRowsView(rows: customPortRows)
2365 }
2366 }
2367 .padding(.top, 8)
2368 }
2369 .font(.system(.caption, design: .monospaced))
2370 .tint(.secondary)
2371 }
2372 }
2373 }
2374
2375 private func reachabilityBadge(_ row: ReachabilityRowViewData) -> AppStatusBadgeModel {
2376 switch row.statusTone {
2377 case .success:
2378 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2379 case .warning:
2380 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2381 case .failure:
2382 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2383 case .primary, .secondary:
2384 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2385 }
2386 }
2387}
2388
2389struct PortRowsView: View {
2390 @Environment(\.appDensity) private var appDensity
2391 let rows: [PortScanRowViewData]
2392
2393 var body: some View {
2394 if rows.isEmpty {
2395 MessageRowView(text: "No results", isError: false)
2396 } else {
2397 ForEach(rows) { row in
2398 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2399 HStack {
2400 Text(row.portLabel)
2401 .font(appDensity.font(.caption))
2402 .frame(width: 52, alignment: .leading)
2403 Text(row.service)
2404 .font(appDensity.font(.caption))
2405 .foregroundStyle(.primary)
2406 Spacer()
2407 if let durationLabel = row.durationLabel {
2408 Text(durationLabel)
2409 .font(appDensity.font(.caption2))
2410 .foregroundStyle(Color(.appTextSecondary))
2411 }
2412 AppStatusBadgeView(model: portBadge(row))
2413 }
2414 if let banner = row.banner {
2415 Text(banner)
2416 .font(appDensity.font(.caption2))
2417 .foregroundStyle(Color(.appTextSecondary))
2418 .padding(.leading, 8)
2419 }
2420 }
2421 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2422 }
2423 }
2424 }
2425
2426 private func portBadge(_ row: PortScanRowViewData) -> AppStatusBadgeModel {
2427 switch row.statusTone {
2428 case .success:
2429 return .init(title: row.statusLabel, systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
2430 case .warning:
2431 return .init(title: row.statusLabel, systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
2432 case .failure:
2433 return .init(title: row.statusLabel, systemImage: "xmark.circle.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
2434 case .primary, .secondary:
2435 return .init(title: row.statusLabel, systemImage: "circle", foregroundColor: Color(.appTextSecondary), backgroundColor: Color(.appSurfaceElevated))
2436 }
2437 }
2438}
2439
2440struct SectionTitleView: View {
2441 @Environment(\.appDensity) private var appDensity
2442 let title: String
2443
2444 var body: some View {
2445 Text(title)
2446 .font(appDensity.font(.headline, design: .default, weight: .semibold))
2447 .foregroundStyle(.primary)
2448 .accessibilityAddTraits(.isHeader)
2449 }
2450}
2451
2452/// A card that wraps its content by default.
2453///
2454/// `allowsHorizontalScroll` used to default to `true`, so nine call sites put
2455/// their content behind a horizontal gesture instead of letting it wrap — a
2456/// WCAG 1.4.10 (Reflow) failure, and the mechanism behind clipped rows at large
2457/// text sizes. It also forced VoiceOver and Switch Control users onto a nested
2458/// scroll axis to reach data.
2459///
2460/// The default is now `false`. Where horizontal scrolling genuinely suits wide
2461/// tabular content, it is still opt-in — but it is suppressed at accessibility
2462/// text sizes, where wrapping always beats a hidden axis.
2463struct CardView<Content: View>: View {
2464 @Environment(\.appDensity) private var appDensity
2465 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
2466 let allowsHorizontalScroll: Bool
2467 let content: Content
2468
2469 init(allowsHorizontalScroll: Bool = false, @ViewBuilder content: () -> Content) {
2470 self.allowsHorizontalScroll = allowsHorizontalScroll
2471 self.content = content()
2472 }
2473
2474 var body: some View {
2475 Group {
2476 if allowsHorizontalScroll, !dynamicTypeSize.isAccessibilitySize {
2477 ScrollView(.horizontal) {
2478 cardContent
2479 .scrollTargetLayout()
2480 }
2481 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
2482 } else {
2483 cardContent
2484 .frame(maxWidth: .infinity, alignment: .leading)
2485 }
2486 }
2487 .frame(maxWidth: .infinity, alignment: .leading)
2488 .padding(appDensity.metrics.cardPadding)
2489 .background(Color(.appSurface))
2490 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
2491 }
2492
2493 private var cardContent: some View {
2494 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
2495 content
2496 }
2497 }
2498}
2499
2500struct LoadingCardView: View {
2501 let text: String
2502
2503 var body: some View {
2504 CardView {
2505 ProgressView(text)
2506 .appLoadingStyle()
2507 .frame(maxWidth: .infinity, alignment: .center)
2508 }
2509 }
2510}
2511
2512struct MessageCardView: View {
2513 let text: String
2514 let isError: Bool
2515
2516 var body: some View {
2517 CardView {
2518 MessageRowView(text: text, isError: isError)
2519 }
2520 }
2521}
2522
2523struct MessageRowView: View {
2524 @Environment(\.appDensity) private var appDensity
2525 let text: String
2526 let isError: Bool
2527
2528 var body: some View {
2529 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
2530 .font(appDensity.font(.caption))
2531 .foregroundStyle(isError ? Color(.statusCritical) : .secondary)
2532 .lineLimit(nil)
2533 .fixedSize(horizontal: false, vertical: true)
2534 }
2535}
2536
2537struct SectionTrustMetadataView: View {
2538 @Environment(\.appDensity) private var appDensity
2539 let provenance: SectionProvenance?
2540 let confidence: ConfidenceLevel?
2541 let note: String?
2542
2543 init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) {
2544 self.provenance = provenance
2545 self.confidence = confidence
2546 self.note = note
2547 }
2548
2549 var body: some View {
2550 if provenance != nil || confidence != nil || note != nil {
2551 VStack(alignment: .leading, spacing: 6) {
2552 HStack(spacing: 8) {
2553 if let confidence {
2554 Text("Confidence \(confidence.title)")
2555 .font(appDensity.font(.caption2))
2556 .foregroundStyle(Color(.appTextSecondary))
2557 }
2558 if let provenance {
2559 Text(provenance.provider ?? provenance.source)
2560 .font(appDensity.font(.caption2))
2561 .foregroundStyle(Color(.appTextSecondary))
2562 Text(provenance.resultSource.label)
2563 .font(appDensity.font(.caption2))
2564 .foregroundStyle(Color(.appTextSecondary))
2565 }
2566 }
2567 DisclosureGroup("Details") {
2568 VStack(alignment: .leading, spacing: 4) {
2569 if let provenance {
2570 LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary))
2571 if let provider = provenance.provider {
2572 LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary))
2573 }
2574 if let resolver = provenance.resolver {
2575 LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary))
2576 }
2577 LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary))
2578 LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary))
2579 }
2580 if let note {
2581 LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary))
2582 }
2583 }
2584 .padding(.top, 4)
2585 }
2586 .font(appDensity.font(.caption))
2587 .tint(.secondary)
2588 }
2589 }
2590 }
2591}
2592
2593struct LabeledValueRow: View {
2594 @Environment(\.appDensity) private var appDensity
2595 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
2596 let row: InfoRowViewData
2597
2598 var body: some View {
2599 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2600 HStack(alignment: .top, spacing: 8) {
2601 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing - 1) {
2602 Text(row.label)
2603 .font(appDensity.font(.caption2))
2604 .foregroundStyle(Color(.appTextSecondary))
2605 HStack(alignment: .firstTextBaseline, spacing: 4) {
2606 if differentiateWithoutColor, let symbol = toneSymbol {
2607 Image(systemName: symbol)
2608 .font(appDensity.font(.caption2))
2609 .foregroundStyle(ResultColors.color(for: row.tone))
2610 .accessibilityHidden(true)
2611 }
2612 valueText
2613 }
2614 }
2615 .frame(maxWidth: .infinity, alignment: .leading)
2616 .layoutPriority(1)
2617 Spacer(minLength: 6)
2618 if !row.value.isEmpty, row.value != "Unavailable" {
2619 AppCopyButton(value: row.value, label: "Copy \(row.label)")
2620 }
2621 }
2622 }
2623 .frame(minHeight: appDensity.metrics.rowMinHeight, alignment: .topLeading)
2624 }
2625
2626 /// A leading symbol for warning/failure tones, shown only under Differentiate
2627 /// Without Color so tone is not conveyed by text colour alone. Hidden from
2628 /// VoiceOver — the value text already carries the meaning.
2629 private var toneSymbol: String? {
2630 switch row.tone {
2631 case .warning: return "exclamationmark.triangle.fill"
2632 case .failure: return "xmark.octagon.fill"
2633 default: return nil
2634 }
2635 }
2636
2637 @ViewBuilder
2638 private var valueText: some View {
2639 let base = Text(row.value)
2640 .font(appDensity.font(.caption))
2641 .foregroundStyle(ResultColors.color(for: row.tone))
2642
2643 switch row.speechStyle {
2644 case .plain:
2645 base
2646 .lineLimit(nil)
2647 .fixedSize(horizontal: false, vertical: true)
2648 .textSelection(.enabled)
2649 case .technical:
2650 // Record values and identifiers: keep punctuation audible (SPF/DMARC
2651 // separators are semantically load-bearing) and let VoiceOver use its
2652 // code-reading heuristics.
2653 base
2654 .speechAlwaysIncludesPunctuation()
2655 .accessibilityTextContentType(.sourceCode)
2656 .lineLimit(nil)
2657 .fixedSize(horizontal: false, vertical: true)
2658 .textSelection(.enabled)
2659 }
2660 }
2661}
2662
2663/// Maps a row's semantic tone onto the app palette.
2664///
2665/// See `Docs/ACCESSIBILITY.md` for the measured contrast ratios behind these
2666/// colours. Never reach for a literal (`Color(.statusCritical)`, `Color(.statusWarning)`, …) — the system
2667/// palette fails WCAG AA badly in light mode (systemYellow is 1.28:1 on white).
2668enum ResultColors {
2669 static func color(for tone: ResultTone) -> Color {
2670 switch tone {
2671 case .primary:
2672 return .primary
2673 case .secondary:
2674 return .secondary
2675 case .success:
2676 return Color(.statusPositive)
2677 case .warning:
2678 return Color(.statusWarning)
2679 case .failure:
2680 return Color(.statusCritical)
2681 }
2682 }
2683}
2684
2685extension DateFormatter {
2686 static let certDate: DateFormatter = {
2687 let formatter = DateFormatter()
2688 formatter.dateStyle = .medium
2689 formatter.timeStyle = .short
2690 return formatter
2691 }()
2692}
2693
2694private extension View {
2695 func appLoadingStyle() -> some View {
2696 font(.system(.caption, design: .monospaced))
2697 }
2698}
2699
2700private extension String {
2701 var nilIfEmpty: String? {
2702 isEmpty ? nil : self
2703 }
2704}
2705
2706struct SettingsView: View {
2707 @Environment(\.appDensity) private var appDensity
2708 @Bindable var viewModel: DomainViewModel
2709 @State private var purchaseService = PurchaseService.shared
2710
2711 var body: some View {
2712 let _ = purchaseService.currentTier
2713
2714 List {
2715 Section("Tier") {
2716 LabeledContent("Status", value: purchaseService.currentTier.title)
2717
2718 if purchaseService.currentTier == .free {
2719 Button("Upgrade") {
2720 viewModel.isPaywallPresented = true
2721 }
2722 } else {
2723 Button("Manage Subscription") {
2724 Task {
2725 await purchaseService.manageSubscription()
2726 }
2727 }
2728 }
2729
2730 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2731 Task {
2732 await purchaseService.restorePurchases()
2733 }
2734 }
2735 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2736
2737 if let statusMessage = purchaseService.statusMessage {
2738 Text(statusMessage)
2739 .font(appDensity.font(.caption, design: .default))
2740 .foregroundStyle(Color(.appTextSecondary))
2741 }
2742
2743 if let errorMessage = purchaseService.errorMessage {
2744 Text(errorMessage)
2745 .font(appDensity.font(.caption, design: .default))
2746 .foregroundStyle(Color(.statusCritical))
2747 }
2748 }
2749
2750 Section("Preferences") {
2751 NavigationLink("Tracked Domains") {
2752 WatchlistView(viewModel: viewModel)
2753 }
2754
2755 NavigationLink("Workflows") {
2756 WorkflowsView(viewModel: viewModel)
2757 }
2758
2759 NavigationLink("Display") {
2760 DisplaySettingsView()
2761 }
2762
2763 NavigationLink("History & Network") {
2764 HistoryNetworkSettingsView(viewModel: viewModel)
2765 }
2766 }
2767
2768 Section("Services") {
2769 NavigationLink("Monitoring Activity") {
2770 MonitoringView(viewModel: viewModel)
2771 }
2772
2773 NavigationLink("Integrations") {
2774 IntegrationsSettingsView()
2775 }
2776
2777 NavigationLink("Local API") {
2778 LocalAPISettingsView()
2779 }
2780
2781 NavigationLink("iCloud Sync") {
2782 CloudSyncSettingsView()
2783 }
2784
2785 NavigationLink("Monitoring") {
2786 MonitoringSettingsView(viewModel: viewModel)
2787 }
2788
2789 NavigationLink("Scheduled Reports") {
2790 ScheduledReportsView()
2791 }
2792 }
2793
2794 Section("Data") {
2795 NavigationLink("Import & Export") {
2796 DataPortabilitySettingsView(viewModel: viewModel)
2797 }
2798
2799 NavigationLink("Data Management") {
2800 DataManagementSettingsView(viewModel: viewModel)
2801 }
2802 }
2803
2804 Section("About") {
2805 NavigationLink("App Info") {
2806 AboutSettingsView()
2807 }
2808 }
2809 }
2810 .navigationTitle("Settings")
2811 }
2812}
2813
2814private struct DisplaySettingsView: View {
2815 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2816 @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue
2817
2818 var body: some View {
2819 Form {
2820 Section("Display") {
2821 Picker("Appearance", selection: $storedAppearance) {
2822 ForEach(AppAppearance.allCases) { appearance in
2823 Text(appearance.title).tag(appearance.rawValue)
2824 }
2825 }
2826
2827 Picker("Density", selection: $storedDensity) {
2828 ForEach(AppDensity.allCases) { density in
2829 Text(density.title).tag(density.rawValue)
2830 }
2831 }
2832 }
2833 }
2834 .navigationTitle("Display")
2835 }
2836}
2837
2838private struct HistoryNetworkSettingsView: View {
2839 @Environment(\.appDensity) private var appDensity
2840 @Bindable var viewModel: DomainViewModel
2841 @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString
2842 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
2843
2844 @State private var resolverOption: DNSResolverOption = .cloudflare
2845 @State private var customResolverURL = DNSResolverOption.defaultURLString
2846
2847 private var customResolverError: String? {
2848 guard resolverOption == .custom else { return nil }
2849 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
2850 }
2851
2852 var body: some View {
2853 Form {
2854 Section("History") {
2855 Picker(
2856 "Auto-prune",
2857 selection: Binding(
2858 get: { viewModel.historyAutoPruneOption },
2859 set: { viewModel.setHistoryAutoPruneOption($0) }
2860 )
2861 ) {
2862 ForEach(HistoryAutoPruneOption.allCases) { option in
2863 Text(option.title).tag(option)
2864 }
2865 }
2866
2867 Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
2868 .font(appDensity.font(.caption, design: .default))
2869 .foregroundStyle(Color(.appTextSecondary))
2870 }
2871
2872 Section("Network") {
2873 Picker("Resolver", selection: $resolverOption) {
2874 ForEach(DNSResolverOption.allCases) { option in
2875 Text(option.title).tag(option)
2876 }
2877 }
2878
2879 if resolverOption == .custom {
2880 TextField("https://resolver.example/dns-query", text: $customResolverURL)
2881 .textInputAutocapitalization(.never)
2882 .autocorrectionDisabled()
2883 .keyboardType(.URL)
2884
2885 if let customResolverError {
2886 Text(customResolverError)
2887 .font(appDensity.font(.caption, design: .default))
2888 .foregroundStyle(Color(.statusCritical))
2889 }
2890 }
2891 }
2892 }
2893 .navigationTitle("History & Network")
2894 .onAppear {
2895 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2896 resolverOption = DNSResolverOption.option(for: currentResolverURL)
2897 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
2898 }
2899 .onChange(of: resolverOption) { _, newValue in
2900 guard let presetURL = newValue.urlString else {
2901 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2902 viewModel.persistCurrentAppSettings(
2903 resolverURLString: storedResolverURL,
2904 appDensityRawValue: storedDensity
2905 )
2906 return
2907 }
2908 storedResolverURL = presetURL
2909 viewModel.persistCurrentAppSettings(
2910 resolverURLString: storedResolverURL,
2911 appDensityRawValue: storedDensity
2912 )
2913 }
2914 .onChange(of: customResolverURL) { _, newValue in
2915 guard resolverOption == .custom else { return }
2916 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
2917 viewModel.persistCurrentAppSettings(
2918 resolverURLString: storedResolverURL,
2919 appDensityRawValue: storedDensity
2920 )
2921 }
2922 .onChange(of: storedDensity) { _, newValue in
2923 viewModel.persistCurrentAppSettings(
2924 resolverURLString: storedResolverURL,
2925 appDensityRawValue: newValue
2926 )
2927 }
2928 }
2929}
2930
2931private struct CloudSyncSettingsView: View {
2932 @Environment(\.appDensity) private var appDensity
2933 @State private var cloudSyncService = CloudSyncService.shared
2934
2935 var body: some View {
2936 Form {
2937 Section("iCloud Sync") {
2938 Toggle(
2939 "Enable iCloud Sync",
2940 isOn: Binding(
2941 get: { cloudSyncService.isEnabled },
2942 set: { cloudSyncService.setSyncEnabled($0) }
2943 )
2944 )
2945
2946 LabeledContent("Status", value: cloudSyncService.status.title)
2947 LabeledContent(
2948 "Last Sync",
2949 value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
2950 )
2951
2952 Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
2953 Task {
2954 await cloudSyncService.syncNow(trigger: .manual)
2955 }
2956 }
2957 .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
2958
2959 Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.")
2960 .font(appDensity.font(.caption, design: .default))
2961 .foregroundStyle(Color(.appTextSecondary))
2962
2963 Text(cloudSyncService.detailMessage)
2964 .font(appDensity.font(.caption, design: .default))
2965 .foregroundStyle(Color(.appTextSecondary))
2966
2967 if let lastErrorMessage = cloudSyncService.lastErrorMessage {
2968 Text(lastErrorMessage)
2969 .font(appDensity.font(.caption, design: .default))
2970 .foregroundStyle(Color(.statusCritical))
2971 }
2972 }
2973 }
2974 .navigationTitle("iCloud Sync")
2975 .task {
2976 await cloudSyncService.refreshAvailability()
2977 }
2978 }
2979}
2980
2981private struct LocalAPISettingsView: View {
2982 @Environment(\.appDensity) private var appDensity
2983 @State private var localAPIService = LocalAPIService.shared
2984 @State private var portText = ""
2985
2986 private var statusText: String {
2987 if localAPIService.isRunning { return "Running" }
2988 return localAPIService.config.isEnabled ? "Stopped" : "Disabled"
2989 }
2990
2991 var body: some View {
2992 Form {
2993 Section("Local API") {
2994 Toggle(
2995 "Enable Local API",
2996 isOn: Binding(
2997 get: { localAPIService.config.isEnabled },
2998 set: { localAPIService.setEnabled($0) }
2999 )
3000 )
3001
3002 TextField(
3003 "Port",
3004 text: Binding(
3005 get: { portText },
3006 set: { newValue in
3007 portText = newValue
3008 if let port = Int(newValue) {
3009 localAPIService.setPort(port)
3010 }
3011 }
3012 )
3013 )
3014 .keyboardType(.numberPad)
3015
3016 LabeledContent("Address", value: localAPIService.address)
3017 LabeledContent("Status", value: statusText)
3018 LabeledContent("Token", value: localAPIService.maskedToken)
3019
3020 if let statusMessage = localAPIService.statusMessage {
3021 Text(statusMessage)
3022 .font(appDensity.font(.caption, design: .default))
3023 .foregroundStyle(Color(.appTextSecondary))
3024 }
3025 }
3026
3027 Section("Authentication") {
3028 Button("Copy Token") {
3029 localAPIService.copyToken()
3030 }
3031
3032 Button("Rotate Token") {
3033 localAPIService.rotateToken()
3034 }
3035
3036 Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.")
3037 .font(appDensity.font(.caption, design: .default))
3038 .foregroundStyle(Color(.appTextSecondary))
3039 }
3040
3041 Section("Request Logging") {
3042 Toggle(
3043 "Log Requests",
3044 isOn: Binding(
3045 get: { localAPIService.config.requestLoggingEnabled },
3046 set: { localAPIService.setRequestLoggingEnabled($0) }
3047 )
3048 )
3049
3050 if localAPIService.requestLogs.isEmpty {
3051 Text("No local API requests logged yet.")
3052 .font(appDensity.font(.caption, design: .default))
3053 .foregroundStyle(Color(.appTextSecondary))
3054 } else {
3055 ForEach(localAPIService.requestLogs.prefix(25)) { log in
3056 VStack(alignment: .leading, spacing: 4) {
3057 HStack {
3058 Text("\(log.method) \(log.path)")
3059 .font(appDensity.font(.callout, design: .monospaced))
3060 Spacer()
3061 Text("\(log.statusCode)")
3062 .font(appDensity.font(.caption, design: .default))
3063 .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary)
3064 }
3065
3066 Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
3067 .font(appDensity.font(.caption2, design: .default))
3068 .foregroundStyle(Color(.appTextSecondary))
3069
3070 Text("\(Int(log.duration * 1000)) ms")
3071 .font(appDensity.font(.caption2, design: .default))
3072 .foregroundStyle(Color(.appTextSecondary))
3073 }
3074 }
3075 }
3076
3077 Button("Clear Logs", role: .destructive) {
3078 localAPIService.clearRequestLogs()
3079 }
3080 }
3081
3082 Section("Control") {
3083 Button("Restart Server") {
3084 localAPIService.setEnabled(false)
3085 localAPIService.setEnabled(true)
3086 }
3087 .disabled(!localAPIService.config.isEnabled)
3088
3089 Button("Stop Server") {
3090 localAPIService.stopServer()
3091 }
3092 .disabled(!localAPIService.isRunning)
3093 }
3094 }
3095 .navigationTitle("Local API")
3096 .onAppear {
3097 portText = String(localAPIService.config.port)
3098 localAPIService.refresh()
3099 }
3100 }
3101}
3102
3103private struct MonitoringSettingsView: View {
3104 @Environment(\.appDensity) private var appDensity
3105 @Bindable var viewModel: DomainViewModel
3106
3107 private var notificationAuthorizationLabel: String {
3108 switch viewModel.monitoringNotificationStatus {
3109 case .authorized, .provisional, .ephemeral:
3110 return "Allowed"
3111 case .denied:
3112 return "Denied"
3113 case .notDetermined:
3114 return "Not Requested"
3115 @unknown default:
3116 return "Unknown"
3117 }
3118 }
3119
3120 var body: some View {
3121 Form {
3122 Section("Monitoring") {
3123 Toggle(
3124 "Enable Background Monitoring",
3125 isOn: Binding(
3126 get: { viewModel.monitoringSettings.isEnabled },
3127 set: { viewModel.setMonitoringEnabled($0) }
3128 )
3129 )
3130
3131 Picker(
3132 "Base Interval",
3133 selection: Binding(
3134 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
3135 set: { viewModel.setMonitoringBaseInterval($0) }
3136 )
3137 ) {
3138 ForEach(MonitoringBaseInterval.allCases) { interval in
3139 Text(interval.title).tag(interval)
3140 }
3141 }
3142
3143 Toggle(
3144 "Adaptive Monitoring",
3145 isOn: Binding(
3146 get: { viewModel.monitoringSettings.adaptiveEnabled },
3147 set: { viewModel.setMonitoringAdaptiveEnabled($0) }
3148 )
3149 )
3150
3151 Picker(
3152 "Sensitivity",
3153 selection: Binding(
3154 get: { viewModel.monitoringSettings.sensitivity },
3155 set: { viewModel.setMonitoringSensitivity($0) }
3156 )
3157 ) {
3158 ForEach(MonitoringSensitivity.allCases) { sensitivity in
3159 Text(sensitivity.title).tag(sensitivity)
3160 }
3161 }
3162
3163 let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
3164 let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
3165 Toggle(
3166 "Quiet Hours",
3167 isOn: Binding(
3168 get: { viewModel.monitoringSettings.quietHours != nil },
3169 set: { isEnabled in
3170 viewModel.setMonitoringQuietHours(
3171 startHour: quietHoursStart,
3172 endHour: quietHoursEnd,
3173 isEnabled: isEnabled
3174 )
3175 }
3176 )
3177 )
3178
3179 if viewModel.monitoringSettings.quietHours != nil {
3180 Picker(
3181 "Quiet Starts",
3182 selection: Binding(
3183 get: { quietHoursStart },
3184 set: { startHour in
3185 viewModel.setMonitoringQuietHours(
3186 startHour: startHour,
3187 endHour: quietHoursEnd,
3188 isEnabled: true
3189 )
3190 }
3191 )
3192 ) {
3193 ForEach(0..<24, id: \.self) { hour in
3194 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3195 }
3196 }
3197
3198 Picker(
3199 "Quiet Ends",
3200 selection: Binding(
3201 get: { quietHoursEnd },
3202 set: { endHour in
3203 viewModel.setMonitoringQuietHours(
3204 startHour: quietHoursStart,
3205 endHour: endHour,
3206 isEnabled: true
3207 )
3208 }
3209 )
3210 ) {
3211 ForEach(0..<24, id: \.self) { hour in
3212 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
3213 }
3214 }
3215 }
3216
3217 Picker(
3218 "Domains",
3219 selection: Binding(
3220 get: { viewModel.monitoringSettings.scope },
3221 set: { viewModel.setMonitoringScope($0) }
3222 )
3223 ) {
3224 ForEach(MonitoringScope.allCases) { scope in
3225 Text(scope.title).tag(scope)
3226 }
3227 }
3228
3229 if viewModel.monitoringSettings.scope == .selectedOnly {
3230 ForEach(viewModel.trackedDomains) { trackedDomain in
3231 Toggle(
3232 trackedDomain.domain,
3233 isOn: Binding(
3234 get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
3235 set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
3236 )
3237 )
3238 }
3239 }
3240
3241 Toggle(
3242 "Local Alerts",
3243 isOn: Binding(
3244 get: { viewModel.monitoringSettings.alertsEnabled },
3245 set: { isEnabled in
3246 if isEnabled {
3247 Task {
3248 await viewModel.requestMonitoringNotificationAuthorization()
3249 }
3250 } else {
3251 viewModel.setMonitoringAlertsEnabled(false)
3252 }
3253 }
3254 )
3255 )
3256
3257 Picker(
3258 "Notify For",
3259 selection: Binding(
3260 get: { viewModel.monitoringSettings.alertFilter },
3261 set: { viewModel.setMonitoringAlertFilter($0) }
3262 )
3263 ) {
3264 ForEach(MonitoringAlertFilter.allCases) { filter in
3265 Text(filter.title).tag(filter)
3266 }
3267 }
3268
3269 LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
3270 LabeledContent("Notification Access", value: notificationAuthorizationLabel)
3271
3272 if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
3273 Text(monitoringStatusMessage)
3274 .font(appDensity.font(.caption, design: .default))
3275 .foregroundStyle(Color(.appTextSecondary))
3276 }
3277
3278 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
3279 Text("Background monitoring and alerts are available in Pro.")
3280 .font(appDensity.font(.caption, design: .default))
3281 .foregroundStyle(Color(.appTextSecondary))
3282 }
3283 }
3284 }
3285 .navigationTitle("Monitoring")
3286 .onAppear {
3287 viewModel.refreshMonitoringState()
3288 Task {
3289 await viewModel.refreshMonitoringAuthorizationStatus()
3290 }
3291 }
3292 }
3293
3294 private static func monitoringHourLabel(for hour: Int) -> String {
3295 let formatter = DateFormatter()
3296 formatter.dateFormat = "h a"
3297 let components = DateComponents(calendar: .current, hour: hour)
3298 return components.date.map(formatter.string(from:)) ?? "\(hour):00"
3299 }
3300}
3301
3302private struct DataPortabilitySettingsView: View {
3303 private enum ImportTarget {
3304 case backup
3305 case trackedDomains
3306 case workflows
3307
3308 var expectedKind: DataPortabilityImportKind {
3309 switch self {
3310 case .backup:
3311 return .backup
3312 case .trackedDomains:
3313 return .trackedDomains
3314 case .workflows:
3315 return .workflows
3316 }
3317 }
3318
3319 var allowedContentTypes: [UTType] {
3320 switch self {
3321 case .backup:
3322 return [UTType.json]
3323 case .trackedDomains, .workflows:
3324 return [UTType.json, UTType.commaSeparatedText]
3325 }
3326 }
3327 }
3328
3329 @Environment(\.appDensity) private var appDensity
3330 @Bindable var viewModel: DomainViewModel
3331
3332 @State private var importMode: DataPortabilityImportMode = .merge
3333 @State private var activeImportTarget: ImportTarget?
3334 @State private var pendingImportTarget: ImportTarget?
3335 @State private var importDebugStatus: String?
3336 @State private var pendingImportPreview: DataImportPreview?
3337 @State private var pendingImportError: String?
3338 @State private var showReplaceImportConfirmation = false
3339
3340 var body: some View {
3341 Form {
3342 Section("Import & Export") {
3343 Picker("Import Mode", selection: $importMode) {
3344 ForEach(DataPortabilityImportMode.allCases) { mode in
3345 Text(mode.title).tag(mode)
3346 }
3347 }
3348
3349 Text(importMode.explanation)
3350 .font(appDensity.font(.caption, design: .default))
3351 .foregroundStyle(Color(.appTextSecondary))
3352
3353 Button("Export Full Backup") {
3354 exportFullBackup()
3355 }
3356
3357 Button("Import Backup") {
3358 recordImportDebugStatus("Tapped Import Backup")
3359 pendingImportTarget = .backup
3360 activeImportTarget = .backup
3361 }
3362
3363 Menu("Export Tracked Domains") {
3364 Button("JSON") {
3365 exportPortableTrackedDomainsJSON()
3366 }
3367 Button("CSV") {
3368 exportPortableTrackedDomainsCSV()
3369 }
3370 }
3371
3372 Button("Import Tracked Domains") {
3373 recordImportDebugStatus("Tapped Import Tracked Domains")
3374 pendingImportTarget = .trackedDomains
3375 activeImportTarget = .trackedDomains
3376 }
3377
3378 Menu("Export Workflows") {
3379 Button("JSON") {
3380 exportPortableWorkflowsJSON()
3381 }
3382 Button("CSV") {
3383 exportPortableWorkflowsCSV()
3384 }
3385 }
3386
3387 Button("Import Workflows") {
3388 recordImportDebugStatus("Tapped Import Workflows")
3389 pendingImportTarget = .workflows
3390 activeImportTarget = .workflows
3391 }
3392
3393 Button("Export History") {
3394 exportPortableHistoryJSON()
3395 }
3396 }
3397
3398 Section("Local Data") {
3399 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
3400 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
3401 LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
3402 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
3403 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
3404 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
3405
3406 Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.")
3407 .font(appDensity.font(.caption, design: .default))
3408 .foregroundStyle(Color(.appTextSecondary))
3409
3410 if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
3411 Text(portabilityStatusMessage)
3412 .font(appDensity.font(.caption, design: .default))
3413 .foregroundStyle(Color(.appTextSecondary))
3414 }
3415 }
3416
3417 #if DEBUG
3418 if let importDebugStatus {
3419 Section("Import Debug") {
3420 Text(importDebugStatus)
3421 .font(appDensity.font(.caption, design: .default))
3422 .foregroundStyle(Color(.appTextSecondary))
3423 .textSelection(.enabled)
3424 }
3425 }
3426 #endif
3427 }
3428 .navigationTitle("Import & Export")
3429 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
3430 Button("Replace", role: .destructive) {
3431 applyPendingImport()
3432 }
3433 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3434 } message: {
3435 Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
3436 }
3437 .alert("Import Error", isPresented: Binding(
3438 get: { pendingImportError != nil },
3439 set: { if !$0 { pendingImportError = nil } }
3440 )) {
3441 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3442 } message: {
3443 Text(pendingImportError ?? "The import could not be completed.")
3444 }
3445 .sheet(isPresented: Binding(
3446 get: { pendingImportPreview != nil },
3447 set: { if !$0 { pendingImportPreview = nil } }
3448 )) {
3449 if let pendingImportPreview {
3450 DataImportPreviewSheet(
3451 preview: pendingImportPreview,
3452 mode: importMode,
3453 onCancel: {
3454 self.pendingImportPreview = nil
3455 },
3456 onApply: {
3457 if importMode == .replace {
3458 showReplaceImportConfirmation = true
3459 } else {
3460 applyPendingImport()
3461 }
3462 }
3463 )
3464 }
3465 }
3466 .fileImporter(
3467 isPresented: Binding(
3468 get: { activeImportTarget != nil },
3469 set: { if !$0 { activeImportTarget = nil } }
3470 ),
3471 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
3472 allowsMultipleSelection: false
3473 ) { result in
3474 guard let pendingImportTarget else {
3475 recordImportDebugStatus("fileImporter returned with no active target")
3476 return
3477 }
3478 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
3479 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
3480 self.pendingImportTarget = nil
3481 self.activeImportTarget = nil
3482 }
3483 .onAppear {
3484 viewModel.refreshDataLifecycleSummary()
3485 }
3486 }
3487
3488 private func exportFullBackup() {
3489 guard let data = viewModel.exportFullBackupData() else { return }
3490 ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
3491 }
3492
3493 private func exportPortableTrackedDomainsJSON() {
3494 guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
3495 ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
3496 }
3497
3498 private func exportPortableTrackedDomainsCSV() {
3499 ExportPresenter.share(
3500 filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
3501 contents: viewModel.exportPortableTrackedDomainsCSV()
3502 )
3503 }
3504
3505 private func exportPortableWorkflowsJSON() {
3506 guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
3507 ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
3508 }
3509
3510 private func exportPortableWorkflowsCSV() {
3511 ExportPresenter.share(
3512 filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
3513 contents: viewModel.exportPortableWorkflowsCSV()
3514 )
3515 }
3516
3517 private func exportPortableHistoryJSON() {
3518 guard let data = viewModel.exportPortableHistoryJSONData() else { return }
3519 ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
3520 }
3521
3522 private func handleImportResult(
3523 _ result: Result<[URL], Error>,
3524 expectedKind: DataPortabilityImportKind
3525 ) {
3526 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
3527 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
3528 do {
3529 let urls = try result.get()
3530 guard let url = urls.first else {
3531 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
3532 recordImportDebugStatus("No URL returned from picker")
3533 return
3534 }
3535 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
3536 recordImportDebugStatus("Selected \(url.lastPathComponent)")
3537 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
3538 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
3539 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
3540 defer {
3541 if shouldStopAccessing {
3542 url.stopAccessingSecurityScopedResource()
3543 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
3544 }
3545 }
3546
3547 let data = try Data(contentsOf: url)
3548 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
3549 recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
3550 let preview = try viewModel.prepareDataImport(
3551 data: data,
3552 fileName: url.lastPathComponent,
3553 mode: importMode
3554 )
3555 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
3556 recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
3557
3558 guard preview.kind == expectedKind else {
3559 let message = preview.kind == .backup
3560 ? "That file is a full backup. Use Import Backup."
3561 : "That file type does not match this import action."
3562 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
3563 recordImportDebugStatus("Kind mismatch: \(message)")
3564 presentImportError(message)
3565 return
3566 }
3567
3568 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
3569 recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
3570 presentImportPreview(preview)
3571 } catch {
3572 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
3573 recordImportDebugStatus("Import failed: \(error.localizedDescription)")
3574 presentImportError(error.localizedDescription)
3575 }
3576 }
3577
3578 private func applyPendingImport() {
3579 guard let pendingImportPreview else { return }
3580 do {
3581 _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
3582 self.pendingImportPreview = nil
3583 } catch {
3584 pendingImportError = error.localizedDescription
3585 }
3586 }
3587
3588 private func portabilityFilename(suffix: String, fileExtension: String) -> String {
3589 let formatter = DateFormatter()
3590 formatter.dateFormat = "yyyyMMdd_HHmmss"
3591 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
3592 }
3593
3594 private func presentImportPreview(_ preview: DataImportPreview) {
3595 Task { @MainActor in
3596 try? await Task.sleep(for: .milliseconds(300))
3597 DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
3598 recordImportDebugStatus("Preview presented for \(preview.fileName)")
3599 pendingImportPreview = preview
3600 }
3601 }
3602
3603 private func presentImportError(_ message: String) {
3604 Task { @MainActor in
3605 try? await Task.sleep(for: .milliseconds(300))
3606 DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
3607 recordImportDebugStatus("Error presented: \(message)")
3608 pendingImportError = message
3609 }
3610 }
3611
3612 private func recordImportDebugStatus(_ message: String) {
3613 #if DEBUG
3614 let status = "[Import Debug] \(message)"
3615 importDebugStatus = status
3616 print(status)
3617 #endif
3618 }
3619}
3620
3621private struct DataManagementSettingsView: View {
3622 @Bindable var viewModel: DomainViewModel
3623 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
3624
3625 @State private var showClearHistoryConfirmation = false
3626 @State private var showClearCacheConfirmation = false
3627 @State private var showClearWorkflowsConfirmation = false
3628 @State private var showClearTrackedDomainsConfirmation = false
3629 @State private var showDeleteAllConfirmation = false
3630 @State private var deleteAllErrorMessage: String?
3631 @State private var deleteAllSuccessMessage: String?
3632 @State private var isDeletingAllData = false
3633
3634 var body: some View {
3635 Form {
3636 Section("Data") {
3637 Button("Clear History", role: .destructive) {
3638 showClearHistoryConfirmation = true
3639 }
3640
3641 Button("Clear Cache", role: .destructive) {
3642 showClearCacheConfirmation = true
3643 }
3644
3645 Button("Clear Workflows", role: .destructive) {
3646 showClearWorkflowsConfirmation = true
3647 }
3648
3649 Button("Clear Tracked Domains", role: .destructive) {
3650 showClearTrackedDomainsConfirmation = true
3651 }
3652 }
3653
3654 Section {
3655 Button(role: .destructive) {
3656 showDeleteAllConfirmation = true
3657 } label: {
3658 HStack {
3659 Text("Delete All Data")
3660 Spacer()
3661 if isDeletingAllData {
3662 ProgressView()
3663 .controlSize(.small)
3664 }
3665 }
3666 }
3667 .disabled(isDeletingAllData)
3668 } header: {
3669 Text("Danger Zone")
3670 } footer: {
3671 Text("Permanently removes all local DomainDig data from this device.")
3672 }
3673 }
3674 .disabled(isDeletingAllData)
3675 .navigationTitle("Data Management")
3676 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
3677 Button("Clear", role: .destructive) {
3678 viewModel.clearHistory()
3679 }
3680 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3681 } message: {
3682 Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
3683 }
3684 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
3685 Button("Clear", role: .destructive) {
3686 viewModel.clearLookupCache()
3687 }
3688 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3689 } message: {
3690 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
3691 }
3692 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
3693 Button("Clear", role: .destructive) {
3694 viewModel.clearWorkflows()
3695 }
3696 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3697 } message: {
3698 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
3699 }
3700 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
3701 Button("Clear", role: .destructive) {
3702 viewModel.clearTrackedDomains()
3703 }
3704 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3705 } message: {
3706 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
3707 }
3708 .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
3709 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3710 Button("Delete All Data", role: .destructive) {
3711 deleteAllData()
3712 }
3713 } message: {
3714 Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.")
3715 }
3716 .alert("Delete Failed", isPresented: Binding(
3717 get: { deleteAllErrorMessage != nil },
3718 set: { if !$0 { deleteAllErrorMessage = nil } }
3719 )) {
3720 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
3721 } message: {
3722 Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
3723 }
3724 .safeAreaInset(edge: .bottom) {
3725 if let deleteAllSuccessMessage {
3726 Text(deleteAllSuccessMessage)
3727 .font(.footnote.weight(.medium))
3728 .foregroundStyle(Color(.appTextSecondary))
3729 .padding(.horizontal, 14)
3730 .padding(.vertical, 10)
3731 // Reduce Transparency swaps the blur for an opaque surface.
3732 // On iOS 26+ the system also composites its own translucency
3733 // that the app cannot declare — verify there too (Phase 6).
3734 .background(
3735 Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
3736 )
3737 .padding(.bottom, 8)
3738 .transition(.move(edge: .bottom).combined(with: .opacity))
3739 }
3740 }
3741 }
3742
3743 private func deleteAllData() {
3744 guard !isDeletingAllData else { return }
3745
3746 isDeletingAllData = true
3747 deleteAllErrorMessage = nil
3748 deleteAllSuccessMessage = nil
3749
3750 Task {
3751 do {
3752 try await DataResetService.wipeAllLocalData(viewModel: viewModel)
3753 deleteAllSuccessMessage = "All local data removed."
3754 try? await Task.sleep(for: .seconds(2))
3755 if deleteAllSuccessMessage == "All local data removed." {
3756 deleteAllSuccessMessage = nil
3757 }
3758 } catch {
3759 deleteAllErrorMessage = error.localizedDescription
3760 }
3761
3762 isDeletingAllData = false
3763 }
3764 }
3765}
3766
3767private struct AboutSettingsView: View {
3768 @State private var cloudSyncService = CloudSyncService.shared
3769
3770 private var appVersion: String {
3771 AppVersion.current
3772 }
3773
3774 var body: some View {
3775 Form {
3776 Section("About") {
3777 LabeledContent("Version", value: appVersion)
3778 LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only")
3779 LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)")
3780 }
3781 }
3782 .navigationTitle("App Info")
3783 .task {
3784 await cloudSyncService.refreshAvailability()
3785 }
3786 }
3787}
3788
3789private struct DataImportPreviewSheet: View {
3790 @Environment(\.dismiss) private var dismiss
3791
3792 let preview: DataImportPreview
3793 let mode: DataPortabilityImportMode
3794 let onCancel: () -> Void
3795 let onApply: () -> Void
3796
3797 var body: some View {
3798 NavigationStack {
3799 List {
3800 Section("Summary") {
3801 ForEach(preview.summaryLines, id: \.self) { line in
3802 Text(line)
3803 }
3804 }
3805
3806 Section("Projected Counts") {
3807 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
3808 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
3809 LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
3810 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
3811 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
3812 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
3813 }
3814
3815 if !preview.warnings.isEmpty {
3816 Section("Warnings") {
3817 ForEach(preview.warnings, id: \.self) { warning in
3818 Text(warning)
3819 .foregroundStyle(Color(.appTextSecondary))
3820 }
3821 }
3822 }
3823 }
3824 .navigationTitle("Import Preview")
3825 .toolbar {
3826 ToolbarItem(placement: .cancellationAction) {
3827 Button("Cancel") {
3828 onCancel()
3829 dismiss()
3830 }
3831 }
3832 ToolbarItem(placement: .confirmationAction) {
3833 Button(mode == .replace ? "Replace" : "Import") {
3834 onApply()
3835 if mode == .merge {
3836 dismiss()
3837 }
3838 }
3839 }
3840 }
3841 }
3842 }
3843}
3844
3845extension ChangeImpactClassification {
3846 var tone: AppStatusTone {
3847 switch self {
3848 case .informational: return .neutral
3849 case .warning: return .warning
3850 case .critical: return .critical
3851 }
3852 }
3853}
3854
3855extension TLSGrade {
3856 var tone: ResultTone {
3857 switch self {
3858 case .a: return .success
3859 case .f: return .failure
3860 default: return .warning
3861 }
3862 }
3863}
3864
3865extension EmailSecurityGrade {
3866 var tone: ResultTone {
3867 switch self {
3868 case .a: return .success
3869 case .f: return .failure
3870 default: return .warning
3871 }
3872 }
3873}
3874
3875#Preview {
3876 ContentView(viewModel: DomainViewModel())
3877}