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