summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-24 12:38:38 -0500
committerChristian Cleberg <[email protected]>2026-04-24 12:38:38 -0500
commitd0b3b7a15b59266b4ae4262fdb0364245092c17c (patch)
tree5adb0df9963caf78aa98fa17adfb2c175291ded9 /DomainDig
parentb35f1b432fc24fbab1fa05593c9a0bca7e4d95a6 (diff)
downloaddomain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.tar.gz
domain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.tar.bz2
domain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.zip
feat(v3.7.0): add timeline and advanced diffing system
- introduce TimelineView for historical snapshots - allow comparison between any two snapshots - implement DiffService for structured domain diffs - improve diff visualization with clear change indicators - add navigation across changes - optimize history loading for performance - extend CLI and export to support timeline data
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift49
-rw-r--r--DomainDig/DiffService.swift563
-rw-r--r--DomainDig/DomainDiffService.swift555
-rw-r--r--DomainDig/DomainDig/DomainDebugLog.swift37
-rw-r--r--DomainDig/DomainMonitoringService.swift4
-rw-r--r--DomainDig/DomainViewModel.swift321
-rw-r--r--DomainDig/HistoryView.swift85
-rw-r--r--DomainDig/Models.swift114
-rw-r--r--DomainDig/PortScanService.swift2
-rw-r--r--DomainDig/RDAPService.swift10
-rw-r--r--DomainDig/ReachabilityService.swift2
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift11
-rw-r--r--DomainDig/TimelineView.swift224
-rw-r--r--DomainDig/WatchlistView.swift3
14 files changed, 1344 insertions, 636 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 0386211..76817cd 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -37,6 +37,7 @@ struct ContentView: View {
@State private var collapsedSections: Set<ResultSection> = [.network]
@State private var showingCurrentDomainWorkflowSheet = false
@State private var showingBatchWorkflowSheet = false
+ @State private var showingTimeline = false
var body: some View {
let _ = purchaseService.currentTier
@@ -83,7 +84,8 @@ struct ContentView: View {
title: "Latest Changes",
sections: viewModel.currentDiffSections,
contextNote: viewModel.currentChangeSummary?.contextNote,
- showsUnchanged: false
+ showsUnchanged: false,
+ highlightedSectionID: nil
)
.padding(.top, appDensity.metrics.sectionSpacing)
}
@@ -230,6 +232,11 @@ struct ContentView: View {
availableDomains: viewModel.batchResults.map(\.domain)
)
}
+ .sheet(isPresented: $showingTimeline) {
+ NavigationStack {
+ TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
+ }
+ }
}
private var inputSection: some View {
@@ -439,6 +446,11 @@ struct ContentView: View {
Button("Add to workflow") {
showingCurrentDomainWorkflowSheet = true
}
+ if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
+ Button("Open timeline") {
+ showingTimeline = true
+ }
+ }
if FeatureAccessService.hasAccess(to: .advancedExports) {
Button("Copy report JSON") {
guard let json = viewModel.exportJSONString() else { return }
@@ -1048,8 +1060,9 @@ struct DomainDiffView: View {
let sections: [DomainDiffSection]
let contextNote: String?
let showsUnchanged: Bool
+ let highlightedSectionID: String?
- @State private var collapsedSections = Set<UUID>()
+ @State private var collapsedSections = Set<String>()
@State private var showsLowSeverity = false
private var filteredSections: [DomainDiffSection] {
@@ -1064,7 +1077,7 @@ struct DomainDiffView: View {
}
return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
}
- return DomainDiffSection(title: section.title, items: items)
+ return DomainDiffSection(id: section.id, title: section.title, items: items)
}
.filter { !$0.items.isEmpty }
}
@@ -1104,7 +1117,7 @@ struct DomainDiffView: View {
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
- Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
+ Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(changeColor(for: item))
.padding(.horizontal, 8)
@@ -1154,9 +1167,19 @@ struct DomainDiffView: View {
}
}
}
+ .id(section.id)
+ .overlay {
+ if highlightedSectionID == section.id {
+ RoundedRectangle(cornerRadius: 12)
+ .stroke(Color.cyan.opacity(0.55), lineWidth: 1)
+ }
+ }
}
}
}
+ .onAppear {
+ collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
+ }
}
private func changeLabel(for changeType: DiffChangeType) -> String {
@@ -2460,6 +2483,24 @@ struct SettingsView: View {
}
}
+ Section("History") {
+ Picker(
+ "Auto-prune",
+ selection: Binding(
+ get: { viewModel.historyAutoPruneOption },
+ set: { viewModel.setHistoryAutoPruneOption($0) }
+ )
+ ) {
+ ForEach(HistoryAutoPruneOption.allCases) { option in
+ Text(option.title).tag(option)
+ }
+ }
+
+ Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ }
+
Section("Network") {
Picker("Resolver", selection: $resolverOption) {
ForEach(DNSResolverOption.allCases) { option in
diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift
new file mode 100644
index 0000000..948f470
--- /dev/null
+++ b/DomainDig/DiffService.swift
@@ -0,0 +1,563 @@
+import Foundation
+
+enum DiffChangeType: String, Codable {
+ case added
+ case removed
+ case changed
+ case unchanged
+
+ var marker: String {
+ switch self {
+ case .added:
+ return "+"
+ case .removed:
+ return "-"
+ case .changed:
+ return "~"
+ case .unchanged:
+ return "="
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .added:
+ return "Added"
+ case .removed:
+ return "Removed"
+ case .changed:
+ return "Changed"
+ case .unchanged:
+ return "Unchanged"
+ }
+ }
+}
+
+struct DiffItem: Identifiable, Equatable, Codable {
+ let id: String
+ let label: String
+ let changeType: DiffChangeType
+ let oldValue: String?
+ let newValue: String?
+ let severity: ChangeSeverity
+
+ init(
+ id: String,
+ label: String,
+ changeType: DiffChangeType,
+ oldValue: String?,
+ newValue: String?,
+ severity: ChangeSeverity
+ ) {
+ self.id = id
+ self.label = label
+ self.changeType = changeType
+ self.oldValue = oldValue
+ self.newValue = newValue
+ self.severity = severity
+ }
+
+ var hasChanges: Bool {
+ changeType != .unchanged
+ }
+}
+
+struct DiffSection: Identifiable, Equatable, Codable {
+ let id: String
+ let title: String
+ let items: [DiffItem]
+
+ var hasChanges: Bool {
+ items.contains(where: \.hasChanges)
+ }
+
+ var severity: ChangeSeverity {
+ items.map(\.severity).max() ?? .low
+ }
+
+ var changeCount: Int {
+ items.filter(\.hasChanges).count
+ }
+}
+
+struct DomainDiff: Identifiable, Equatable, Codable {
+ let domain: String
+ let fromTimestamp: Date
+ let toTimestamp: Date
+ let sections: [DiffSection]
+ let changedSectionIDs: [String]
+ let changedSectionTitles: [String]
+ let contextNote: String?
+
+ var id: String {
+ "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
+ }
+
+ var changeCount: Int {
+ sections.reduce(0) { $0 + $1.changeCount }
+ }
+
+ var severity: ChangeSeverity {
+ sections.map(\.severity).max() ?? .low
+ }
+}
+
+typealias DomainDiffItem = DiffItem
+typealias DomainDiffSection = DiffSection
+
+enum DiffService {
+ static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
+ let sections = [
+ availabilitySection(from: oldReport, to: newReport),
+ ownershipSection(from: oldReport, to: newReport),
+ dnsSection(from: oldReport, to: newReport),
+ webSection(from: oldReport, to: newReport),
+ emailSection(from: oldReport, to: newReport),
+ networkSection(from: oldReport, to: newReport),
+ subdomainsSection(from: oldReport, to: newReport),
+ riskSection(from: oldReport, to: newReport)
+ ]
+
+ let changedSections = sections.filter(\.hasChanges)
+ return DomainDiff(
+ domain: newReport.domain,
+ fromTimestamp: oldReport.timestamp,
+ toTimestamp: newReport.timestamp,
+ sections: sections,
+ changedSectionIDs: changedSections.map(\.id),
+ changedSectionTitles: changedSections.map(\.title),
+ contextNote: comparisonContextNote(from: oldReport, to: newReport)
+ )
+ }
+
+ static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
+ let builder = DomainReportBuilder()
+ let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
+ let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
+ return compare(from: oldReport, to: newReport)
+ }
+
+ static func summary(
+ from oldSnapshot: LookupSnapshot,
+ to newSnapshot: LookupSnapshot,
+ generatedAt: Date = Date(),
+ riskAssessment: DomainRiskAssessment? = nil,
+ insights: [String]? = nil
+ ) -> DomainChangeSummary {
+ let diff = compare(from: oldSnapshot, to: newSnapshot)
+ let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
+ let highlights = diff.changedSectionTitles
+ let severity = changedItems.map(\.severity).max() ?? .low
+ let message = summaryMessage(from: highlights, changeCount: changedItems.count)
+ let observedFacts = changedItems.prefix(4).map { item in
+ "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
+ }
+
+ let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
+ let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
+ let currentInsights = insights ?? analysis.insights
+ let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
+ let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
+ let impactClassification = DomainInsightEngine.impactClassification(
+ severity: severity,
+ riskDelta: riskScoreDelta,
+ changedSections: highlights
+ )
+
+ return DomainChangeSummary(
+ hasChanges: !changedItems.isEmpty,
+ changedSections: highlights,
+ message: message,
+ severity: severity,
+ impactClassification: impactClassification,
+ generatedAt: generatedAt,
+ observedFacts: observedFacts,
+ inferredConclusions: highlights.isEmpty ? [] : [message],
+ contextNote: diff.contextNote,
+ riskAssessment: currentRiskAssessment,
+ insights: currentInsights,
+ riskScoreDelta: riskScoreDelta
+ )
+ }
+
+ static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
+ var notes: [String] = []
+ if oldReport.resolverURLString != newReport.resolverURLString {
+ notes.append("Compared snapshots used different DNS resolvers.")
+ }
+ if oldReport.resultSource != newReport.resultSource {
+ notes.append("Compared snapshots came from different collection modes.")
+ }
+ return notes.isEmpty ? nil : notes.joined(separator: " ")
+ }
+
+ static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
+ comparisonContextNote(
+ from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
+ to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
+ )
+ }
+
+ static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+ guard let days = snapshot.sslInfo?.daysUntilExpiry else {
+ return .none
+ }
+ if days < 14 {
+ return .critical
+ }
+ if days < 30 {
+ return .warning
+ }
+ return .none
+ }
+
+ private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "availability",
+ title: "Domain / Availability",
+ items: [
+ compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
+ compare(
+ id: "availability",
+ label: "Availability",
+ oldValue: availabilityLabel(oldReport.availability),
+ newValue: availabilityLabel(newReport.availability),
+ severity: .high
+ ),
+ compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
+ compare(
+ id: "tls-status",
+ label: "TLS Status",
+ oldValue: oldReport.web.tlsStatus,
+ newValue: newReport.web.tlsStatus,
+ severity: .medium
+ )
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "ownership",
+ title: "Ownership",
+ items: [
+ compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
+ compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
+ compare(
+ id: "ownership-created",
+ label: "Registration Date",
+ oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
+ newValue: ownershipDateLabel(newReport.ownership?.createdDate),
+ severity: .low
+ ),
+ compare(
+ id: "ownership-expires",
+ label: "Expiration Date",
+ oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
+ newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
+ severity: .medium
+ ),
+ compare(
+ id: "ownership-status",
+ label: "Status",
+ oldValue: joined(oldReport.ownership?.status),
+ newValue: joined(newReport.ownership?.status),
+ severity: .low
+ ),
+ compare(
+ id: "ownership-nameservers",
+ label: "Nameservers",
+ oldValue: joined(oldReport.ownership?.nameservers),
+ newValue: joined(newReport.ownership?.nameservers),
+ severity: .medium
+ ),
+ compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
+ let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
+ let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
+
+ var items: [DiffItem] = [
+ compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
+ compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
+ ].compactMap { $0 }
+
+ for type in recordTypes {
+ items.append(
+ compare(
+ id: "dns-\(type.rawValue.lowercased())-records",
+ label: "\(type.rawValue) Records",
+ oldValue: normalizedRecordValues(for: oldSections[type]),
+ newValue: normalizedRecordValues(for: newSections[type]),
+ severity: type == .A || type == .NS ? .high : .medium
+ ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
+ )
+ if let ttlChange = compare(
+ id: "dns-\(type.rawValue.lowercased())-ttl",
+ label: "\(type.rawValue) TTL",
+ oldValue: normalizedTTLValues(for: oldSections[type]),
+ newValue: normalizedTTLValues(for: newSections[type]),
+ severity: .low
+ ) {
+ items.append(ttlChange)
+ }
+ }
+
+ return DiffSection(
+ id: "dns",
+ title: "DNS",
+ items: items.filter { !$0.id.isEmpty }
+ )
+ }
+
+ private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "web",
+ title: "Web",
+ items: [
+ compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
+ compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
+ compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
+ compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
+ compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
+ compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
+ compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "email",
+ title: "Email Security",
+ items: [
+ compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
+ compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
+ compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
+ compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
+ compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
+ compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
+ compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "network",
+ title: "Network",
+ items: [
+ compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
+ compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
+ compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high),
+ compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "subdomains",
+ title: "Subdomains",
+ items: [
+ compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
+ compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
+ compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
+ DiffSection(
+ id: "risk",
+ title: "Risk / Insights",
+ items: [
+ compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
+ compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
+ compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
+ compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func compare(
+ id: String,
+ label: String,
+ oldValue: String?,
+ newValue: String?,
+ severity: ChangeSeverity
+ ) -> DiffItem? {
+ let oldValue = normalized(oldValue)
+ let newValue = normalized(newValue)
+
+ guard oldValue != nil || newValue != nil else {
+ return nil
+ }
+
+ let changeType: DiffChangeType
+ switch (oldValue?.lowercased(), newValue?.lowercased()) {
+ case let (old?, new?) where old == new:
+ changeType = .unchanged
+ case (nil, _?):
+ changeType = .added
+ case (_?, nil):
+ changeType = .removed
+ default:
+ changeType = .changed
+ }
+
+ return DiffItem(
+ id: id,
+ label: label,
+ changeType: changeType,
+ oldValue: oldValue,
+ newValue: newValue,
+ severity: severity
+ )
+ }
+
+ static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
+ guard !sectionTitles.isEmpty else {
+ return "No meaningful changes"
+ }
+ if sectionTitles.count == 1 {
+ return "\(sectionTitles[0]) changed"
+ }
+ return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
+ }
+
+ private static func normalized(_ value: String?) -> String? {
+ guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+ return value
+ }
+
+ private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
+ switch status {
+ case .available:
+ return "Available"
+ case .registered:
+ return "Registered"
+ case .unknown:
+ return "Unknown"
+ }
+ }
+
+ private static func ownershipDateLabel(_ date: Date?) -> String? {
+ date?.formatted(date: .abbreviated, time: .omitted)
+ }
+
+ private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
+ guard let certificate else { return nil }
+ return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
+ }
+
+ private static func joined(_ values: [String]?) -> String? {
+ guard let values else { return nil }
+ let normalizedValues = values
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ .sorted()
+ return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
+ }
+
+ private static func normalizedRecordValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map(\.value)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ", ")
+ }
+
+ private static func normalizedTTLValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map { "\($0.value.lowercased()):\($0.ttl)" }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ", ")
+ }
+
+ private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
+ let values = headers
+ .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: " | ")
+ }
+
+ private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
+ let values = redirects.map { "\($0.statusCode) \($0.url)" }
+ return values.isEmpty ? nil : values.joined(separator: " -> ")
+ }
+
+ private static func portScanSummary(_ results: [PortScanResult]) -> String? {
+ let values = results
+ .sorted { $0.port < $1.port }
+ .map { "\($0.port):\($0.open ? "open" : "closed")" }
+ return values.isEmpty ? nil : values.joined(separator: ", ")
+ }
+
+ private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
+ joined(groups.map { "\($0.label): \($0.subdomains.count)" })
+ }
+
+ private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
+ guard let record else { return nil }
+ if record.found {
+ return record.value ?? "Present"
+ }
+ return "Missing"
+ }
+
+ private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
+ guard let result else { return nil }
+ guard result.txtFound else { return "Missing" }
+ return result.policyMode ?? "Present"
+ }
+
+ private static func dnssecLabel(_ value: Bool?) -> String? {
+ switch value {
+ case true:
+ return "Signed"
+ case false:
+ return "Unsigned"
+ case nil:
+ return nil
+ }
+ }
+}
+
+enum DomainDiffService {
+ static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
+ DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
+ }
+
+ static func summary(
+ from oldSnapshot: LookupSnapshot,
+ to newSnapshot: LookupSnapshot,
+ generatedAt: Date = Date(),
+ riskAssessment: DomainRiskAssessment? = nil,
+ insights: [String]? = nil
+ ) -> DomainChangeSummary {
+ DiffService.summary(
+ from: oldSnapshot,
+ to: newSnapshot,
+ generatedAt: generatedAt,
+ riskAssessment: riskAssessment,
+ insights: insights
+ )
+ }
+
+ static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
+ DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
+ }
+
+ static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+ DiffService.certificateWarningLevel(for: snapshot)
+ }
+}
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
deleted file mode 100644
index 0d8795a..0000000
--- a/DomainDig/DomainDiffService.swift
+++ /dev/null
@@ -1,555 +0,0 @@
-import Foundation
-
-enum DiffChangeType: String, Codable {
- case added
- case removed
- case changed
- case unchanged
-}
-
-struct DomainDiffItem: Identifiable, Equatable {
- let id = UUID()
- let label: String
- let changeType: DiffChangeType
- let oldValue: String?
- let newValue: String?
- let severity: ChangeSeverity
-
- var hasChanges: Bool {
- changeType != .unchanged
- }
-
- var isMeaningful: Bool {
- hasChanges && severity >= .medium
- }
-}
-
-struct DomainDiffSection: Identifiable, Equatable {
- let id = UUID()
- let title: String
- let items: [DomainDiffItem]
-
- var hasChanges: Bool {
- items.contains(where: \.hasChanges)
- }
-
- var severity: ChangeSeverity {
- items.map(\.severity).max() ?? .low
- }
-}
-
-enum DomainDiffService {
- static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
- [
- availabilitySection(from: oldSnapshot, to: newSnapshot),
- primaryIPSection(from: oldSnapshot, to: newSnapshot),
- ownershipSection(from: oldSnapshot, to: newSnapshot),
- dnsSection(from: oldSnapshot, to: newSnapshot),
- redirectSection(from: oldSnapshot, to: newSnapshot),
- tlsSection(from: oldSnapshot, to: newSnapshot),
- httpSection(from: oldSnapshot, to: newSnapshot),
- emailSection(from: oldSnapshot, to: newSnapshot),
- subdomainSection(from: oldSnapshot, to: newSnapshot)
- ]
- .filter { !$0.items.isEmpty }
- }
-
- static func summary(
- from oldSnapshot: LookupSnapshot,
- to newSnapshot: LookupSnapshot,
- generatedAt: Date = Date(),
- riskAssessment: DomainRiskAssessment? = nil,
- insights: [String]? = nil
- ) -> DomainChangeSummary {
- let sections = diff(from: oldSnapshot, to: newSnapshot)
- let allChangedItems = sections
- .flatMap(\.items)
- .filter(\.hasChanges)
-
- 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)
- let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
- let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment
- let currentInsights = insights ?? newAnalysis.insights
- let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment
- let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score
- let impactClassification = DomainInsightEngine.impactClassification(
- severity: severity,
- riskDelta: riskScoreDelta,
- changedSections: highlights
- )
-
- return DomainChangeSummary(
- hasChanges: !allChangedItems.isEmpty,
- changedSections: highlights,
- message: message,
- severity: severity,
- impactClassification: impactClassification,
- generatedAt: generatedAt,
- observedFacts: observedFacts,
- inferredConclusions: inferredConclusions,
- contextNote: contextNote,
- riskAssessment: currentRiskAssessment,
- insights: currentInsights,
- riskScoreDelta: riskScoreDelta
- )
- }
-
- 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
- }
- if days < 14 {
- return .critical
- }
- if days < 30 {
- return .warning
- }
- return .none
- }
-
- private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Availability",
- items: [
- compare(
- label: "Availability",
- oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
- newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
- severity: .high
- )
- ].compactMap { $0 }
- )
- }
-
- private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Primary IP",
- items: [
- compare(
- label: "Primary IP",
- oldValue: primaryIP(from: oldSnapshot),
- newValue: primaryIP(from: newSnapshot),
- severity: .high
- )
- ].compactMap { $0 }
- )
- }
-
- private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
- let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
- let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
-
- var items: [DomainDiffItem] = []
- for type in types {
- let oldSection = oldSections[type]
- let newSection = newSections[type]
-
- if let recordChange = compare(
- label: "\(type.rawValue) Records",
- oldValue: normalizedRecordValues(for: oldSection),
- newValue: normalizedRecordValues(for: newSection),
- severity: .medium
- ) {
- items.append(recordChange)
- }
-
- if let ttlChange = compare(
- label: "\(type.rawValue) TTL",
- oldValue: normalizedTTLValues(for: oldSection),
- newValue: normalizedTTLValues(for: newSection),
- severity: .low
- ), let oldSection, let newSection,
- normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
- items.append(ttlChange)
- }
- }
-
- return DomainDiffSection(title: "DNS", items: items)
- }
-
- private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Ownership",
- items: [
- compare(
- label: "Registrar",
- oldValue: normalized(oldSnapshot.ownership?.registrar),
- newValue: normalized(newSnapshot.ownership?.registrar),
- severity: .high
- ),
- compare(
- label: "Registration Date",
- oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
- newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
- severity: .low
- ),
- compare(
- label: "Expiration Date",
- oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
- newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
- severity: .low
- ),
- compare(
- label: "Ownership Status",
- oldValue: ownershipList(oldSnapshot.ownership?.status),
- newValue: ownershipList(newSnapshot.ownership?.status),
- severity: .low
- ),
- compare(
- label: "Nameservers",
- oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
- newValue: ownershipList(newSnapshot.ownership?.nameservers),
- severity: .medium
- ),
- compare(
- label: "Abuse Contact",
- oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
- newValue: normalized(newSnapshot.ownership?.abuseEmail),
- severity: .low
- )
- ].compactMap { $0 }
- )
- }
-
- private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Redirect",
- items: [
- compare(
- label: "Redirect Target",
- oldValue: finalRedirectURL(from: oldSnapshot),
- newValue: finalRedirectURL(from: newSnapshot),
- severity: .high
- )
- ].compactMap { $0 }
- )
- }
-
- private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- var items: [DomainDiffItem] = []
-
- if let issuerChange = compare(
- label: "TLS Issuer",
- oldValue: normalized(oldSnapshot.sslInfo?.issuer),
- newValue: normalized(newSnapshot.sslInfo?.issuer),
- severity: .medium
- ) {
- items.append(issuerChange)
- }
-
- if let expiryChange = compare(
- label: "TLS Expiration",
- oldValue: expirationLabel(oldSnapshot.sslInfo),
- newValue: expirationLabel(newSnapshot.sslInfo),
- severity: .medium
- ) {
- items.append(expiryChange)
- }
-
- let oldWarning = certificateWarningLevel(for: oldSnapshot)
- let newWarning = certificateWarningLevel(for: newSnapshot)
- if oldWarning != newWarning, newWarning != .none {
- let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
- items.append(
- DomainDiffItem(
- label: "Certificate Warning",
- changeType: .changed,
- oldValue: oldWarning.title,
- newValue: "Certificate expires in \(days) days",
- severity: newWarning == .critical ? .high : .medium
- )
- )
- }
-
- return DomainDiffSection(title: "TLS", items: items)
- }
-
- private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- var items: [DomainDiffItem] = []
-
- if let statusChange = compare(
- label: "HTTP Status",
- oldValue: httpStatusSummary(from: oldSnapshot),
- newValue: httpStatusSummary(from: newSnapshot),
- severity: .medium
- ) {
- items.append(statusChange)
- }
-
- if let gradeChange = compare(
- label: "Security Grade",
- oldValue: normalized(oldSnapshot.httpSecurityGrade),
- newValue: normalized(newSnapshot.httpSecurityGrade),
- severity: .low
- ) {
- items.append(gradeChange)
- }
-
- if let headerChange = compare(
- label: "Headers",
- oldValue: normalizedHeaders(from: oldSnapshot),
- newValue: normalizedHeaders(from: newSnapshot),
- severity: .low
- ) {
- items.append(headerChange)
- }
-
- return DomainDiffSection(title: "HTTP", items: items)
- }
-
- private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Email Security",
- items: [
- compare(
- label: "Email Security",
- oldValue: normalized(emailSummary(from: oldSnapshot)),
- newValue: normalized(emailSummary(from: newSnapshot)),
- severity: .medium
- )
- ].compactMap { $0 }
- )
- }
-
- private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- DomainDiffSection(
- title: "Subdomains",
- items: [
- compare(
- label: "Passive Subdomains",
- oldValue: subdomainList(from: oldSnapshot),
- newValue: subdomainList(from: newSnapshot),
- severity: .low
- )
- ].compactMap { $0 }
- )
- }
-
- private static func compare(
- label: String,
- oldValue: String?,
- newValue: String?,
- severity: ChangeSeverity
- ) -> DomainDiffItem? {
- let oldValue = normalized(oldValue)
- let newValue = normalized(newValue)
- let normalizedOldValue = comparisonValue(oldValue)
- let normalizedNewValue = comparisonValue(newValue)
-
- guard oldValue != nil || newValue != nil else {
- return nil
- }
-
- let changeType: DiffChangeType
- switch (normalizedOldValue, normalizedNewValue) {
- case let (old?, new?) where old == new:
- changeType = .unchanged
- case (nil, _?):
- changeType = .added
- case (_?, nil):
- changeType = .removed
- default:
- changeType = .changed
- }
-
- return DomainDiffItem(
- label: label,
- changeType: changeType,
- oldValue: oldValue,
- newValue: newValue,
- severity: severity
- )
- }
-
- private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
- var highlights: [String] = []
-
- let labels = Set(items.map(\.label))
- if labels.contains("Availability") {
- highlights.append("Availability changed")
- }
- if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
- highlights.append("IP changed")
- highlights.append("DNS changed")
- return highlights
- }
- if labels.contains("Primary IP") {
- highlights.append("IP changed")
- }
- if labels.contains("Redirect Target") {
- highlights.append("Redirect target changed")
- }
- if labels.contains("Registrar") {
- highlights.append("Registrar changed")
- } else if labels.contains("Nameservers") {
- highlights.append("Nameservers changed")
- } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
- highlights.append("Ownership metadata changed")
- }
- if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
- let message = certificateItem.newValue {
- highlights.append(message)
- } else if labels.contains("TLS Issuer") {
- highlights.append("TLS issuer changed")
- } else if labels.contains("TLS Expiration") {
- highlights.append("Certificate expiration changed")
- }
- if labels.contains(where: { $0.hasSuffix("Records") }) {
- highlights.append("DNS changed")
- }
- if labels.contains("HTTP Status") {
- highlights.append("HTTP status changed")
- }
- if labels.contains("Email Security") {
- highlights.append("Email security changed")
- }
- if labels.contains("Passive Subdomains") {
- highlights.append("Subdomains changed")
- }
-
- var deduplicated: [String] = []
- for highlight in highlights where !deduplicated.contains(highlight) {
- deduplicated.append(highlight)
- }
- return deduplicated
- }
-
- private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
- guard !items.isEmpty, !highlights.isEmpty else {
- return "No meaningful changes"
- }
-
- if highlights.count == 1 {
- return highlights[0]
- }
-
- 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
- }
- return value
- }
-
- private static func comparisonValue(_ value: String?) -> String? {
- value?.lowercased()
- }
-
- private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
- switch status {
- case .available:
- return "available"
- case .registered:
- return "registered"
- case .unknown:
- return "unknown"
- case .none:
- return nil
- }
- }
-
- private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
- snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
- }
-
- private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
- snapshot.redirectChain.last?.url
- }
-
- private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
- guard let sslInfo else { return nil }
- return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
- }
-
- private static func ownershipDateLabel(_ date: Date?) -> String? {
- date?.formatted(date: .abbreviated, time: .omitted)
- }
-
- private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
- if let httpStatusCode = snapshot.httpStatusCode {
- return "\(httpStatusCode)"
- }
- return snapshot.httpHeadersError
- }
-
- private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
- if let emailSecurity = snapshot.emailSecurity {
- return [
- "spf:\(emailSecurity.spf.found)",
- "dmarc:\(emailSecurity.dmarc.found)",
- "dkim:\(emailSecurity.dkim.found)",
- "bimi:\(emailSecurity.bimi.found)",
- "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
- ].joined(separator: "|")
- }
- return snapshot.emailSecurityError
- }
-
- private static func normalizedRecordValues(for section: DNSSection?) -> String? {
- guard let section else { return nil }
- let values = (section.records + section.wildcardRecords)
- .map(\.value)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
- .sorted()
- return values.isEmpty ? nil : values.joined(separator: ",")
- }
-
- private static func normalizedTTLValues(for section: DNSSection?) -> String? {
- guard let section else { return nil }
- let values = (section.records + section.wildcardRecords)
- .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
- .sorted()
- return values.isEmpty ? nil : values.joined(separator: ",")
- }
-
- private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
- let headers = snapshot.httpHeaders
- .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
- .sorted()
- return headers.isEmpty ? nil : headers.joined(separator: "|")
- }
-
- private static func ownershipList(_ values: [String]?) -> String? {
- guard let values else { return nil }
- let normalizedValues = values
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
- .filter { !$0.isEmpty }
- .sorted()
- return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
- }
-
- private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
- let values = snapshot.subdomains
- .map(\.hostname)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
- .sorted()
- return values.isEmpty ? nil : values.joined(separator: ",")
- }
-}
diff --git a/DomainDig/DomainDig/DomainDebugLog.swift b/DomainDig/DomainDig/DomainDebugLog.swift
new file mode 100644
index 0000000..12a6cc5
--- /dev/null
+++ b/DomainDig/DomainDig/DomainDebugLog.swift
@@ -0,0 +1,37 @@
+import Foundation
+import os
+
+enum DomainDebugLog {
+ static let enabled = true
+ private static let logger = Logger(subsystem: "co.zerolabs.domain-dig", category: "Debug")
+
+ static func debug(_ message: String) {
+ guard enabled else { return }
+ logger.debug("\(message, privacy: .public)")
+ }
+
+ static func error(_ message: String) {
+ guard enabled else { return }
+ logger.error("\(message, privacy: .public)")
+ }
+
+ static func signpostStart(_ scope: String, domain: String? = nil) -> CFAbsoluteTime {
+ let start = CFAbsoluteTimeGetCurrent()
+ if let domain {
+ debug("[START] \(scope) domain=\(domain)")
+ } else {
+ debug("[START] \(scope)")
+ }
+ return start
+ }
+
+ static func signpostEnd(_ scope: String, start: CFAbsoluteTime, domain: String? = nil, extra: String? = nil) {
+ let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
+ let suffix = extra.map { " \($0)" } ?? ""
+ if let domain {
+ debug("[END] \(scope) domain=\(domain) elapsedMs=\(elapsedMs)\(suffix)")
+ } else {
+ debug("[END] \(scope) elapsedMs=\(elapsedMs)\(suffix)")
+ }
+ }
+}
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 5128c0b..56dfe3f 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -509,6 +509,10 @@ final class DomainMonitoringService {
isPartialSnapshot: previousSnapshot.isPartialSnapshot,
validationIssues: previousSnapshot.validationIssues,
totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+ snapshotIndex: previousSnapshot.snapshotIndex,
+ previousSnapshotID: previousSnapshot.previousSnapshotID,
+ changeCount: previousSnapshot.changeCount,
+ severitySummary: previousSnapshot.severitySummary,
dnsSections: previousSnapshot.dnsSections,
dnsError: previousSnapshot.dnsError,
availabilityResult: previousSnapshot.availabilityResult,
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 02e09f7..656529c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -208,6 +208,10 @@ final class DomainViewModel {
private var lastBatchStartedAt: Date?
private var activeWorkflowRunID: UUID?
private var activeWorkflowRunName: String?
+ private var historyPersistenceSuspended = false
+ private var trackedDomainsPersistenceSuspended = false
+ private var historyPersistenceDirty = false
+ private var trackedDomainsPersistenceDirty = false
private let reportBuilder = DomainReportBuilder()
private let inspectionService = DomainInspectionService()
private(set) var currentResultSource: LookupResultSource = .live
@@ -237,6 +241,8 @@ final class DomainViewModel {
var historyDateFilter: HistoryDateFilter = .all
var historyChangeFilter: ChangeFilterOption = .all
var historySortOption: HistorySortOption = .newest
+ var timelineGrouping: TimelineGroupingOption = .relativeDay
+ var timelineDomainFilter = ""
var watchlistSearchText = ""
var watchlistFilter: WatchlistFilterOption = .all
var watchlistSortOption: WatchlistSortOption = .pinned
@@ -249,6 +255,12 @@ final class DomainViewModel {
var portabilityStatusMessage: String?
var upgradePrompt: UpgradePromptContext?
var isPaywallPresented = false
+ var selectedSnapshotIDs = Set<UUID>()
+ var activeDomainDiff: DomainDiff?
+ var activeDiffChangeIndex = 0
+
+ private static let historyAutoPruneKey = "historyAutoPrune"
+ var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption()
var trimmedDomain: String {
domain
@@ -365,6 +377,15 @@ final class DomainViewModel {
return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
}
+ var timelineDomains: [String] {
+ let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
+ let domains = history.map(\.domain)
+ let filtered = query.isEmpty
+ ? domains
+ : domains.filter { $0.localizedCaseInsensitiveContains(query) }
+ return Array(Set(filtered)).sorted()
+ }
+
var batchProgressLabel: String {
guard batchTotalCount > 0 else { return "No active batch" }
if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
@@ -441,6 +462,10 @@ final class DomainViewModel {
isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false,
validationIssues: currentHistoryEntry?.validationIssues ?? [],
totalLookupDurationMs: lastLookupDurationMs,
+ snapshotIndex: currentHistoryEntry?.snapshotIndex,
+ previousSnapshotID: currentHistoryEntry?.previousSnapshotID,
+ changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0,
+ severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity,
dnsSections: dnsSections,
dnsError: dnsError,
availabilityResult: availabilityResult,
@@ -1372,6 +1397,22 @@ final class DomainViewModel {
)
}
+ func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
+ DomainReportExporter.timelineText(
+ for: timelineReports(for: domain),
+ domain: domain,
+ includeDiffSummary: includeDiffSummary
+ )
+ }
+
+ func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
+ try? DomainReportExporter.timelineData(
+ for: timelineReports(for: domain),
+ domain: domain,
+ includeDiffSummary: includeDiffSummary
+ )
+ }
+
func exportFullBackupData() -> Data? {
try? DomainDataPortabilityService.backupData()
}
@@ -1461,20 +1502,25 @@ final class DomainViewModel {
}
private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
+ let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
let previous = previousSnapshot(
for: domain,
trackedDomainID: currentTrackedDomain?.id,
replacingLatest: false
)
let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
+ DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)")
guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
+ let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain)
applySnapshot(snapshot)
+ DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain)
lastLookupDurationMs = snapshot.totalLookupDurationMs
refreshingTrackedDomainID = nil
if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
+ DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)")
await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
}
@@ -1483,8 +1529,11 @@ final class DomainViewModel {
return history.first(where: { $0.id == snapshot.historyEntryID })
}
- let entry = saveHistoryEntry(replaceLatest: false)
+ let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
+ let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
+ DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
await refreshUsageCredits()
+ DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
return entry
}
@@ -1496,6 +1545,7 @@ final class DomainViewModel {
currentStatusMessage = snapshot.statusMessage
currentDiffSections = []
ownershipDiff = []
+ let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain)
currentReport = reportBuilder.build(
from: snapshot,
previousSnapshot: previousSnapshot(
@@ -1504,6 +1554,7 @@ final class DomainViewModel {
replacingLatest: false
)
)
+ DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
dnsSections = snapshot.dnsSections
@@ -1596,6 +1647,10 @@ final class DomainViewModel {
isPartialSnapshot: previousSnapshot.isPartialSnapshot,
validationIssues: previousSnapshot.validationIssues,
totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+ snapshotIndex: previousSnapshot.snapshotIndex,
+ previousSnapshotID: previousSnapshot.previousSnapshotID,
+ changeCount: previousSnapshot.changeCount,
+ severitySummary: previousSnapshot.severitySummary,
dnsSections: previousSnapshot.dnsSections,
dnsError: previousSnapshot.dnsError,
availabilityResult: previousSnapshot.availabilityResult,
@@ -1956,32 +2011,61 @@ final class DomainViewModel {
}
@discardableResult
- private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
+ private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? {
guard !searchedDomain.isEmpty else { return nil }
- return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
+ return saveHistoryEntry(
+ from: currentSnapshot,
+ replaceLatest: replaceLatest,
+ updateCurrentState: true,
+ reuseCurrentAnalysis: reuseCurrentAnalysis
+ )
}
@discardableResult
- private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
+ private func saveHistoryEntry(
+ from snapshot: LookupSnapshot,
+ replaceLatest: Bool,
+ updateCurrentState: Bool,
+ reuseCurrentAnalysis: Bool = false
+ ) -> HistoryEntry? {
let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
- let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
- let changeSummary = previousSnapshot.map {
- DomainDiffService.summary(
- from: $0,
- to: snapshot,
- generatedAt: snapshot.timestamp,
- riskAssessment: analysis.riskAssessment,
- insights: analysis.insights
- )
- }
- let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+ let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
+ let changeSummary = reuseCurrentAnalysis
+ ? currentChangeSummary ?? snapshot.changeSummary
+ : previousSnapshot.map {
+ DiffService.summary(
+ from: $0,
+ to: snapshot,
+ generatedAt: snapshot.timestamp,
+ riskAssessment: analysis?.riskAssessment,
+ insights: analysis?.insights
+ )
+ }
+ let domainDiff = reuseCurrentAnalysis
+ ? previousSnapshot.map {
+ DomainDiff(
+ domain: snapshot.domain,
+ fromTimestamp: $0.timestamp,
+ toTimestamp: snapshot.timestamp,
+ sections: currentDiffSections,
+ changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id),
+ changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title),
+ contextNote: currentChangeSummary?.contextNote
+ )
+ }
+ : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) }
+ let diffSections = domainDiff?.sections ?? currentDiffSections
+ let previousSnapshotID = previousSnapshot?.historyEntryID
+ let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID)
if updateCurrentState {
currentChangeSummary = changeSummary
currentDiffSections = diffSections
ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
- currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
+ if !reuseCurrentAnalysis {
+ currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
+ }
}
let entry = HistoryEntry(
@@ -2029,6 +2113,10 @@ final class DomainViewModel {
emailSecuritySummary: Self.emailSummary(from: snapshot),
httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
changeSummary: changeSummary,
+ snapshotIndex: nextSnapshotIndex,
+ previousSnapshotID: previousSnapshotID,
+ changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
+ severitySummary: changeSummary?.severity,
sslError: snapshot.sslError,
httpHeadersError: snapshot.httpHeadersError,
reachabilityError: snapshot.reachabilityError,
@@ -2053,9 +2141,7 @@ final class DomainViewModel {
history[0] = entry
} else {
history.insert(entry, at: 0)
- if history.count > Self.maxHistory {
- history = Array(history.prefix(Self.maxHistory))
- }
+ trimHistoryToLimit()
}
updateTrackedDomainSnapshotMetadata(
@@ -2074,8 +2160,21 @@ final class DomainViewModel {
}
private func persistHistory() {
+ if historyPersistenceSuspended {
+ historyPersistenceDirty = true
+ return
+ }
+ let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
DomainDataPortabilityService.saveHistoryEntries(history)
refreshDataLifecycleSummary()
+ DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
+ }
+
+ func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
+ historyAutoPruneOption = option
+ UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
+ trimHistoryToLimit()
+ persistHistory()
}
func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -2085,11 +2184,56 @@ final class DomainViewModel {
}
private func persistTrackedDomains() {
+ if trackedDomainsPersistenceSuspended {
+ trackedDomainsPersistenceDirty = true
+ return
+ }
DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
CloudSyncService.shared.scheduleSyncIfNeeded()
refreshDataLifecycleSummary()
}
+ private func beginBulkPersistenceDeferral() {
+ historyPersistenceSuspended = true
+ trackedDomainsPersistenceSuspended = true
+ historyPersistenceDirty = false
+ trackedDomainsPersistenceDirty = false
+ }
+
+ private func endBulkPersistenceDeferral() {
+ historyPersistenceSuspended = false
+ trackedDomainsPersistenceSuspended = false
+
+ if trackedDomainsPersistenceDirty {
+ trackedDomainsPersistenceDirty = false
+ DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
+ CloudSyncService.shared.scheduleSyncIfNeeded()
+ }
+
+ if historyPersistenceDirty {
+ historyPersistenceDirty = false
+ DomainDataPortabilityService.saveHistoryEntries(history)
+ }
+
+ refreshDataLifecycleSummary()
+ }
+
+ private func trimHistoryToLimit() {
+ let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
+ history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
+ }
+
+ private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
+ let siblings = history.filter { entry in
+ if let trackedDomainID {
+ return entry.trackedDomainID == trackedDomainID
+ }
+ return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
+ }
+ let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
+ return existingMax + 1
+ }
+
private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
MonitoringStorage.saveSettings(monitoringSettings)
@@ -2328,6 +2472,7 @@ final class DomainViewModel {
private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
var nextIndex = 0
+ beginBulkPersistenceDeferral()
await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
for _ in 0..<concurrencyLimit {
@@ -2348,6 +2493,7 @@ final class DomainViewModel {
}
}
+ endBulkPersistenceDeferral()
finishBatchLookup(source: source)
}
@@ -2396,7 +2542,7 @@ final class DomainViewModel {
}
}
let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
- let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
+ let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
let quickStatus: String
if entry?.changeSummary?.hasChanges == true {
quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
@@ -2646,15 +2792,105 @@ final class DomainViewModel {
}
func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
- let siblings = history.filter { candidate in
- if let trackedDomainID = entry.trackedDomainID {
- return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
+ previousHistoryEntry(for: entry)?.snapshot
+ }
+
+ func timelineEntries(for domain: String) -> [SnapshotSummary] {
+ historyEntries(for: domain).map(\.snapshotSummary)
+ }
+
+ func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
+ let entries = timelineEntries(for: domain)
+ let grouping = grouping ?? timelineGrouping
+
+ guard grouping == .relativeDay else {
+ return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
+ }
+
+ let calendar = Calendar.current
+ let today = Date()
+ let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
+ let grouped = Dictionary(grouping: entries) { entry -> String in
+ if calendar.isDate(entry.timestamp, inSameDayAs: today) {
+ return "Today"
}
- return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
+ if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
+ return "Yesterday"
+ }
+ return "Older"
+ }
+
+ return ["Today", "Yesterday", "Older"].compactMap { title in
+ guard let items = grouped[title], !items.isEmpty else { return nil }
+ return TimelineSection(id: title.lowercased(), title: title, entries: items)
+ }
+ }
+
+ func historyEntries(for domain: String) -> [HistoryEntry] {
+ history
+ .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ .sorted { $0.timestamp > $1.timestamp }
+ }
+
+ func historyEntry(withID id: UUID) -> HistoryEntry? {
+ history.first(where: { $0.id == id })
+ }
+
+ func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
+ let siblings = historyEntries(for: entry.domain)
+ guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
+ let nextIndex = index + 1
+ guard siblings.indices.contains(nextIndex) else { return nil }
+ return siblings[nextIndex]
+ }
+
+ func toggleSnapshotSelection(_ entry: HistoryEntry) {
+ if selectedSnapshotIDs.contains(entry.id) {
+ selectedSnapshotIDs.remove(entry.id)
+ } else if selectedSnapshotIDs.count < 2 {
+ selectedSnapshotIDs.insert(entry.id)
+ } else if let oldest = selectedSnapshotIDs.first {
+ selectedSnapshotIDs.remove(oldest)
+ selectedSnapshotIDs.insert(entry.id)
+ }
+ }
+
+ func clearSnapshotSelection() {
+ selectedSnapshotIDs.removeAll()
+ }
+
+ var selectedSnapshots: [HistoryEntry] {
+ selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
+ }
+
+ @discardableResult
+ func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
+ guard selectedSnapshots.count == 2 else {
+ activeDomainDiff = nil
+ activeDiffChangeIndex = 0
+ return nil
+ }
+
+ let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
+ activeDomainDiff = diff
+ if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+ activeDiffChangeIndex = index
+ } else {
+ activeDiffChangeIndex = 0
}
- .sorted { $0.timestamp > $1.timestamp }
+ return diff
+ }
- return siblings.first?.snapshot
+ func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
+ selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
+ let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
+ activeDomainDiff = diff
+ if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
+ activeDiffChangeIndex = index
+ } else {
+ activeDiffChangeIndex = 0
+ }
+ return diff
}
func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
@@ -2662,6 +2898,23 @@ final class DomainViewModel {
return history.first(where: { $0.id == historyEntryID })
}
+ var currentDiffTargetSectionID: String? {
+ guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
+ return nil
+ }
+ return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
+ }
+
+ func moveToNextDiffChange() {
+ guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
+ activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
+ }
+
+ func moveToPreviousDiffChange() {
+ guard activeDomainDiff != nil else { return }
+ activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
+ }
+
private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
let latestEntries = latestSnapshots(for: domains)
@@ -2709,6 +2962,10 @@ final class DomainViewModel {
}
}
+ private func timelineReports(for domain: String) -> [DomainReport] {
+ historyEntries(for: domain).map { report(for: $0) }
+ }
+
private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
}
@@ -2745,6 +3002,10 @@ final class DomainViewModel {
isPartialSnapshot: true,
validationIssues: ["No stored snapshot data available"],
totalLookupDurationMs: nil,
+ snapshotIndex: nil,
+ previousSnapshotID: nil,
+ changeCount: 0,
+ severitySummary: trackedDomain.lastChangeSeverity,
dnsSections: [],
dnsError: nil,
availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
@@ -2795,6 +3056,14 @@ final class DomainViewModel {
return DomainDataPortabilityService.loadHistoryEntries()
}
+ private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
+ guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
+ let option = HistoryAutoPruneOption(rawValue: rawValue) else {
+ return .unlimited
+ }
+ return option
+ }
+
private static func loadTrackedDomains() -> [TrackedDomain] {
DataMigrationService.migrateIfNeeded()
return DomainDataPortabilityService.loadTrackedDomains()
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 6eb6777..be7548a 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -7,8 +7,12 @@ struct HistoryView: View {
@State private var showClearAllConfirmation = false
@State private var showWorkflowAddSheet = false
- private var groupedHistory: [HistoryGroup] {
- HistoryGroup.groups(for: viewModel.filteredHistory)
+ private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] {
+ viewModel.timelineDomains.compactMap { domain in
+ let entries = viewModel.timelineEntries(for: domain)
+ guard let latest = entries.first else { return nil }
+ return (domain, latest, entries.count)
+ }
}
var body: some View {
@@ -22,55 +26,41 @@ struct HistoryView: View {
)
.listRowBackground(Color(.systemGray6).opacity(0.5))
} else {
- ForEach(groupedHistory) { group in
- Section(group.title) {
- ForEach(group.entries) { entry in
- NavigationLink {
- HistoryDetailView(viewModel: viewModel, entry: entry)
- } label: {
- VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
- HStack(alignment: .center, spacing: 8) {
- Text(entry.domain)
- .font(appDensity.font(.callout))
- .foregroundStyle(.primary)
- Spacer()
- AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary))
- }
-
- 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)))
- }
- }
+ Section("Domains") {
+ ForEach(domainSummaries, id: \.domain) { item in
+ NavigationLink {
+ TimelineView(viewModel: viewModel, domain: item.domain)
+ } label: {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+ HStack(alignment: .center, spacing: 8) {
+ Text(item.domain)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Spacer()
+ Text("\(item.count) snapshots")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
- HStack(spacing: 8) {
- Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
- Text(entry.timestamp.formatted(.relative(presentation: .named)))
- Text(entry.resolverDisplayName)
- if let totalLookupDurationMs = entry.totalLookupDurationMs {
- Text("\(totalLookupDurationMs) ms")
- }
- }
- .font(appDensity.font(.caption2))
+ Text(item.latest.changeSummaryMessage ?? "No change summary")
+ .font(appDensity.font(.caption))
.foregroundStyle(.secondary)
+ .lineLimit(2)
- if let note = entry.note, !note.isEmpty {
- Text(note)
- .font(appDensity.font(.caption2))
- .foregroundStyle(.secondary)
- .lineLimit(1)
+ HStack(spacing: 8) {
+ AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability))
+ if let severity = item.latest.severitySummary {
+ AppStatusBadgeView(
+ model: .init(
+ title: severity.title,
+ systemImage: "arrow.triangle.2.circlepath",
+ foregroundColor: severity == .high ? .red : .yellow,
+ backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
+ )
+ )
}
}
}
- .swipeActions(edge: .trailing, allowsFullSwipe: true) {
- Button(role: .destructive) {
- viewModel.removeHistoryEntries(withIDs: [entry.id])
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
}
.listRowBackground(Color(.systemGray6).opacity(0.5))
}
@@ -80,7 +70,7 @@ struct HistoryView: View {
.scrollContentBackground(.hidden)
.background(Color.black)
.navigationTitle("History")
- .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
+ .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains")
.toolbar {
if !viewModel.history.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
@@ -244,7 +234,8 @@ struct HistoryDetailView: View {
title: "Compared With Previous Snapshot",
sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot),
- showsUnchanged: false
+ showsUnchanged: false,
+ highlightedSectionID: nil
)
.padding(.top, appDensity.metrics.sectionSpacing)
}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 25d1ba2..51273b4 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -541,6 +541,83 @@ enum HistorySortOption: String, CaseIterable, Identifiable {
}
}
+enum TimelineGroupingOption: String, CaseIterable, Identifiable {
+ case none
+ case relativeDay
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .none:
+ return "Ungrouped"
+ case .relativeDay:
+ return "Today / Yesterday / Older"
+ }
+ }
+}
+
+enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable {
+ case keep50
+ case keep100
+ case unlimited
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .keep50:
+ return "Keep Last 50"
+ case .keep100:
+ return "Keep Last 100"
+ case .unlimited:
+ return "Unlimited"
+ }
+ }
+
+ var keepCount: Int? {
+ switch self {
+ case .keep50:
+ return 50
+ case .keep100:
+ return 100
+ case .unlimited:
+ return nil
+ }
+ }
+}
+
+struct SnapshotSummary: Identifiable, Codable, Equatable {
+ let id: UUID
+ let domain: String
+ let timestamp: Date
+ let trackedDomainID: UUID?
+ let snapshotIndex: Int?
+ let previousSnapshotID: UUID?
+ let changeCount: Int
+ let severitySummary: ChangeSeverity?
+ let changeSummaryMessage: String?
+ let availability: DomainAvailabilityStatus?
+ let primaryIP: String?
+ let tlsStatus: String?
+ let riskScore: Int?
+ let historyEntryID: UUID
+
+ var hasChanges: Bool {
+ changeCount > 0
+ }
+}
+
+struct FullSnapshot: Codable {
+ let historyEntry: HistoryEntry
+}
+
+struct TimelineSection: Identifiable, Equatable {
+ let id: String
+ let title: String
+ let entries: [SnapshotSummary]
+}
+
enum WatchlistFilterOption: String, CaseIterable, Identifiable {
case all
case pinnedOnly
@@ -1304,6 +1381,10 @@ struct HistoryEntry: Identifiable, Codable {
var emailSecuritySummary: String?
var httpGradeSummary: String?
var changeSummary: DomainChangeSummary?
+ var snapshotIndex: Int?
+ var previousSnapshotID: UUID?
+ var changeCount: Int
+ var severitySummary: ChangeSeverity?
var sslError: String?
var httpHeadersError: String?
var reachabilityError: String?
@@ -1339,7 +1420,8 @@ struct HistoryEntry: Identifiable, Codable {
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,
+ changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
+ changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
reachabilityError: String? = nil, ipGeolocationError: String? = nil,
emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
@@ -1389,6 +1471,10 @@ struct HistoryEntry: Identifiable, Codable {
self.emailSecuritySummary = emailSecuritySummary
self.httpGradeSummary = httpGradeSummary
self.changeSummary = changeSummary
+ self.snapshotIndex = snapshotIndex
+ self.previousSnapshotID = previousSnapshotID
+ self.changeCount = changeCount
+ self.severitySummary = severitySummary
self.sslError = sslError
self.httpHeadersError = httpHeadersError
self.reachabilityError = reachabilityError
@@ -1452,6 +1538,13 @@ struct HistoryEntry: Identifiable, Codable {
emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
+ snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
+ previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
+ changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
+ ?? changeSummary?.changedSections.count
+ ?? 0
+ severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
+ ?? changeSummary?.severity
sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
@@ -1478,6 +1571,25 @@ struct HistoryEntry: Identifiable, Codable {
}
return issues
}
+
+ var snapshotSummary: SnapshotSummary {
+ SnapshotSummary(
+ id: id,
+ domain: domain,
+ timestamp: timestamp,
+ trackedDomainID: trackedDomainID,
+ snapshotIndex: snapshotIndex,
+ previousSnapshotID: previousSnapshotID,
+ changeCount: changeCount,
+ severitySummary: severitySummary ?? changeSummary?.severity,
+ changeSummaryMessage: changeSummary?.message,
+ availability: availabilityResult?.status,
+ primaryIP: primaryIP,
+ tlsStatus: tlsStatusSummary,
+ riskScore: changeSummary?.riskAssessment?.score,
+ historyEntryID: id
+ )
+ }
}
// MARK: - Cloudflare DNS-over-HTTPS Response
diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift
index 36c2827..1f3e649 100644
--- a/DomainDig/PortScanService.swift
+++ b/DomainDig/PortScanService.swift
@@ -124,7 +124,7 @@ struct PortScanService {
}
private static func probe(domain: String, port: UInt16) async -> PortProbeResult {
- await probe(domain: domain, port: port, timeout: 5)
+ await probe(domain: domain, port: port, timeout: 1.5)
}
private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult {
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift
index e7e2c93..6d67e45 100644
--- a/DomainDig/RDAPService.swift
+++ b/DomainDig/RDAPService.swift
@@ -48,6 +48,7 @@ enum RDAPService {
}
private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
+ let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain)
guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
return .error("Unavailable")
}
@@ -55,9 +56,11 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
do {
var request = URLRequest(url: url, timeoutInterval: 8)
request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
+ DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
+ DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil")
return .error(URLError(.badServerResponse).localizedDescription)
}
@@ -66,15 +69,22 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
let decoder = JSONDecoder()
let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
guard rdapResponse.isDomainRecord else {
+ DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord")
return .empty("Unavailable")
}
+ DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)")
return .success(rdapResponse)
case 404:
+ DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404")
return .empty("Unavailable")
default:
+ DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)")
+ DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)")
return .error("Unavailable")
}
} catch {
+ DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)")
+ DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error")
return .error(error.localizedDescription)
}
}
diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift
index 3fb7dd9..207499f 100644
--- a/DomainDig/ReachabilityService.swift
+++ b/DomainDig/ReachabilityService.swift
@@ -23,7 +23,7 @@ struct ReachabilityService {
let queue = DispatchQueue(label: "reachability.\(port)")
connection.start(queue: queue)
- queue.asyncAfter(deadline: .now() + 5) {
+ queue.asyncAfter(deadline: .now() + 2) {
context.finish(reachable: false)
}
}
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
index 7339648..4511edd 100644
--- a/DomainDig/SubdomainDiscoveryService.swift
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -17,6 +17,7 @@ enum SubdomainDiscoveryService {
}
private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+ let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain)
var components = URLComponents(string: "https://crt.sh/")!
components.queryItems = [
URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -29,15 +30,25 @@ enum SubdomainDiscoveryService {
do {
let request = URLRequest(url: url, timeoutInterval: 10)
+ DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)")
return .error("Subdomain discovery unavailable")
}
let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
+ DomainDebugLog.signpostEnd(
+ "SubdomainDiscovery.fetch",
+ start: startedAt,
+ domain: domain,
+ extra: "entries=\(entries.count) subdomains=\(subdomains.count)"
+ )
return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
} catch {
+ DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)")
+ DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error")
return .error(error.localizedDescription)
}
}
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
new file mode 100644
index 0000000..404fc1e
--- /dev/null
+++ b/DomainDig/TimelineView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct TimelineView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ let domain: String
+
+ @State private var presentedDiff: DomainDiff?
+ @State private var focusedSectionID: String?
+
+ private var timelineSections: [TimelineSection] {
+ viewModel.timelineSections(for: domain)
+ }
+
+ var body: some View {
+ List {
+ ForEach(timelineSections) { section in
+ Section(section.title) {
+ ForEach(section.entries) { summary in
+ if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
+ NavigationLink {
+ HistoryDetailView(viewModel: viewModel, entry: entry)
+ } label: {
+ TimelineRow(summary: summary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button {
+ viewModel.toggleSnapshotSelection(entry)
+ } label: {
+ Label(
+ viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
+ systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
+ )
+ }
+
+ Button(role: .destructive) {
+ viewModel.removeHistoryEntries(withIDs: [entry.id])
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle(domain)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Menu {
+ Picker("Grouping", selection: $viewModel.timelineGrouping) {
+ ForEach(TimelineGroupingOption.allCases) { option in
+ Text(option.title).tag(option)
+ }
+ }
+ } label: {
+ Image(systemName: "line.3.horizontal.decrease.circle")
+ }
+
+ Button("Compare") {
+ presentedDiff = viewModel.generateDiffForSelectedSnapshots()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ }
+ .disabled(viewModel.selectedSnapshots.count != 2)
+
+ Menu("Export") {
+ Button("Export TXT") {
+ ExportPresenter.share(
+ filename: "\(domain)-timeline.txt",
+ contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
+ )
+ }
+
+ Button("Export JSON") {
+ guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
+ ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
+ }
+ }
+ }
+ }
+ .sheet(item: $presentedDiff) { diff in
+ NavigationStack {
+ TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
+ }
+ }
+ }
+}
+
+private struct TimelineRow: View {
+ @Environment(\.appDensity) private var appDensity
+ let summary: SnapshotSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+ HStack(alignment: .center, spacing: 8) {
+ Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Spacer()
+ if let severity = summary.severitySummary {
+ AppStatusBadgeView(
+ model: .init(
+ title: severity.title,
+ systemImage: "arrow.triangle.2.circlepath",
+ foregroundColor: severity == .high ? .red : .yellow,
+ backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
+ )
+ )
+ }
+ }
+
+ Text(summary.changeSummaryMessage ?? "No change summary")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
+ if let primaryIP = summary.primaryIP {
+ AppStatusBadgeView(
+ model: .init(
+ title: primaryIP,
+ systemImage: "network",
+ foregroundColor: .blue,
+ backgroundColor: .blue.opacity(0.16)
+ )
+ )
+ }
+ if let tlsStatus = summary.tlsStatus {
+ AppStatusBadgeView(
+ model: .init(
+ title: tlsStatus.capitalized,
+ systemImage: "lock.shield",
+ foregroundColor: .green,
+ backgroundColor: .green.opacity(0.16)
+ )
+ )
+ }
+ if let riskScore = summary.riskScore {
+ AppStatusBadgeView(
+ model: .init(
+ title: "Risk \(riskScore)",
+ systemImage: "exclamationmark.shield",
+ foregroundColor: riskScore >= 70 ? .red : .orange,
+ backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
+ )
+ )
+ }
+ }
+
+ HStack(spacing: 8) {
+ if let snapshotIndex = summary.snapshotIndex {
+ Text("#\(snapshotIndex)")
+ }
+ Text(summary.timestamp.formatted(.relative(presentation: .named)))
+ if summary.changeCount > 0 {
+ Text("\(summary.changeCount) changes")
+ }
+ }
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+struct TimelineDiffView: View {
+ @Bindable var viewModel: DomainViewModel
+ let diff: DomainDiff
+ @Binding var focusedSectionID: String?
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Button("Previous Change") {
+ viewModel.moveToPreviousDiffChange()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ scroll(proxy: proxy)
+ }
+ .disabled(viewModel.activeDiffChangeIndex == 0)
+
+ Button("Next Change") {
+ viewModel.moveToNextDiffChange()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ scroll(proxy: proxy)
+ }
+ .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
+
+ Spacer()
+ }
+
+ DomainDiffView(
+ title: "Snapshot Diff",
+ sections: diff.sections,
+ contextNote: diff.contextNote,
+ showsUnchanged: false,
+ highlightedSectionID: focusedSectionID
+ )
+ }
+ .padding()
+ }
+ .background(Color.black)
+ .navigationTitle("Compare Snapshots")
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ scroll(proxy: proxy)
+ }
+ .onChange(of: focusedSectionID) { _, _ in
+ scroll(proxy: proxy)
+ }
+ }
+ }
+
+ private func scroll(proxy: ScrollViewProxy) {
+ guard let focusedSectionID else { return }
+ withAnimation {
+ proxy.scrollTo(focusedSectionID, anchor: .top)
+ }
+ }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 1a38282..31c6202 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -483,7 +483,8 @@ struct TrackedDomainDetailView: View {
contextNote: latestSnapshots.count >= 2
? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
: nil,
- showsUnchanged: false
+ showsUnchanged: false,
+ highlightedSectionID: nil
)
}
.listRowBackground(Color.clear)