summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-22 09:47:24 -0500
committerChristian Cleberg <[email protected]>2026-04-22 09:47:24 -0500
commit7b41195620eadd54d25fa98dcd36735cba906ba3 (patch)
tree929a791a4c2fd3278f98c17c0ae5476940a01312 /DomainDig
parent36ea668d7fc9070770973b0bc329f4319bf43cab (diff)
downloaddomain-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.swift7
-rw-r--r--DomainDig/ContentView.swift226
-rw-r--r--DomainDig/DomainAvailabilityService.swift8
-rw-r--r--DomainDig/DomainDiffService.swift27
-rw-r--r--DomainDig/DomainViewModel.swift187
-rw-r--r--DomainDig/HistoryView.swift112
-rw-r--r--DomainDig/LookupRuntime.swift12
-rw-r--r--DomainDig/Models.swift138
-rw-r--r--DomainDig/WatchlistView.swift25
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 {