diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 09:47:24 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 09:47:24 -0500 |
| commit | 7b41195620eadd54d25fa98dcd36735cba906ba3 (patch) | |
| tree | 929a791a4c2fd3278f98c17c0ae5476940a01312 /DomainDig | |
| parent | 36ea668d7fc9070770973b0bc329f4319bf43cab (diff) | |
| download | domain-dig-7b41195620eadd54d25fa98dcd36735cba906ba3.tar.gz domain-dig-7b41195620eadd54d25fa98dcd36735cba906ba3.tar.bz2 domain-dig-7b41195620eadd54d25fa98dcd36735cba906ba3.zip | |
feat(v2.7.0): add provenance, confidence, and reproducibility metadata
* add result provenance across major sections
* introduce confidence levels for ambiguous outputs
* distinguish observed facts from inferred summaries
* expand snapshot metadata for reproducibility
* improve error classification and partial snapshot handling
* include provenance and confidence in export
* add local notes for tracked domains and history
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 7 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 226 | ||||
| -rw-r--r-- | DomainDig/DomainAvailabilityService.swift | 8 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 27 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 187 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 112 | ||||
| -rw-r--r-- | DomainDig/LookupRuntime.swift | 12 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 138 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 25 |
9 files changed, 655 insertions, 87 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift new file mode 100644 index 0000000..a1516b8 --- /dev/null +++ b/DomainDig/AppVersion.swift @@ -0,0 +1,7 @@ +import Foundation + +enum AppVersion { + static var current: String { + "2.7.0" + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 21c37d2..ed2da49 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -42,15 +42,20 @@ struct ContentView: View { } if viewModel.hasRun { actionButtons - if let statusMessage = resultStatusMessage { + if !viewModel.resultsLoaded { + LookupProgressOverviewView(steps: viewModel.activeLoadingLabels) + .padding(.top, appDensity.metrics.cardSpacing) + } else if let statusMessage = resultStatusMessage { LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) .padding(.top, appDensity.metrics.cardSpacing) } - SummaryView(fields: viewModel.summaryFields) - .padding(.top, appDensity.metrics.cardSpacing) - if let changeSummary = viewModel.currentChangeSummary { - DomainChangeSummaryView(summary: changeSummary) + if viewModel.resultsLoaded { + SummaryView(fields: viewModel.summaryFields) .padding(.top, appDensity.metrics.cardSpacing) + if let changeSummary = viewModel.currentChangeSummary { + DomainChangeSummaryView(summary: changeSummary) + .padding(.top, appDensity.metrics.cardSpacing) + } } DomainSectionView( isCollapsed: sectionCollapsedBinding(.domain), @@ -59,6 +64,9 @@ struct ContentView: View { showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, availabilityLoading: viewModel.availabilityLoading, suggestionsLoading: viewModel.suggestionsLoading, + provenance: viewModel.currentSnapshot.provenanceBySection[.availability], + confidence: viewModel.currentSnapshot.availabilityConfidence, + snapshotNote: viewModel.currentSnapshot.note, trackedDomain: viewModel.currentTrackedDomain, trackingLimitMessage: viewModel.trackingLimitMessage, onTrack: { @@ -82,6 +90,8 @@ struct ContentView: View { rows: viewModel.ownershipRows, loading: viewModel.ownershipLoading, error: viewModel.ownershipError, + provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], + confidence: viewModel.currentSnapshot.ownershipConfidence, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -90,6 +100,8 @@ struct ContentView: View { rows: viewModel.subdomainRows, loading: viewModel.subdomainsLoading, error: viewModel.subdomainsError, + provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], + confidence: viewModel.currentSnapshot.subdomainConfidence, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -97,6 +109,7 @@ struct ContentView: View { DomainDiffView( title: "Latest Changes", sections: viewModel.currentDiffSections, + contextNote: viewModel.currentChangeSummary?.contextNote, showsUnchanged: false ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -107,6 +120,8 @@ struct ContentView: View { sections: viewModel.dnsRows, ptrMessage: viewModel.ptrMessage, loading: viewModel.dnsLoading || viewModel.ptrLoading, + dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], + ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], sectionError: viewModel.dnsError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -116,13 +131,16 @@ struct ContentView: View { sslInfo: viewModel.sslInfo, sslLoading: viewModel.sslLoading || viewModel.hstsLoading, sslError: viewModel.sslError, + tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl], responseRows: viewModel.webResponseRows, headers: viewModel.httpHeaders, headersLoading: viewModel.httpHeadersLoading, headersError: viewModel.httpHeadersError, + httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders], redirects: viewModel.redirectRows, redirectLoading: viewModel.redirectChainLoading, redirectError: viewModel.redirectChainError, + redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain], finalURL: viewModel.currentSnapshot.redirectChain.last?.url ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -130,6 +148,8 @@ struct ContentView: View { isCollapsed: sectionCollapsedBinding(.email), rows: viewModel.emailRows, loading: viewModel.emailSecurityLoading, + provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity], + confidence: viewModel.currentSnapshot.emailSecurityConfidence, error: viewModel.emailSecurityError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -138,14 +158,18 @@ struct ContentView: View { reachabilityRows: viewModel.reachabilityRows, reachabilityLoading: viewModel.reachabilityLoading, reachabilityError: viewModel.reachabilityError, + reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability], locationRows: viewModel.locationRows, geolocation: viewModel.ipGeolocation, geolocationLoading: viewModel.ipGeolocationLoading, geolocationError: viewModel.ipGeolocationError, + geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation], + geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence, standardPortRows: viewModel.standardPortRows, customPortRows: viewModel.customPortRows, portScanLoading: viewModel.portScanLoading, portScanError: viewModel.portScanError, + portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan], customPortScanLoading: viewModel.customPortScanLoading, customPortScanError: viewModel.customPortScanError, isCloudflareProxied: viewModel.isCloudflareProxied, @@ -161,27 +185,6 @@ struct ContentView: View { .padding(.horizontal) .padding(.bottom, 32) } - .safeAreaInset(edge: .top) { - if viewModel.hasRun { - StickyLookupSummaryView( - domain: viewModel.searchedDomain, - availability: viewModel.availabilityResult?.status, - primaryIP: currentPrimaryIP, - sslInfo: viewModel.sslInfo, - sslError: viewModel.sslError, - emailSecurity: viewModel.emailSecurity, - emailError: viewModel.emailSecurityError, - changeSummary: viewModel.currentChangeSummary - ) - .padding(.horizontal) - .padding(.top, 6) - .background { - Rectangle() - .fill(.ultraThinMaterial) - .opacity(0.96) - } - } - } .background( LinearGradient( colors: [Color.black, Color(.systemGray6).opacity(0.12)], @@ -521,11 +524,7 @@ struct ContentView: View { } private var defaultCollapsedSections: Set<ResultSection> { - var sections: Set<ResultSection> = [] - if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil { - sections.insert(.network) - } - return sections + [] } private var currentPrimaryIP: String? { @@ -636,6 +635,29 @@ struct StickyLookupSummaryView: View { } } +struct LookupProgressOverviewView: View { + @Environment(\.appDensity) private var appDensity + let steps: [String] + + var body: some View { + CardView(allowsHorizontalScroll: false) { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + VStack(alignment: .leading, spacing: 4) { + Text("Running lookup…") + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: " • ")) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + } +} + struct LookupStatusBannerView: View { @Environment(\.appDensity) private var appDensity let message: String @@ -686,6 +708,7 @@ struct LookupStatusBannerView: View { struct DomainChangeSummaryView: View { @Environment(\.appDensity) private var appDensity let summary: DomainChangeSummary + @State private var showsDetails = false var body: some View { CardView(allowsHorizontalScroll: false) { @@ -706,9 +729,43 @@ struct DomainChangeSummaryView: View { .foregroundStyle(.secondary) } - Text(summary.message) + VStack(alignment: .leading, spacing: 4) { + Text("Inference") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(summary.message) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + .lineLimit(2) + } + + if !summary.observedFacts.isEmpty || summary.contextNote != nil { + DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) { + VStack(alignment: .leading, spacing: 8) { + if !summary.observedFacts.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Observed") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in + Text(fact) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + } + } + } + + if let contextNote = summary.contextNote { + Text(contextNote) + .font(appDensity.font(.caption2)) + .foregroundStyle(.orange) + } + } + .padding(.top, 4) + } .font(appDensity.font(.caption)) - .foregroundStyle(.primary) + .tint(.secondary) + } } } @@ -727,6 +784,7 @@ struct DomainChangeSummaryView: View { struct DomainDiffView: View { let title: String let sections: [DomainDiffSection] + let contextNote: String? let showsUnchanged: Bool @State private var collapsedSections = Set<UUID>() @@ -766,6 +824,9 @@ struct DomainDiffView: View { .font(.system(.caption, design: .monospaced)) } } + if let contextNote { + MessageCardView(text: contextNote, isError: false) + } if filteredSections.isEmpty { MessageCardView(text: "No comparison data available", isError: false) } else { @@ -917,6 +978,9 @@ struct DomainSectionView: View { let showSuggestions: Bool let availabilityLoading: Bool let suggestionsLoading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let snapshotNote: String? let trackedDomain: TrackedDomain? let trackingLimitMessage: String? let onTrack: () -> Void @@ -953,6 +1017,11 @@ struct DomainSectionView: View { } } content: { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView( + provenance: provenance, + confidence: confidence, + note: snapshotNote == nil ? nil : "Audit note present" + ) ForEach(rows) { row in LabeledValueRow(row: row) } @@ -986,7 +1055,7 @@ struct DomainSectionView: View { .foregroundStyle(.primary) .textSelection(.enabled) Spacer() - AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) + AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus)) } } } @@ -1001,11 +1070,14 @@ struct OwnershipSectionView: View { let rows: [InfoRowViewData] let loading: Bool let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let showsHistoryPlaceholder: Bool var body: some View { CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) if loading { ProgressView("Fetching RDAP ownership…") .appLoadingStyle() @@ -1033,11 +1105,14 @@ struct SubdomainsSectionView: View { let rows: [SubdomainRowViewData] let loading: Bool let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let showsExtendedPlaceholder: Bool var body: some View { CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) if loading { ProgressView("Checking certificate transparency…") .appLoadingStyle() @@ -1082,6 +1157,8 @@ struct DNSSectionView: View { let sections: [DNSRecordSectionViewData] let ptrMessage: SectionMessageViewData? let loading: Bool + let dnsProvenance: SectionProvenance? + let ptrProvenance: SectionProvenance? let sectionError: String? var body: some View { @@ -1091,6 +1168,11 @@ struct DNSSectionView: View { } else if let sectionError, sections.isEmpty { MessageCardView(text: sectionError, isError: true) } else { + if dnsProvenance != nil { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) + } + } ForEach(sections) { section in CardView { Text(section.title) @@ -1124,6 +1206,7 @@ struct DNSSectionView: View { .font(.system(.subheadline, design: .monospaced)) .fontWeight(.semibold) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil) MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) } } @@ -1139,13 +1222,16 @@ struct WebSectionView: View { let sslInfo: SSLCertificateInfo? let sslLoading: Bool let sslError: String? + let tlsProvenance: SectionProvenance? let responseRows: [InfoRowViewData] let headers: [HTTPHeader] let headersLoading: Bool let headersError: String? + let httpProvenance: SectionProvenance? let redirects: [RedirectHopViewData] let redirectLoading: Bool let redirectError: String? + let redirectProvenance: SectionProvenance? let finalURL: String? var body: some View { @@ -1158,6 +1244,7 @@ struct WebSectionView: View { Spacer() AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) } + SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) if sslLoading { ProgressView("Checking certificate…") .appLoadingStyle() @@ -1188,6 +1275,7 @@ struct WebSectionView: View { Text("Headers") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: httpProvenance, confidence: nil) if headersLoading { ProgressView("Fetching headers…") .appLoadingStyle() @@ -1225,6 +1313,7 @@ struct WebSectionView: View { AppCopyButton(value: finalURL, label: "Copy redirect URL") } } + SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil) if redirectLoading { ProgressView("Tracing redirects…") .appLoadingStyle() @@ -1268,11 +1357,14 @@ struct EmailSectionView: View { @Binding var isCollapsed: Bool let rows: [EmailRowViewData] let loading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let error: String? var body: some View { CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { CardView { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) HStack { Spacer() AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) @@ -1331,14 +1423,18 @@ struct NetworkSectionView: View { let reachabilityRows: [ReachabilityRowViewData] let reachabilityLoading: Bool let reachabilityError: String? + let reachabilityProvenance: SectionProvenance? let locationRows: [InfoRowViewData] let geolocation: IPGeolocation? let geolocationLoading: Bool let geolocationError: String? + let geolocationProvenance: SectionProvenance? + let geolocationConfidence: ConfidenceLevel? let standardPortRows: [PortScanRowViewData] let customPortRows: [PortScanRowViewData] let portScanLoading: Bool let portScanError: String? + let portScanProvenance: SectionProvenance? let customPortScanLoading: Bool let customPortScanError: String? let isCloudflareProxied: Bool @@ -1352,6 +1448,7 @@ struct NetworkSectionView: View { Text("Reachability") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil) if reachabilityLoading { ProgressView("Checking ports…") .appLoadingStyle() @@ -1376,6 +1473,7 @@ struct NetworkSectionView: View { Text("Location") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence) if geolocationLoading { ProgressView("Looking up location…") .appLoadingStyle() @@ -1407,6 +1505,7 @@ struct NetworkSectionView: View { Text("Port Scan") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil) if isCloudflareProxied { Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") @@ -1609,6 +1708,62 @@ struct MessageRowView: View { } } +struct SectionTrustMetadataView: View { + @Environment(\.appDensity) private var appDensity + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let note: String? + + init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) { + self.provenance = provenance + self.confidence = confidence + self.note = note + } + + var body: some View { + if provenance != nil || confidence != nil || note != nil { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if let confidence { + Text("Confidence \(confidence.title)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if let provenance { + Text(provenance.provider ?? provenance.source) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(provenance.resultSource.label) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + DisclosureGroup("Details") { + VStack(alignment: .leading, spacing: 4) { + if let provenance { + LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary)) + if let provider = provenance.provider { + LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary)) + } + if let resolver = provenance.resolver { + LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary)) + } + LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary)) + LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary)) + } + if let note { + LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary)) + } + } + .padding(.top, 4) + } + .font(appDensity.font(.caption)) + .tint(.secondary) + } + } + } +} + struct LabeledValueRow: View { @Environment(\.appDensity) private var appDensity let row: InfoRowViewData @@ -1737,7 +1892,6 @@ private struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Focus", value: "Readable domain inspection") } } .navigationTitle("Settings") @@ -1776,7 +1930,7 @@ private struct SettingsView: View { } private var appVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" + AppVersion.current } } diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift index 9a350ef..59f6afa 100644 --- a/DomainDig/DomainAvailabilityService.swift +++ b/DomainDig/DomainAvailabilityService.swift @@ -15,8 +15,12 @@ struct DomainAvailabilityService { } let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) - let method = fallbackStatus == .registered ? "dns" : "fallback" - debugLog(method, domain: normalizedDomain, status: fallbackStatus) + if fallbackStatus == .registered { + debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration") + return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown) + } + + debugLog("fallback", domain: normalizedDomain, status: fallbackStatus) return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) } diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index 340873d..528841d 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -67,16 +67,33 @@ enum DomainDiffService { let highlights = summaryHighlights(from: allChangedItems) let severity = allChangedItems.map(\.severity).max() ?? .low let message = summaryMessage(from: allChangedItems, highlights: highlights) + let observedFacts = observedFacts(from: allChangedItems) + let inferredConclusions = highlights.isEmpty ? [] : [message] + let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) return DomainChangeSummary( hasChanges: !allChangedItems.isEmpty, changedSections: highlights, message: message, severity: severity, - generatedAt: generatedAt + generatedAt: generatedAt, + observedFacts: observedFacts, + inferredConclusions: inferredConclusions, + contextNote: contextNote ) } + static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { + var notes: [String] = [] + if oldSnapshot.resolverURLString != newSnapshot.resolverURLString { + notes.append("Compared snapshots used different DNS resolvers.") + } + if oldSnapshot.resultSource != newSnapshot.resultSource { + notes.append("Compared snapshots came from different collection modes.") + } + return notes.isEmpty ? nil : notes.joined(separator: " ") + } + static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { guard let days = snapshot.sslInfo?.daysUntilExpiry else { return .none @@ -410,6 +427,14 @@ enum DomainDiffService { return "\(highlights[0]) and \(highlights[1].lowercased())" } + private static func observedFacts(from items: [DomainDiffItem]) -> [String] { + items.prefix(3).map { item in + let oldValue = item.oldValue ?? "none" + let newValue = item.newValue ?? "none" + return "\(item.label): \(oldValue) -> \(newValue)" + } + } + private static func normalized(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 326db3a..3373447 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -87,6 +87,7 @@ struct SubdomainRowViewData: Identifiable { struct DomainSuggestionViewData: Identifiable { let id: UUID let domain: String + let availabilityStatus: DomainAvailabilityStatus let status: String let tone: ResultTone } @@ -204,13 +205,7 @@ final class DomainViewModel { private static let historyKey = "lookupHistory" private static let maxHistory = 250 - var history: [HistoryEntry] = { - guard let data = UserDefaults.standard.data(forKey: historyKey), - let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { - return [] - } - return entries - }() + var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() var historySearchText = "" var historyDateFilter: HistoryDateFilter = .all var historyChangeFilter: ChangeFilterOption = .all @@ -246,6 +241,24 @@ final class DomainViewModel { !customPortScanLoading } + var activeLoadingLabels: [String] { + var labels: [String] = [] + if availabilityLoading { labels.append("Availability") } + if dnsLoading { labels.append("DNS") } + if sslLoading || hstsLoading { labels.append("TLS") } + if httpHeadersLoading { labels.append("HTTP") } + if ownershipLoading { labels.append("Ownership") } + if emailSecurityLoading { labels.append("Email") } + if subdomainsLoading { labels.append("Subdomains") } + if redirectChainLoading { labels.append("Redirects") } + if reachabilityLoading { labels.append("Reachability") } + if ipGeolocationLoading { labels.append("Geolocation") } + if ptrLoading { labels.append("PTR") } + if portScanLoading { labels.append("Port Scan") } + if customPortScanLoading { labels.append("Custom Ports") } + return labels + } + var isCloudflareProxied: Bool { httpHeaders.contains { $0.name.lowercased() == "cf-ray" } } @@ -365,8 +378,20 @@ final class DomainViewModel { domain: searchedDomain, timestamp: currentSnapshotTimestamp, trackedDomainID: currentTrackedDomain?.id, + note: currentHistoryEntry?.note ?? currentTrackedDomain?.note, + appVersion: AppVersion.current, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: currentHistoryEntry?.dataSources ?? [], + provenanceBySection: currentHistoryEntry?.provenanceBySection ?? [:], + availabilityConfidence: currentHistoryEntry?.availabilityConfidence, + ownershipConfidence: currentHistoryEntry?.ownershipConfidence, + subdomainConfidence: currentHistoryEntry?.subdomainConfidence, + emailSecurityConfidence: currentHistoryEntry?.emailSecurityConfidence, + geolocationConfidence: currentHistoryEntry?.geolocationConfidence, + errorDetails: currentHistoryEntry?.errorDetails ?? [:], + isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, + validationIssues: currentHistoryEntry?.validationIssues ?? [], totalLookupDurationMs: lastLookupDurationMs, dnsSections: dnsSections, dnsError: dnsError, @@ -405,6 +430,11 @@ final class DomainViewModel { ) } + private var currentHistoryEntry: HistoryEntry? { + guard let currentHistoryEntryID else { return nil } + return history.first(where: { $0.id == currentHistoryEntryID }) + } + var currentReport: DomainReport? { guard !searchedDomain.isEmpty else { return nil } return reportBuilder.build( @@ -543,9 +573,7 @@ final class DomainViewModel { } func rerunInspection(for trackedDomain: TrackedDomain) { - domain = trackedDomain.domain - run() - rerunNavigationToken = UUID() + rerunInspection(for: trackedDomain, useSnapshotResolver: false) } func deleteTrackedDomains(at offsets: IndexSet) { @@ -609,13 +637,24 @@ final class DomainViewModel { UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) } - func rerunLookup(from entry: HistoryEntry) { - UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { + if useSnapshotResolver { + UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + } domain = entry.domain run() rerunNavigationToken = UUID() } + func rerunInspection(for trackedDomain: TrackedDomain, useSnapshotResolver: Bool) { + if useSnapshotResolver, let snapshot = latestSnapshot(for: trackedDomain) { + UserDefaults.standard.set(snapshot.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + } + domain = trackedDomain.domain + run() + rerunNavigationToken = UUID() + } + func reset() { lookupTask?.cancel() customPortScanTask?.cancel() @@ -853,8 +892,20 @@ final class DomainViewModel { domain: previousSnapshot.domain, timestamp: previousSnapshot.timestamp, trackedDomainID: previousSnapshot.trackedDomainID, + note: previousSnapshot.note, + appVersion: previousSnapshot.appVersion, resolverDisplayName: previousSnapshot.resolverDisplayName, resolverURLString: previousSnapshot.resolverURLString, + dataSources: previousSnapshot.dataSources, + provenanceBySection: previousSnapshot.provenanceBySection, + availabilityConfidence: previousSnapshot.availabilityConfidence, + ownershipConfidence: previousSnapshot.ownershipConfidence, + subdomainConfidence: previousSnapshot.subdomainConfidence, + emailSecurityConfidence: previousSnapshot.emailSecurityConfidence, + geolocationConfidence: previousSnapshot.geolocationConfidence, + errorDetails: previousSnapshot.errorDetails, + isPartialSnapshot: previousSnapshot.isPartialSnapshot, + validationIssues: previousSnapshot.validationIssues, totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, dnsSections: previousSnapshot.dnsSections, dnsError: previousSnapshot.dnsError, @@ -1232,6 +1283,7 @@ final class DomainViewModel { domain: snapshot.domain, timestamp: snapshot.timestamp, trackedDomainID: trackedDomainID, + note: currentHistoryEntry?.note, dnsSections: snapshot.dnsSections, sslInfo: snapshot.sslInfo, httpHeaders: snapshot.httpHeaders, @@ -1247,6 +1299,18 @@ final class DomainViewModel { hstsPreloaded: snapshot.hstsPreloaded, availabilityResult: snapshot.availabilityResult, suggestions: snapshot.suggestions, + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, totalLookupDurationMs: snapshot.totalLookupDurationMs, @@ -1302,6 +1366,12 @@ final class DomainViewModel { } } + func updateHistoryNote(_ note: String, for entry: HistoryEntry) { + guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return } + history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + persistHistory() + } + private func persistTrackedDomains() { if let data = try? JSONEncoder().encode(trackedDomains) { UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) @@ -1777,6 +1847,22 @@ final class DomainViewModel { trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary } + func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? { + recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot + } + + func resolverMismatchNote(for entry: HistoryEntry) -> String? { + guard entry.resolverURLString != resolverURLString else { return nil } + return "Current resolver differs from this snapshot. Re-running may produce different evidence." + } + + func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? { + guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else { + return nil + } + return "Current resolver differs from the latest snapshot for this tracked domain." + } + func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { let siblings = history.filter { candidate in if let trackedDomainID = entry.trackedDomainID { @@ -1834,8 +1920,20 @@ final class DomainViewModel { domain: trackedDomain.domain, timestamp: trackedDomain.updatedAt, trackedDomainID: trackedDomain.id, + note: trackedDomain.note, + appVersion: AppVersion.current, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: [], + provenanceBySection: [:], + availabilityConfidence: nil, + ownershipConfidence: nil, + subdomainConfidence: nil, + emailSecurityConfidence: nil, + geolocationConfidence: nil, + errorDetails: [:], + isPartialSnapshot: true, + validationIssues: ["No stored snapshot data available"], totalLookupDurationMs: nil, dnsSections: [], dnsError: nil, @@ -1874,6 +1972,31 @@ final class DomainViewModel { ) } + private static func loadHistoryEntries() -> [HistoryEntry] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: historyKey) else { + return [] + } + + if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { + return entries + } + + guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + return [] + } + + let decoder = JSONDecoder() + return rawArray.compactMap { item in + guard JSONSerialization.isValidJSONObject(item), + let itemData = try? JSONSerialization.data(withJSONObject: item), + let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else { + return nil + } + return entry + } + } + private static func loadTrackedDomains() -> [TrackedDomain] { let defaults = UserDefaults.standard let decoder = JSONDecoder() @@ -1953,10 +2076,11 @@ final class DomainViewModel { static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { [ SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), - SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), - SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), + SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), + SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), + SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)), + SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), - SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) ] } @@ -1965,17 +2089,32 @@ final class DomainViewModel { var rows = [ InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), + InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary), InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) ] rows.insert( InfoRowViewData( - label: "Availability", - value: availabilityLabel(snapshot.availabilityResult?.status), - tone: availabilityTone(snapshot.availabilityResult?.status) + label: "Observed Availability", + value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected", + tone: .secondary ), at: 1 ) + rows.insert( + InfoRowViewData( + label: "Inference", + value: availabilityInference(from: snapshot), + tone: availabilityTone(snapshot.availabilityResult?.status) + ), + at: 2 + ) + if let confidence = snapshot.availabilityConfidence { + rows.insert( + InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary), + at: 3 + ) + } if let certificateStatus = certificateBadgeLabel(from: snapshot) { rows.insert( InfoRowViewData( @@ -1994,6 +2133,7 @@ final class DomainViewModel { DomainSuggestionViewData( id: $0.id, domain: $0.domain, + availabilityStatus: $0.status, status: availabilityLabel($0.status), tone: availabilityTone($0.status) ) @@ -2562,6 +2702,17 @@ final class DomainViewModel { } } + private static func availabilityInference(from snapshot: LookupSnapshot) -> String { + switch snapshot.availabilityResult?.status { + case .registered: + return "Likely registered" + case .available: + return "Possibly available" + case .unknown, .none: + return "Unclear" + } + } + private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { switch status { case .available: diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 1a5c0d0..9505c12 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -39,6 +39,9 @@ struct HistoryView: View { HStack(spacing: 8) { AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) + if entry.isPartialSnapshot { + AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))) + } } HStack(spacing: 8) { @@ -51,6 +54,13 @@ struct HistoryView: View { } .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + + if let note = entry.note, !note.isEmpty { + Text(note) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -128,6 +138,9 @@ struct HistoryDetailView: View { @Bindable var viewModel: DomainViewModel let entry: HistoryEntry @Environment(\.dismiss) private var dismiss + @State private var noteDraft = "" + @State private var isEditingNote = false + @State private var showRerunOptions = false private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -153,6 +166,9 @@ struct HistoryDetailView: View { showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, availabilityLoading: false, suggestionsLoading: false, + provenance: snapshot.provenanceBySection[.availability], + confidence: snapshot.availabilityConfidence, + snapshotNote: entry.note, trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), trackingLimitMessage: nil, onTrack: { @@ -170,6 +186,8 @@ struct HistoryDetailView: View { rows: DomainViewModel.ownershipRows(from: snapshot), loading: false, error: snapshot.ownershipError, + provenance: snapshot.provenanceBySection[.ownership], + confidence: snapshot.ownershipConfidence, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -178,6 +196,8 @@ struct HistoryDetailView: View { rows: DomainViewModel.subdomainRows(from: snapshot), loading: false, error: snapshot.subdomainsError, + provenance: snapshot.provenanceBySection[.subdomains], + confidence: snapshot.subdomainConfidence, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -189,6 +209,7 @@ struct HistoryDetailView: View { DomainDiffView( title: "Compared With Previous Snapshot", sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), + contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), showsUnchanged: false ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -199,6 +220,8 @@ struct HistoryDetailView: View { sections: DomainViewModel.dnsRows(from: snapshot), ptrMessage: DomainViewModel.ptrMessage(from: snapshot), loading: false, + dnsProvenance: snapshot.provenanceBySection[.dns], + ptrProvenance: snapshot.provenanceBySection[.ptr], sectionError: snapshot.dnsError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -208,13 +231,16 @@ struct HistoryDetailView: View { sslInfo: snapshot.sslInfo, sslLoading: false, sslError: snapshot.sslError, + tlsProvenance: snapshot.provenanceBySection[.ssl], responseRows: DomainViewModel.webResponseRows(from: snapshot), headers: snapshot.httpHeaders, headersLoading: false, headersError: snapshot.httpHeadersError, + httpProvenance: snapshot.provenanceBySection[.httpHeaders], redirects: DomainViewModel.redirectRows(from: snapshot), redirectLoading: false, redirectError: snapshot.redirectChainError, + redirectProvenance: snapshot.provenanceBySection[.redirectChain], finalURL: snapshot.redirectChain.last?.url ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -222,6 +248,8 @@ struct HistoryDetailView: View { isCollapsed: .constant(false), rows: DomainViewModel.emailRows(from: snapshot), loading: false, + provenance: snapshot.provenanceBySection[.emailSecurity], + confidence: snapshot.emailSecurityConfidence, error: snapshot.emailSecurityError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -230,14 +258,18 @@ struct HistoryDetailView: View { reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), reachabilityLoading: false, reachabilityError: snapshot.reachabilityError, + reachabilityProvenance: snapshot.provenanceBySection[.reachability], locationRows: DomainViewModel.locationRows(from: snapshot), geolocation: snapshot.ipGeolocation, geolocationLoading: false, geolocationError: snapshot.ipGeolocationError, + geolocationProvenance: snapshot.provenanceBySection[.ipGeolocation], + geolocationConfidence: snapshot.geolocationConfidence, standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), portScanLoading: false, portScanError: snapshot.portScanError, + portScanProvenance: snapshot.provenanceBySection[.portScan], customPortScanLoading: false, customPortScanError: nil, isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), @@ -253,26 +285,84 @@ struct HistoryDetailView: View { .background(Color.black) .navigationTitle(entry.domain) .toolbar { - Button("Re-run") { - viewModel.rerunLookup(from: entry) + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Note") { + noteDraft = entry.note ?? "" + isEditingNote = true + } + Button("Re-run") { + showRerunOptions = true + } } } .onChange(of: viewModel.rerunNavigationToken) { _, _ in dismiss() } + .confirmationDialog("Re-run lookup", isPresented: $showRerunOptions) { + Button("Run with Current Settings") { + viewModel.rerunLookup(from: entry, useSnapshotResolver: false) + } + Button("Run with Snapshot Resolver") { + viewModel.rerunLookup(from: entry, useSnapshotResolver: true) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(viewModel.resolverMismatchNote(for: entry) ?? "Choose how to reproduce this snapshot.") + } + .sheet(isPresented: $isEditingNote) { + NavigationStack { + Form { + Section("Audit Note") { + TextField("Optional note", text: $noteDraft, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle(entry.domain) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isEditingNote = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.updateHistoryNote(noteDraft, for: entry) + isEditingNote = false + } + } + } + } + } .preferredColorScheme(.dark) } private var snapshotBanner: some View { - HStack(spacing: 8) { - Image(systemName: "archivebox") - .font(.caption) - Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") - .font(appDensity.font(.caption)) - Spacer() - Text("Live re-run available") - .font(appDensity.font(.caption2)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "archivebox") + .font(.caption) + Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") + .font(appDensity.font(.caption)) + Spacer() + Text("Live re-run available") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if let mismatchNote = viewModel.resolverMismatchNote(for: entry) { + Text(mismatchNote) + .font(appDensity.font(.caption2)) + .foregroundStyle(.orange) + } + if entry.isPartialSnapshot { + Text("Partial snapshot: \(entry.validationIssues.joined(separator: " | "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + if let note = entry.note, !note.isEmpty { + Text(note) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } } .foregroundStyle(.secondary) .padding(8) diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift index 08ea122..5db3134 100644 --- a/DomainDig/LookupRuntime.swift +++ b/DomainDig/LookupRuntime.swift @@ -79,15 +79,9 @@ actor LookupRuntime { } func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> { - await execute( - key: .domain(domain, .availability), - extract: { payload in - guard case let .availability(result) = payload else { return nil } - return result - }, - operation: { - .availability(await DomainAvailabilityService.check(domain: domain)) - } + CachedLookupResult( + value: await DomainAvailabilityService.check(domain: domain), + source: .live ) } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 85136d2..60e5852 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -6,6 +6,59 @@ enum ServiceResult<Value> { case error(String) } +enum ConfidenceLevel: String, Codable { + case high + case medium + case low + + var title: String { + rawValue.capitalized + } +} + +enum InspectionErrorKind: String, Codable { + case network + case timeout + case rateLimited + case parsing + case unsupported + case unavailable + case unknown + + var title: String { + switch self { + case .network: + return "Network" + case .timeout: + return "Timeout" + case .rateLimited: + return "Rate limited" + case .parsing: + return "Parsing" + case .unsupported: + return "Unsupported" + case .unavailable: + return "Unavailable" + case .unknown: + return "Unknown" + } + } +} + +struct InspectionFailure: Codable, Equatable { + let kind: InspectionErrorKind + let message: String + let details: String? +} + +struct SectionProvenance: Codable, Equatable { + let source: String + let collectedAt: Date + let provider: String? + let resolver: String? + let resultSource: LookupResultSource +} + enum LookupResultSource: String, Codable { case live case cached @@ -129,19 +182,28 @@ struct DomainChangeSummary: Codable, Equatable { let message: String let severity: ChangeSeverity let generatedAt: Date + let observedFacts: [String] + let inferredConclusions: [String] + let contextNote: String? init( hasChanges: Bool, changedSections: [String], message: String, severity: ChangeSeverity, - generatedAt: Date + generatedAt: Date, + observedFacts: [String] = [], + inferredConclusions: [String] = [], + contextNote: String? = nil ) { self.hasChanges = hasChanges self.changedSections = changedSections self.message = message self.severity = severity self.generatedAt = generatedAt + self.observedFacts = observedFacts + self.inferredConclusions = inferredConclusions + self.contextNote = contextNote } init(from decoder: Decoder) throws { @@ -152,6 +214,9 @@ struct DomainChangeSummary: Codable, Equatable { severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) message = try container.decodeIfPresent(String.self, forKey: .message) ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) + observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? [] + inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? [] + contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote) } } @@ -709,6 +774,7 @@ struct HistoryEntry: Identifiable, Codable { let domain: String let timestamp: Date var trackedDomainID: UUID? + var note: String? let dnsSections: [DNSSection] let sslInfo: SSLCertificateInfo? let httpHeaders: [HTTPHeader] @@ -724,6 +790,18 @@ struct HistoryEntry: Identifiable, Codable { var hstsPreloaded: Bool? var availabilityResult: DomainAvailabilityResult? var suggestions: [DomainSuggestionResult] + var appVersion: String + var resultSource: LookupResultSource + var dataSources: [String] + var provenanceBySection: [LookupSectionKind: SectionProvenance] + var availabilityConfidence: ConfidenceLevel? + var ownershipConfidence: ConfidenceLevel? + var subdomainConfidence: ConfidenceLevel? + var emailSecurityConfidence: ConfidenceLevel? + var geolocationConfidence: ConfidenceLevel? + var errorDetails: [LookupSectionKind: InspectionFailure] + var isPartialSnapshot: Bool + var validationIssues: [String] var resolverDisplayName: String var resolverURLString: String var totalLookupDurationMs: Int? @@ -744,14 +822,21 @@ struct HistoryEntry: Identifiable, Codable { var subdomainsError: String? var portScanError: String? - init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], + init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, - suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, + suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", + resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], + provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], + availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, + subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, + geolocationConfidence: ConfidenceLevel? = nil, + errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, + validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, @@ -761,6 +846,7 @@ struct HistoryEntry: Identifiable, Codable { self.domain = domain self.timestamp = timestamp self.trackedDomainID = trackedDomainID + self.note = note self.dnsSections = dnsSections self.sslInfo = sslInfo self.httpHeaders = httpHeaders @@ -776,6 +862,18 @@ struct HistoryEntry: Identifiable, Codable { self.hstsPreloaded = hstsPreloaded self.availabilityResult = availabilityResult self.suggestions = suggestions + self.appVersion = appVersion + self.resultSource = resultSource + self.dataSources = dataSources + self.provenanceBySection = provenanceBySection + self.availabilityConfidence = availabilityConfidence + self.ownershipConfidence = ownershipConfidence + self.subdomainConfidence = subdomainConfidence + self.emailSecurityConfidence = emailSecurityConfidence + self.geolocationConfidence = geolocationConfidence + self.errorDetails = errorDetails + self.isPartialSnapshot = isPartialSnapshot + self.validationIssues = validationIssues self.resolverDisplayName = resolverDisplayName self.resolverURLString = resolverURLString self.totalLookupDurationMs = totalLookupDurationMs @@ -800,13 +898,14 @@ struct HistoryEntry: Identifiable, Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - domain = try container.decode(String.self, forKey: .domain) - timestamp = try container.decode(Date.self, forKey: .timestamp) + domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain" + timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) - dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) + note = try container.decodeIfPresent(String.self, forKey: .note) + dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? [] sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) - httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) - reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) + httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? [] + reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? [] ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts @@ -818,6 +917,18 @@ struct HistoryEntry: Identifiable, Codable { hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] + appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0" + resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot + dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? [] + provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:] + availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence) + ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence) + subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence) + emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence) + geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence) + errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:] + validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp) + isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) @@ -838,6 +949,17 @@ struct HistoryEntry: Identifiable, Codable { subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) } + + private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] { + var issues: [String] = [] + if domain == "unknown-domain" { + issues.append("Missing domain in stored snapshot") + } + if timestamp == .distantPast { + issues.append("Missing collection timestamp in stored snapshot") + } + return issues + } } // MARK: - Cloudflare DNS-over-HTTPS Response diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 0874956..d58061f 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -334,6 +334,7 @@ struct TrackedDomainDetailView: View { @State private var noteDraft = "" @State private var isEditingNote = false + @State private var showRerunOptions = false private var liveTrackedDomain: TrackedDomain { viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain @@ -365,7 +366,7 @@ struct TrackedDomainDetailView: View { } Button { - viewModel.rerunInspection(for: liveTrackedDomain) + showRerunOptions = true } label: { Label("Re-run Inspection", systemImage: "magnifyingglass") } @@ -394,7 +395,14 @@ struct TrackedDomainDetailView: View { if !latestDiffSections.isEmpty { Section("Latest Diff") { - DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false) + DomainDiffView( + title: "Latest Snapshot vs Previous", + sections: latestDiffSections, + contextNote: latestSnapshots.count >= 2 + ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) + : nil, + showsUnchanged: false + ) } .listRowBackground(Color.clear) } @@ -430,6 +438,19 @@ struct TrackedDomainDetailView: View { .onChange(of: viewModel.rerunNavigationToken) { _, _ in dismiss() } + .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) { + Button("Run with Current Settings") { + viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false) + } + if latestSnapshots.first != nil { + Button("Run with Snapshot Resolver") { + viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.") + } .sheet(isPresented: $isEditingNote) { NavigationStack { Form { |
