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