Commit d0b3b7a15b

d0b3b7a15b59266b4ae4262fdb0364245092c17c

parent: b35f1b432f

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-24 17:38 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -366,7 +366,7 @@
366366 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
367367 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
368368 CODE_SIGN_STYLE = Automatic;
369 CURRENT_PROJECT_VERSION = 28;
369 CURRENT_PROJECT_VERSION = 29;
370370 DEVELOPMENT_TEAM = ZCNAX3VL9D;
371371 ENABLE_PREVIEWS = YES;
372372 GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
383383 "$(inherited)",
384384 "@executable_path/Frameworks",
385385 );
386 MARKETING_VERSION = 3.6.0;
386 MARKETING_VERSION = 3.7.0;
387387 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
388388 PRODUCT_NAME = "$(TARGET_NAME)";
389389 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
403403 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
404404 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
405405 CODE_SIGN_STYLE = Automatic;
406 CURRENT_PROJECT_VERSION = 28;
406 CURRENT_PROJECT_VERSION = 29;
407407 DEVELOPMENT_TEAM = ZCNAX3VL9D;
408408 ENABLE_PREVIEWS = YES;
409409 GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
420420 "$(inherited)",
421421 "@executable_path/Frameworks",
422422 );
423 MARKETING_VERSION = 3.6.0;
423 MARKETING_VERSION = 3.7.0;
424424 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
425425 PRODUCT_NAME = "$(TARGET_NAME)";
426426 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +2 −2
@@ -7,12 +7,12 @@
77 <key>DomainDig.xcscheme_^#shared#^_</key>
88 <dict>
99 <key>orderHint</key>
10 <integer>0</integer>
10 <integer>1</integer>
1111 </dict>
1212 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
1313 <dict>
1414 <key>orderHint</key>
15 <integer>1</integer>
15 <integer>0</integer>
1616 </dict>
1717 </dict>
1818</dict>
DomainDig/ContentView.swift +45 −4
@@ -37,6 +37,7 @@ struct ContentView: View {
3737 @State private var collapsedSections: Set<ResultSection> = [.network]
3838 @State private var showingCurrentDomainWorkflowSheet = false
3939 @State private var showingBatchWorkflowSheet = false
40 @State private var showingTimeline = false
4041
4142 var body: some View {
4243 let _ = purchaseService.currentTier
@@ -83,7 +84,8 @@ struct ContentView: View {
8384 title: "Latest Changes",
8485 sections: viewModel.currentDiffSections,
8586 contextNote: viewModel.currentChangeSummary?.contextNote,
86 showsUnchanged: false
87 showsUnchanged: false,
88 highlightedSectionID: nil
8789 )
8890 .padding(.top, appDensity.metrics.sectionSpacing)
8991 }
@@ -230,6 +232,11 @@ struct ContentView: View {
230232 availableDomains: viewModel.batchResults.map(\.domain)
231233 )
232234 }
235 .sheet(isPresented: $showingTimeline) {
236 NavigationStack {
237 TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain)
238 }
239 }
233240 }
234241
235242 private var inputSection: some View {
@@ -439,6 +446,11 @@ struct ContentView: View {
439446 Button("Add to workflow") {
440447 showingCurrentDomainWorkflowSheet = true
441448 }
449 if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty {
450 Button("Open timeline") {
451 showingTimeline = true
452 }
453 }
442454 if FeatureAccessService.hasAccess(to: .advancedExports) {
443455 Button("Copy report JSON") {
444456 guard let json = viewModel.exportJSONString() else { return }
@@ -1048,8 +1060,9 @@ struct DomainDiffView: View {
10481060 let sections: [DomainDiffSection]
10491061 let contextNote: String?
10501062 let showsUnchanged: Bool
1063 let highlightedSectionID: String?
10511064
1052 @State private var collapsedSections = Set<UUID>()
1065 @State private var collapsedSections = Set<String>()
10531066 @State private var showsLowSeverity = false
10541067
10551068 private var filteredSections: [DomainDiffSection] {
@@ -1064,7 +1077,7 @@ struct DomainDiffView: View {
10641077 }
10651078 return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
10661079 }
1067 return DomainDiffSection(title: section.title, items: items)
1080 return DomainDiffSection(id: section.id, title: section.title, items: items)
10681081 }
10691082 .filter { !$0.items.isEmpty }
10701083 }
@@ -1104,7 +1117,7 @@ struct DomainDiffView: View {
11041117 .font(.system(.caption, design: .monospaced))
11051118 .foregroundStyle(.secondary)
11061119 Spacer()
1107 Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
1120 Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))")
11081121 .font(.system(.caption2, design: .monospaced))
11091122 .foregroundStyle(changeColor(for: item))
11101123 .padding(.horizontal, 8)
@@ -1154,9 +1167,19 @@ struct DomainDiffView: View {
11541167 }
11551168 }
11561169 }
1170 .id(section.id)
1171 .overlay {
1172 if highlightedSectionID == section.id {
1173 RoundedRectangle(cornerRadius: 12)
1174 .stroke(Color.cyan.opacity(0.55), lineWidth: 1)
1175 }
1176 }
11571177 }
11581178 }
11591179 }
1180 .onAppear {
1181 collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id))
1182 }
11601183 }
11611184
11621185 private func changeLabel(for changeType: DiffChangeType) -> String {
@@ -2460,6 +2483,24 @@ struct SettingsView: View {
24602483 }
24612484 }
24622485
2486 Section("History") {
2487 Picker(
2488 "Auto-prune",
2489 selection: Binding(
2490 get: { viewModel.historyAutoPruneOption },
2491 set: { viewModel.setHistoryAutoPruneOption($0) }
2492 )
2493 ) {
2494 ForEach(HistoryAutoPruneOption.allCases) { option in
2495 Text(option.title).tag(option)
2496 }
2497 }
2498
2499 Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
2500 .font(appDensity.font(.caption, design: .default))
2501 .foregroundStyle(.secondary)
2502 }
2503
24632504 Section("Network") {
24642505 Picker("Resolver", selection: $resolverOption) {
24652506 ForEach(DNSResolverOption.allCases) { option in
DomainDig/DiffService.swift added +563
@@ -0,0 +1,563 @@
1import Foundation
2
3enum DiffChangeType: String, Codable {
4 case added
5 case removed
6 case changed
7 case unchanged
8
9 var marker: String {
10 switch self {
11 case .added:
12 return "+"
13 case .removed:
14 return "-"
15 case .changed:
16 return "~"
17 case .unchanged:
18 return "="
19 }
20 }
21
22 var title: String {
23 switch self {
24 case .added:
25 return "Added"
26 case .removed:
27 return "Removed"
28 case .changed:
29 return "Changed"
30 case .unchanged:
31 return "Unchanged"
32 }
33 }
34}
35
36struct DiffItem: Identifiable, Equatable, Codable {
37 let id: String
38 let label: String
39 let changeType: DiffChangeType
40 let oldValue: String?
41 let newValue: String?
42 let severity: ChangeSeverity
43
44 init(
45 id: String,
46 label: String,
47 changeType: DiffChangeType,
48 oldValue: String?,
49 newValue: String?,
50 severity: ChangeSeverity
51 ) {
52 self.id = id
53 self.label = label
54 self.changeType = changeType
55 self.oldValue = oldValue
56 self.newValue = newValue
57 self.severity = severity
58 }
59
60 var hasChanges: Bool {
61 changeType != .unchanged
62 }
63}
64
65struct DiffSection: Identifiable, Equatable, Codable {
66 let id: String
67 let title: String
68 let items: [DiffItem]
69
70 var hasChanges: Bool {
71 items.contains(where: \.hasChanges)
72 }
73
74 var severity: ChangeSeverity {
75 items.map(\.severity).max() ?? .low
76 }
77
78 var changeCount: Int {
79 items.filter(\.hasChanges).count
80 }
81}
82
83struct DomainDiff: Identifiable, Equatable, Codable {
84 let domain: String
85 let fromTimestamp: Date
86 let toTimestamp: Date
87 let sections: [DiffSection]
88 let changedSectionIDs: [String]
89 let changedSectionTitles: [String]
90 let contextNote: String?
91
92 var id: String {
93 "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
94 }
95
96 var changeCount: Int {
97 sections.reduce(0) { $0 + $1.changeCount }
98 }
99
100 var severity: ChangeSeverity {
101 sections.map(\.severity).max() ?? .low
102 }
103}
104
105typealias DomainDiffItem = DiffItem
106typealias DomainDiffSection = DiffSection
107
108enum DiffService {
109 static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
110 let sections = [
111 availabilitySection(from: oldReport, to: newReport),
112 ownershipSection(from: oldReport, to: newReport),
113 dnsSection(from: oldReport, to: newReport),
114 webSection(from: oldReport, to: newReport),
115 emailSection(from: oldReport, to: newReport),
116 networkSection(from: oldReport, to: newReport),
117 subdomainsSection(from: oldReport, to: newReport),
118 riskSection(from: oldReport, to: newReport)
119 ]
120
121 let changedSections = sections.filter(\.hasChanges)
122 return DomainDiff(
123 domain: newReport.domain,
124 fromTimestamp: oldReport.timestamp,
125 toTimestamp: newReport.timestamp,
126 sections: sections,
127 changedSectionIDs: changedSections.map(\.id),
128 changedSectionTitles: changedSections.map(\.title),
129 contextNote: comparisonContextNote(from: oldReport, to: newReport)
130 )
131 }
132
133 static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
134 let builder = DomainReportBuilder()
135 let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
136 let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
137 return compare(from: oldReport, to: newReport)
138 }
139
140 static func summary(
141 from oldSnapshot: LookupSnapshot,
142 to newSnapshot: LookupSnapshot,
143 generatedAt: Date = Date(),
144 riskAssessment: DomainRiskAssessment? = nil,
145 insights: [String]? = nil
146 ) -> DomainChangeSummary {
147 let diff = compare(from: oldSnapshot, to: newSnapshot)
148 let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
149 let highlights = diff.changedSectionTitles
150 let severity = changedItems.map(\.severity).max() ?? .low
151 let message = summaryMessage(from: highlights, changeCount: changedItems.count)
152 let observedFacts = changedItems.prefix(4).map { item in
153 "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
154 }
155
156 let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
157 let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
158 let currentInsights = insights ?? analysis.insights
159 let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
160 let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
161 let impactClassification = DomainInsightEngine.impactClassification(
162 severity: severity,
163 riskDelta: riskScoreDelta,
164 changedSections: highlights
165 )
166
167 return DomainChangeSummary(
168 hasChanges: !changedItems.isEmpty,
169 changedSections: highlights,
170 message: message,
171 severity: severity,
172 impactClassification: impactClassification,
173 generatedAt: generatedAt,
174 observedFacts: observedFacts,
175 inferredConclusions: highlights.isEmpty ? [] : [message],
176 contextNote: diff.contextNote,
177 riskAssessment: currentRiskAssessment,
178 insights: currentInsights,
179 riskScoreDelta: riskScoreDelta
180 )
181 }
182
183 static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
184 var notes: [String] = []
185 if oldReport.resolverURLString != newReport.resolverURLString {
186 notes.append("Compared snapshots used different DNS resolvers.")
187 }
188 if oldReport.resultSource != newReport.resultSource {
189 notes.append("Compared snapshots came from different collection modes.")
190 }
191 return notes.isEmpty ? nil : notes.joined(separator: " ")
192 }
193
194 static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
195 comparisonContextNote(
196 from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
197 to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
198 )
199 }
200
201 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
202 guard let days = snapshot.sslInfo?.daysUntilExpiry else {
203 return .none
204 }
205 if days < 14 {
206 return .critical
207 }
208 if days < 30 {
209 return .warning
210 }
211 return .none
212 }
213
214 private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
215 DiffSection(
216 id: "availability",
217 title: "Domain / Availability",
218 items: [
219 compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
220 compare(
221 id: "availability",
222 label: "Availability",
223 oldValue: availabilityLabel(oldReport.availability),
224 newValue: availabilityLabel(newReport.availability),
225 severity: .high
226 ),
227 compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
228 compare(
229 id: "tls-status",
230 label: "TLS Status",
231 oldValue: oldReport.web.tlsStatus,
232 newValue: newReport.web.tlsStatus,
233 severity: .medium
234 )
235 ].compactMap { $0 }
236 )
237 }
238
239 private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
240 DiffSection(
241 id: "ownership",
242 title: "Ownership",
243 items: [
244 compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
245 compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
246 compare(
247 id: "ownership-created",
248 label: "Registration Date",
249 oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
250 newValue: ownershipDateLabel(newReport.ownership?.createdDate),
251 severity: .low
252 ),
253 compare(
254 id: "ownership-expires",
255 label: "Expiration Date",
256 oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
257 newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
258 severity: .medium
259 ),
260 compare(
261 id: "ownership-status",
262 label: "Status",
263 oldValue: joined(oldReport.ownership?.status),
264 newValue: joined(newReport.ownership?.status),
265 severity: .low
266 ),
267 compare(
268 id: "ownership-nameservers",
269 label: "Nameservers",
270 oldValue: joined(oldReport.ownership?.nameservers),
271 newValue: joined(newReport.ownership?.nameservers),
272 severity: .medium
273 ),
274 compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
275 ].compactMap { $0 }
276 )
277 }
278
279 private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
280 let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
281 let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
282 let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
283
284 var items: [DiffItem] = [
285 compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
286 compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
287 ].compactMap { $0 }
288
289 for type in recordTypes {
290 items.append(
291 compare(
292 id: "dns-\(type.rawValue.lowercased())-records",
293 label: "\(type.rawValue) Records",
294 oldValue: normalizedRecordValues(for: oldSections[type]),
295 newValue: normalizedRecordValues(for: newSections[type]),
296 severity: type == .A || type == .NS ? .high : .medium
297 ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
298 )
299 if let ttlChange = compare(
300 id: "dns-\(type.rawValue.lowercased())-ttl",
301 label: "\(type.rawValue) TTL",
302 oldValue: normalizedTTLValues(for: oldSections[type]),
303 newValue: normalizedTTLValues(for: newSections[type]),
304 severity: .low
305 ) {
306 items.append(ttlChange)
307 }
308 }
309
310 return DiffSection(
311 id: "dns",
312 title: "DNS",
313 items: items.filter { !$0.id.isEmpty }
314 )
315 }
316
317 private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
318 DiffSection(
319 id: "web",
320 title: "Web",
321 items: [
322 compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
323 compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
324 compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
325 compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
326 compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
327 compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
328 compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
329 ].compactMap { $0 }
330 )
331 }
332
333 private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
334 DiffSection(
335 id: "email",
336 title: "Email Security",
337 items: [
338 compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
339 compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
340 compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
341 compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
342 compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
343 compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
344 compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
345 ].compactMap { $0 }
346 )
347 }
348
349 private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
350 DiffSection(
351 id: "network",
352 title: "Network",
353 items: [
354 compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
355 compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
356 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),
357 compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
358 ].compactMap { $0 }
359 )
360 }
361
362 private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
363 DiffSection(
364 id: "subdomains",
365 title: "Subdomains",
366 items: [
367 compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
368 compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
369 compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
370 ].compactMap { $0 }
371 )
372 }
373
374 private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
375 DiffSection(
376 id: "risk",
377 title: "Risk / Insights",
378 items: [
379 compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
380 compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
381 compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
382 compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
383 ].compactMap { $0 }
384 )
385 }
386
387 private static func compare(
388 id: String,
389 label: String,
390 oldValue: String?,
391 newValue: String?,
392 severity: ChangeSeverity
393 ) -> DiffItem? {
394 let oldValue = normalized(oldValue)
395 let newValue = normalized(newValue)
396
397 guard oldValue != nil || newValue != nil else {
398 return nil
399 }
400
401 let changeType: DiffChangeType
402 switch (oldValue?.lowercased(), newValue?.lowercased()) {
403 case let (old?, new?) where old == new:
404 changeType = .unchanged
405 case (nil, _?):
406 changeType = .added
407 case (_?, nil):
408 changeType = .removed
409 default:
410 changeType = .changed
411 }
412
413 return DiffItem(
414 id: id,
415 label: label,
416 changeType: changeType,
417 oldValue: oldValue,
418 newValue: newValue,
419 severity: severity
420 )
421 }
422
423 static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
424 guard !sectionTitles.isEmpty else {
425 return "No meaningful changes"
426 }
427 if sectionTitles.count == 1 {
428 return "\(sectionTitles[0]) changed"
429 }
430 return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
431 }
432
433 private static func normalized(_ value: String?) -> String? {
434 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
435 return nil
436 }
437 return value
438 }
439
440 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
441 switch status {
442 case .available:
443 return "Available"
444 case .registered:
445 return "Registered"
446 case .unknown:
447 return "Unknown"
448 }
449 }
450
451 private static func ownershipDateLabel(_ date: Date?) -> String? {
452 date?.formatted(date: .abbreviated, time: .omitted)
453 }
454
455 private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
456 guard let certificate else { return nil }
457 return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
458 }
459
460 private static func joined(_ values: [String]?) -> String? {
461 guard let values else { return nil }
462 let normalizedValues = values
463 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
464 .filter { !$0.isEmpty }
465 .sorted()
466 return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
467 }
468
469 private static func normalizedRecordValues(for section: DNSSection?) -> String? {
470 guard let section else { return nil }
471 let values = (section.records + section.wildcardRecords)
472 .map(\.value)
473 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
474 .sorted()
475 return values.isEmpty ? nil : values.joined(separator: ", ")
476 }
477
478 private static func normalizedTTLValues(for section: DNSSection?) -> String? {
479 guard let section else { return nil }
480 let values = (section.records + section.wildcardRecords)
481 .map { "\($0.value.lowercased()):\($0.ttl)" }
482 .sorted()
483 return values.isEmpty ? nil : values.joined(separator: ", ")
484 }
485
486 private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
487 let values = headers
488 .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
489 .sorted()
490 return values.isEmpty ? nil : values.joined(separator: " | ")
491 }
492
493 private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
494 let values = redirects.map { "\($0.statusCode) \($0.url)" }
495 return values.isEmpty ? nil : values.joined(separator: " -> ")
496 }
497
498 private static func portScanSummary(_ results: [PortScanResult]) -> String? {
499 let values = results
500 .sorted { $0.port < $1.port }
501 .map { "\($0.port):\($0.open ? "open" : "closed")" }
502 return values.isEmpty ? nil : values.joined(separator: ", ")
503 }
504
505 private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
506 joined(groups.map { "\($0.label): \($0.subdomains.count)" })
507 }
508
509 private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
510 guard let record else { return nil }
511 if record.found {
512 return record.value ?? "Present"
513 }
514 return "Missing"
515 }
516
517 private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
518 guard let result else { return nil }
519 guard result.txtFound else { return "Missing" }
520 return result.policyMode ?? "Present"
521 }
522
523 private static func dnssecLabel(_ value: Bool?) -> String? {
524 switch value {
525 case true:
526 return "Signed"
527 case false:
528 return "Unsigned"
529 case nil:
530 return nil
531 }
532 }
533}
534
535enum DomainDiffService {
536 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
537 DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
538 }
539
540 static func summary(
541 from oldSnapshot: LookupSnapshot,
542 to newSnapshot: LookupSnapshot,
543 generatedAt: Date = Date(),
544 riskAssessment: DomainRiskAssessment? = nil,
545 insights: [String]? = nil
546 ) -> DomainChangeSummary {
547 DiffService.summary(
548 from: oldSnapshot,
549 to: newSnapshot,
550 generatedAt: generatedAt,
551 riskAssessment: riskAssessment,
552 insights: insights
553 )
554 }
555
556 static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
557 DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
558 }
559
560 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
561 DiffService.certificateWarningLevel(for: snapshot)
562 }
563}
DomainDig/DomainDiffService.swift deleted −555
@@ -1,555 +0,0 @@
1import Foundation
2
3enum DiffChangeType: String, Codable {
4 case added
5 case removed
6 case changed
7 case unchanged
8}
9
10struct DomainDiffItem: Identifiable, Equatable {
11 let id = UUID()
12 let label: String
13 let changeType: DiffChangeType
14 let oldValue: String?
15 let newValue: String?
16 let severity: ChangeSeverity
17
18 var hasChanges: Bool {
19 changeType != .unchanged
20 }
21
22 var isMeaningful: Bool {
23 hasChanges && severity >= .medium
24 }
25}
26
27struct DomainDiffSection: Identifiable, Equatable {
28 let id = UUID()
29 let title: String
30 let items: [DomainDiffItem]
31
32 var hasChanges: Bool {
33 items.contains(where: \.hasChanges)
34 }
35
36 var severity: ChangeSeverity {
37 items.map(\.severity).max() ?? .low
38 }
39}
40
41enum DomainDiffService {
42 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
43 [
44 availabilitySection(from: oldSnapshot, to: newSnapshot),
45 primaryIPSection(from: oldSnapshot, to: newSnapshot),
46 ownershipSection(from: oldSnapshot, to: newSnapshot),
47 dnsSection(from: oldSnapshot, to: newSnapshot),
48 redirectSection(from: oldSnapshot, to: newSnapshot),
49 tlsSection(from: oldSnapshot, to: newSnapshot),
50 httpSection(from: oldSnapshot, to: newSnapshot),
51 emailSection(from: oldSnapshot, to: newSnapshot),
52 subdomainSection(from: oldSnapshot, to: newSnapshot)
53 ]
54 .filter { !$0.items.isEmpty }
55 }
56
57 static func summary(
58 from oldSnapshot: LookupSnapshot,
59 to newSnapshot: LookupSnapshot,
60 generatedAt: Date = Date(),
61 riskAssessment: DomainRiskAssessment? = nil,
62 insights: [String]? = nil
63 ) -> DomainChangeSummary {
64 let sections = diff(from: oldSnapshot, to: newSnapshot)
65 let allChangedItems = sections
66 .flatMap(\.items)
67 .filter(\.hasChanges)
68
69 let highlights = summaryHighlights(from: allChangedItems)
70 let severity = allChangedItems.map(\.severity).max() ?? .low
71 let message = summaryMessage(from: allChangedItems, highlights: highlights)
72 let observedFacts = observedFacts(from: allChangedItems)
73 let inferredConclusions = highlights.isEmpty ? [] : [message]
74 let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot)
75 let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
76 let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment
77 let currentInsights = insights ?? newAnalysis.insights
78 let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment
79 let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score
80 let impactClassification = DomainInsightEngine.impactClassification(
81 severity: severity,
82 riskDelta: riskScoreDelta,
83 changedSections: highlights
84 )
85
86 return DomainChangeSummary(
87 hasChanges: !allChangedItems.isEmpty,
88 changedSections: highlights,
89 message: message,
90 severity: severity,
91 impactClassification: impactClassification,
92 generatedAt: generatedAt,
93 observedFacts: observedFacts,
94 inferredConclusions: inferredConclusions,
95 contextNote: contextNote,
96 riskAssessment: currentRiskAssessment,
97 insights: currentInsights,
98 riskScoreDelta: riskScoreDelta
99 )
100 }
101
102 static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
103 var notes: [String] = []
104 if oldSnapshot.resolverURLString != newSnapshot.resolverURLString {
105 notes.append("Compared snapshots used different DNS resolvers.")
106 }
107 if oldSnapshot.resultSource != newSnapshot.resultSource {
108 notes.append("Compared snapshots came from different collection modes.")
109 }
110 return notes.isEmpty ? nil : notes.joined(separator: " ")
111 }
112
113 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
114 guard let days = snapshot.sslInfo?.daysUntilExpiry else {
115 return .none
116 }
117 if days < 14 {
118 return .critical
119 }
120 if days < 30 {
121 return .warning
122 }
123 return .none
124 }
125
126 private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
127 DomainDiffSection(
128 title: "Availability",
129 items: [
130 compare(
131 label: "Availability",
132 oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
133 newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
134 severity: .high
135 )
136 ].compactMap { $0 }
137 )
138 }
139
140 private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
141 DomainDiffSection(
142 title: "Primary IP",
143 items: [
144 compare(
145 label: "Primary IP",
146 oldValue: primaryIP(from: oldSnapshot),
147 newValue: primaryIP(from: newSnapshot),
148 severity: .high
149 )
150 ].compactMap { $0 }
151 )
152 }
153
154 private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
155 let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
156 let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
157 let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
158
159 var items: [DomainDiffItem] = []
160 for type in types {
161 let oldSection = oldSections[type]
162 let newSection = newSections[type]
163
164 if let recordChange = compare(
165 label: "\(type.rawValue) Records",
166 oldValue: normalizedRecordValues(for: oldSection),
167 newValue: normalizedRecordValues(for: newSection),
168 severity: .medium
169 ) {
170 items.append(recordChange)
171 }
172
173 if let ttlChange = compare(
174 label: "\(type.rawValue) TTL",
175 oldValue: normalizedTTLValues(for: oldSection),
176 newValue: normalizedTTLValues(for: newSection),
177 severity: .low
178 ), let oldSection, let newSection,
179 normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
180 items.append(ttlChange)
181 }
182 }
183
184 return DomainDiffSection(title: "DNS", items: items)
185 }
186
187 private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
188 DomainDiffSection(
189 title: "Ownership",
190 items: [
191 compare(
192 label: "Registrar",
193 oldValue: normalized(oldSnapshot.ownership?.registrar),
194 newValue: normalized(newSnapshot.ownership?.registrar),
195 severity: .high
196 ),
197 compare(
198 label: "Registration Date",
199 oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
200 newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
201 severity: .low
202 ),
203 compare(
204 label: "Expiration Date",
205 oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
206 newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
207 severity: .low
208 ),
209 compare(
210 label: "Ownership Status",
211 oldValue: ownershipList(oldSnapshot.ownership?.status),
212 newValue: ownershipList(newSnapshot.ownership?.status),
213 severity: .low
214 ),
215 compare(
216 label: "Nameservers",
217 oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
218 newValue: ownershipList(newSnapshot.ownership?.nameservers),
219 severity: .medium
220 ),
221 compare(
222 label: "Abuse Contact",
223 oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
224 newValue: normalized(newSnapshot.ownership?.abuseEmail),
225 severity: .low
226 )
227 ].compactMap { $0 }
228 )
229 }
230
231 private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
232 DomainDiffSection(
233 title: "Redirect",
234 items: [
235 compare(
236 label: "Redirect Target",
237 oldValue: finalRedirectURL(from: oldSnapshot),
238 newValue: finalRedirectURL(from: newSnapshot),
239 severity: .high
240 )
241 ].compactMap { $0 }
242 )
243 }
244
245 private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
246 var items: [DomainDiffItem] = []
247
248 if let issuerChange = compare(
249 label: "TLS Issuer",
250 oldValue: normalized(oldSnapshot.sslInfo?.issuer),
251 newValue: normalized(newSnapshot.sslInfo?.issuer),
252 severity: .medium
253 ) {
254 items.append(issuerChange)
255 }
256
257 if let expiryChange = compare(
258 label: "TLS Expiration",
259 oldValue: expirationLabel(oldSnapshot.sslInfo),
260 newValue: expirationLabel(newSnapshot.sslInfo),
261 severity: .medium
262 ) {
263 items.append(expiryChange)
264 }
265
266 let oldWarning = certificateWarningLevel(for: oldSnapshot)
267 let newWarning = certificateWarningLevel(for: newSnapshot)
268 if oldWarning != newWarning, newWarning != .none {
269 let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
270 items.append(
271 DomainDiffItem(
272 label: "Certificate Warning",
273 changeType: .changed,
274 oldValue: oldWarning.title,
275 newValue: "Certificate expires in \(days) days",
276 severity: newWarning == .critical ? .high : .medium
277 )
278 )
279 }
280
281 return DomainDiffSection(title: "TLS", items: items)
282 }
283
284 private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
285 var items: [DomainDiffItem] = []
286
287 if let statusChange = compare(
288 label: "HTTP Status",
289 oldValue: httpStatusSummary(from: oldSnapshot),
290 newValue: httpStatusSummary(from: newSnapshot),
291 severity: .medium
292 ) {
293 items.append(statusChange)
294 }
295
296 if let gradeChange = compare(
297 label: "Security Grade",
298 oldValue: normalized(oldSnapshot.httpSecurityGrade),
299 newValue: normalized(newSnapshot.httpSecurityGrade),
300 severity: .low
301 ) {
302 items.append(gradeChange)
303 }
304
305 if let headerChange = compare(
306 label: "Headers",
307 oldValue: normalizedHeaders(from: oldSnapshot),
308 newValue: normalizedHeaders(from: newSnapshot),
309 severity: .low
310 ) {
311 items.append(headerChange)
312 }
313
314 return DomainDiffSection(title: "HTTP", items: items)
315 }
316
317 private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
318 DomainDiffSection(
319 title: "Email Security",
320 items: [
321 compare(
322 label: "Email Security",
323 oldValue: normalized(emailSummary(from: oldSnapshot)),
324 newValue: normalized(emailSummary(from: newSnapshot)),
325 severity: .medium
326 )
327 ].compactMap { $0 }
328 )
329 }
330
331 private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
332 DomainDiffSection(
333 title: "Subdomains",
334 items: [
335 compare(
336 label: "Passive Subdomains",
337 oldValue: subdomainList(from: oldSnapshot),
338 newValue: subdomainList(from: newSnapshot),
339 severity: .low
340 )
341 ].compactMap { $0 }
342 )
343 }
344
345 private static func compare(
346 label: String,
347 oldValue: String?,
348 newValue: String?,
349 severity: ChangeSeverity
350 ) -> DomainDiffItem? {
351 let oldValue = normalized(oldValue)
352 let newValue = normalized(newValue)
353 let normalizedOldValue = comparisonValue(oldValue)
354 let normalizedNewValue = comparisonValue(newValue)
355
356 guard oldValue != nil || newValue != nil else {
357 return nil
358 }
359
360 let changeType: DiffChangeType
361 switch (normalizedOldValue, normalizedNewValue) {
362 case let (old?, new?) where old == new:
363 changeType = .unchanged
364 case (nil, _?):
365 changeType = .added
366 case (_?, nil):
367 changeType = .removed
368 default:
369 changeType = .changed
370 }
371
372 return DomainDiffItem(
373 label: label,
374 changeType: changeType,
375 oldValue: oldValue,
376 newValue: newValue,
377 severity: severity
378 )
379 }
380
381 private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
382 var highlights: [String] = []
383
384 let labels = Set(items.map(\.label))
385 if labels.contains("Availability") {
386 highlights.append("Availability changed")
387 }
388 if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
389 highlights.append("IP changed")
390 highlights.append("DNS changed")
391 return highlights
392 }
393 if labels.contains("Primary IP") {
394 highlights.append("IP changed")
395 }
396 if labels.contains("Redirect Target") {
397 highlights.append("Redirect target changed")
398 }
399 if labels.contains("Registrar") {
400 highlights.append("Registrar changed")
401 } else if labels.contains("Nameservers") {
402 highlights.append("Nameservers changed")
403 } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
404 highlights.append("Ownership metadata changed")
405 }
406 if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
407 let message = certificateItem.newValue {
408 highlights.append(message)
409 } else if labels.contains("TLS Issuer") {
410 highlights.append("TLS issuer changed")
411 } else if labels.contains("TLS Expiration") {
412 highlights.append("Certificate expiration changed")
413 }
414 if labels.contains(where: { $0.hasSuffix("Records") }) {
415 highlights.append("DNS changed")
416 }
417 if labels.contains("HTTP Status") {
418 highlights.append("HTTP status changed")
419 }
420 if labels.contains("Email Security") {
421 highlights.append("Email security changed")
422 }
423 if labels.contains("Passive Subdomains") {
424 highlights.append("Subdomains changed")
425 }
426
427 var deduplicated: [String] = []
428 for highlight in highlights where !deduplicated.contains(highlight) {
429 deduplicated.append(highlight)
430 }
431 return deduplicated
432 }
433
434 private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
435 guard !items.isEmpty, !highlights.isEmpty else {
436 return "No meaningful changes"
437 }
438
439 if highlights.count == 1 {
440 return highlights[0]
441 }
442
443 return "\(highlights[0]) and \(highlights[1].lowercased())"
444 }
445
446 private static func observedFacts(from items: [DomainDiffItem]) -> [String] {
447 items.prefix(3).map { item in
448 let oldValue = item.oldValue ?? "none"
449 let newValue = item.newValue ?? "none"
450 return "\(item.label): \(oldValue) -> \(newValue)"
451 }
452 }
453
454 private static func normalized(_ value: String?) -> String? {
455 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
456 return nil
457 }
458 return value
459 }
460
461 private static func comparisonValue(_ value: String?) -> String? {
462 value?.lowercased()
463 }
464
465 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
466 switch status {
467 case .available:
468 return "available"
469 case .registered:
470 return "registered"
471 case .unknown:
472 return "unknown"
473 case .none:
474 return nil
475 }
476 }
477
478 private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
479 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
480 }
481
482 private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
483 snapshot.redirectChain.last?.url
484 }
485
486 private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
487 guard let sslInfo else { return nil }
488 return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
489 }
490
491 private static func ownershipDateLabel(_ date: Date?) -> String? {
492 date?.formatted(date: .abbreviated, time: .omitted)
493 }
494
495 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
496 if let httpStatusCode = snapshot.httpStatusCode {
497 return "\(httpStatusCode)"
498 }
499 return snapshot.httpHeadersError
500 }
501
502 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
503 if let emailSecurity = snapshot.emailSecurity {
504 return [
505 "spf:\(emailSecurity.spf.found)",
506 "dmarc:\(emailSecurity.dmarc.found)",
507 "dkim:\(emailSecurity.dkim.found)",
508 "bimi:\(emailSecurity.bimi.found)",
509 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
510 ].joined(separator: "|")
511 }
512 return snapshot.emailSecurityError
513 }
514
515 private static func normalizedRecordValues(for section: DNSSection?) -> String? {
516 guard let section else { return nil }
517 let values = (section.records + section.wildcardRecords)
518 .map(\.value)
519 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
520 .sorted()
521 return values.isEmpty ? nil : values.joined(separator: ",")
522 }
523
524 private static func normalizedTTLValues(for section: DNSSection?) -> String? {
525 guard let section else { return nil }
526 let values = (section.records + section.wildcardRecords)
527 .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
528 .sorted()
529 return values.isEmpty ? nil : values.joined(separator: ",")
530 }
531
532 private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
533 let headers = snapshot.httpHeaders
534 .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
535 .sorted()
536 return headers.isEmpty ? nil : headers.joined(separator: "|")
537 }
538
539 private static func ownershipList(_ values: [String]?) -> String? {
540 guard let values else { return nil }
541 let normalizedValues = values
542 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
543 .filter { !$0.isEmpty }
544 .sorted()
545 return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
546 }
547
548 private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
549 let values = snapshot.subdomains
550 .map(\.hostname)
551 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
552 .sorted()
553 return values.isEmpty ? nil : values.joined(separator: ",")
554 }
555}
DomainDig/DomainDig/DomainDebugLog.swift added +37
@@ -0,0 +1,37 @@
1import Foundation
2import os
3
4enum DomainDebugLog {
5 static let enabled = true
6 private static let logger = Logger(subsystem: "co.zerolabs.domain-dig", category: "Debug")
7
8 static func debug(_ message: String) {
9 guard enabled else { return }
10 logger.debug("\(message, privacy: .public)")
11 }
12
13 static func error(_ message: String) {
14 guard enabled else { return }
15 logger.error("\(message, privacy: .public)")
16 }
17
18 static func signpostStart(_ scope: String, domain: String? = nil) -> CFAbsoluteTime {
19 let start = CFAbsoluteTimeGetCurrent()
20 if let domain {
21 debug("[START] \(scope) domain=\(domain)")
22 } else {
23 debug("[START] \(scope)")
24 }
25 return start
26 }
27
28 static func signpostEnd(_ scope: String, start: CFAbsoluteTime, domain: String? = nil, extra: String? = nil) {
29 let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000)
30 let suffix = extra.map { " \($0)" } ?? ""
31 if let domain {
32 debug("[END] \(scope) domain=\(domain) elapsedMs=\(elapsedMs)\(suffix)")
33 } else {
34 debug("[END] \(scope) elapsedMs=\(elapsedMs)\(suffix)")
35 }
36 }
37}
DomainDig/DomainMonitoringService.swift +4
@@ -509,6 +509,10 @@ final class DomainMonitoringService {
509509 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
510510 validationIssues: previousSnapshot.validationIssues,
511511 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
512 snapshotIndex: previousSnapshot.snapshotIndex,
513 previousSnapshotID: previousSnapshot.previousSnapshotID,
514 changeCount: previousSnapshot.changeCount,
515 severitySummary: previousSnapshot.severitySummary,
512516 dnsSections: previousSnapshot.dnsSections,
513517 dnsError: previousSnapshot.dnsError,
514518 availabilityResult: previousSnapshot.availabilityResult,
DomainDig/DomainViewModel.swift +295 −26
@@ -208,6 +208,10 @@ final class DomainViewModel {
208208 private var lastBatchStartedAt: Date?
209209 private var activeWorkflowRunID: UUID?
210210 private var activeWorkflowRunName: String?
211 private var historyPersistenceSuspended = false
212 private var trackedDomainsPersistenceSuspended = false
213 private var historyPersistenceDirty = false
214 private var trackedDomainsPersistenceDirty = false
211215 private let reportBuilder = DomainReportBuilder()
212216 private let inspectionService = DomainInspectionService()
213217 private(set) var currentResultSource: LookupResultSource = .live
@@ -237,6 +241,8 @@ final class DomainViewModel {
237241 var historyDateFilter: HistoryDateFilter = .all
238242 var historyChangeFilter: ChangeFilterOption = .all
239243 var historySortOption: HistorySortOption = .newest
244 var timelineGrouping: TimelineGroupingOption = .relativeDay
245 var timelineDomainFilter = ""
240246 var watchlistSearchText = ""
241247 var watchlistFilter: WatchlistFilterOption = .all
242248 var watchlistSortOption: WatchlistSortOption = .pinned
@@ -249,6 +255,12 @@ final class DomainViewModel {
249255 var portabilityStatusMessage: String?
250256 var upgradePrompt: UpgradePromptContext?
251257 var isPaywallPresented = false
258 var selectedSnapshotIDs = Set<UUID>()
259 var activeDomainDiff: DomainDiff?
260 var activeDiffChangeIndex = 0
261
262 private static let historyAutoPruneKey = "historyAutoPrune"
263 var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption()
252264
253265 var trimmedDomain: String {
254266 domain
@@ -365,6 +377,15 @@ final class DomainViewModel {
365377 return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
366378 }
367379
380 var timelineDomains: [String] {
381 let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines)
382 let domains = history.map(\.domain)
383 let filtered = query.isEmpty
384 ? domains
385 : domains.filter { $0.localizedCaseInsensitiveContains(query) }
386 return Array(Set(filtered)).sorted()
387 }
388
368389 var batchProgressLabel: String {
369390 guard batchTotalCount > 0 else { return "No active batch" }
370391 if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
@@ -441,6 +462,10 @@ final class DomainViewModel {
441462 isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false,
442463 validationIssues: currentHistoryEntry?.validationIssues ?? [],
443464 totalLookupDurationMs: lastLookupDurationMs,
465 snapshotIndex: currentHistoryEntry?.snapshotIndex,
466 previousSnapshotID: currentHistoryEntry?.previousSnapshotID,
467 changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0,
468 severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity,
444469 dnsSections: dnsSections,
445470 dnsError: dnsError,
446471 availabilityResult: availabilityResult,
@@ -1372,6 +1397,22 @@ final class DomainViewModel {
13721397 )
13731398 }
13741399
1400 func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String {
1401 DomainReportExporter.timelineText(
1402 for: timelineReports(for: domain),
1403 domain: domain,
1404 includeDiffSummary: includeDiffSummary
1405 )
1406 }
1407
1408 func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? {
1409 try? DomainReportExporter.timelineData(
1410 for: timelineReports(for: domain),
1411 domain: domain,
1412 includeDiffSummary: includeDiffSummary
1413 )
1414 }
1415
13751416 func exportFullBackupData() -> Data? {
13761417 try? DomainDataPortabilityService.backupData()
13771418 }
@@ -1461,20 +1502,25 @@ final class DomainViewModel {
14611502 }
14621503
14631504 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
1505 let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain)
14641506 let previous = previousSnapshot(
14651507 for: domain,
14661508 trackedDomainID: currentTrackedDomain?.id,
14671509 replacingLatest: false
14681510 )
14691511 let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
1512 DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)")
14701513 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
14711514
14721515 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
1516 let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain)
14731517 applySnapshot(snapshot)
1518 DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain)
14741519 lastLookupDurationMs = snapshot.totalLookupDurationMs
14751520 refreshingTrackedDomainID = nil
14761521
14771522 if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil {
1523 DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)")
14781524 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
14791525 }
14801526
@@ -1483,8 +1529,11 @@ final class DomainViewModel {
14831529 return history.first(where: { $0.id == snapshot.historyEntryID })
14841530 }
14851531
1486 let entry = saveHistoryEntry(replaceLatest: false)
1532 let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain)
1533 let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true)
1534 DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain)
14871535 await refreshUsageCredits()
1536 DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain)
14881537 return entry
14891538 }
14901539
@@ -1496,6 +1545,7 @@ final class DomainViewModel {
14961545 currentStatusMessage = snapshot.statusMessage
14971546 currentDiffSections = []
14981547 ownershipDiff = []
1548 let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain)
14991549 currentReport = reportBuilder.build(
15001550 from: snapshot,
15011551 previousSnapshot: previousSnapshot(
@@ -1504,6 +1554,7 @@ final class DomainViewModel {
15041554 replacingLatest: false
15051555 )
15061556 )
1557 DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
15071558 currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
15081559
15091560 dnsSections = snapshot.dnsSections
@@ -1596,6 +1647,10 @@ final class DomainViewModel {
15961647 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
15971648 validationIssues: previousSnapshot.validationIssues,
15981649 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
1650 snapshotIndex: previousSnapshot.snapshotIndex,
1651 previousSnapshotID: previousSnapshot.previousSnapshotID,
1652 changeCount: previousSnapshot.changeCount,
1653 severitySummary: previousSnapshot.severitySummary,
15991654 dnsSections: previousSnapshot.dnsSections,
16001655 dnsError: previousSnapshot.dnsError,
16011656 availabilityResult: previousSnapshot.availabilityResult,
@@ -1956,32 +2011,61 @@ final class DomainViewModel {
19562011 }
19572012
19582013 @discardableResult
1959 private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
2014 private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? {
19602015 guard !searchedDomain.isEmpty else { return nil }
1961 return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
2016 return saveHistoryEntry(
2017 from: currentSnapshot,
2018 replaceLatest: replaceLatest,
2019 updateCurrentState: true,
2020 reuseCurrentAnalysis: reuseCurrentAnalysis
2021 )
19622022 }
19632023
19642024 @discardableResult
1965 private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
2025 private func saveHistoryEntry(
2026 from snapshot: LookupSnapshot,
2027 replaceLatest: Bool,
2028 updateCurrentState: Bool,
2029 reuseCurrentAnalysis: Bool = false
2030 ) -> HistoryEntry? {
19662031 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
19672032 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1968 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
1969 let changeSummary = previousSnapshot.map {
1970 DomainDiffService.summary(
1971 from: $0,
1972 to: snapshot,
1973 generatedAt: snapshot.timestamp,
1974 riskAssessment: analysis.riskAssessment,
1975 insights: analysis.insights
1976 )
1977 }
1978 let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
2033 let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
2034 let changeSummary = reuseCurrentAnalysis
2035 ? currentChangeSummary ?? snapshot.changeSummary
2036 : previousSnapshot.map {
2037 DiffService.summary(
2038 from: $0,
2039 to: snapshot,
2040 generatedAt: snapshot.timestamp,
2041 riskAssessment: analysis?.riskAssessment,
2042 insights: analysis?.insights
2043 )
2044 }
2045 let domainDiff = reuseCurrentAnalysis
2046 ? previousSnapshot.map {
2047 DomainDiff(
2048 domain: snapshot.domain,
2049 fromTimestamp: $0.timestamp,
2050 toTimestamp: snapshot.timestamp,
2051 sections: currentDiffSections,
2052 changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id),
2053 changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title),
2054 contextNote: currentChangeSummary?.contextNote
2055 )
2056 }
2057 : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) }
2058 let diffSections = domainDiff?.sections ?? currentDiffSections
2059 let previousSnapshotID = previousSnapshot?.historyEntryID
2060 let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID)
19792061
19802062 if updateCurrentState {
19812063 currentChangeSummary = changeSummary
19822064 currentDiffSections = diffSections
19832065 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
1984 currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
2066 if !reuseCurrentAnalysis {
2067 currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
2068 }
19852069 }
19862070
19872071 let entry = HistoryEntry(
@@ -2029,6 +2113,10 @@ final class DomainViewModel {
20292113 emailSecuritySummary: Self.emailSummary(from: snapshot),
20302114 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
20312115 changeSummary: changeSummary,
2116 snapshotIndex: nextSnapshotIndex,
2117 previousSnapshotID: previousSnapshotID,
2118 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
2119 severitySummary: changeSummary?.severity,
20322120 sslError: snapshot.sslError,
20332121 httpHeadersError: snapshot.httpHeadersError,
20342122 reachabilityError: snapshot.reachabilityError,
@@ -2053,9 +2141,7 @@ final class DomainViewModel {
20532141 history[0] = entry
20542142 } else {
20552143 history.insert(entry, at: 0)
2056 if history.count > Self.maxHistory {
2057 history = Array(history.prefix(Self.maxHistory))
2058 }
2144 trimHistoryToLimit()
20592145 }
20602146
20612147 updateTrackedDomainSnapshotMetadata(
@@ -2074,8 +2160,21 @@ final class DomainViewModel {
20742160 }
20752161
20762162 private func persistHistory() {
2163 if historyPersistenceSuspended {
2164 historyPersistenceDirty = true
2165 return
2166 }
2167 let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory")
20772168 DomainDataPortabilityService.saveHistoryEntries(history)
20782169 refreshDataLifecycleSummary()
2170 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
2171 }
2172
2173 func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) {
2174 historyAutoPruneOption = option
2175 UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey)
2176 trimHistoryToLimit()
2177 persistHistory()
20792178 }
20802179
20812180 func updateHistoryNote(_ note: String, for entry: HistoryEntry) {
@@ -2085,11 +2184,56 @@ final class DomainViewModel {
20852184 }
20862185
20872186 private func persistTrackedDomains() {
2187 if trackedDomainsPersistenceSuspended {
2188 trackedDomainsPersistenceDirty = true
2189 return
2190 }
20882191 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
20892192 CloudSyncService.shared.scheduleSyncIfNeeded()
20902193 refreshDataLifecycleSummary()
20912194 }
20922195
2196 private func beginBulkPersistenceDeferral() {
2197 historyPersistenceSuspended = true
2198 trackedDomainsPersistenceSuspended = true
2199 historyPersistenceDirty = false
2200 trackedDomainsPersistenceDirty = false
2201 }
2202
2203 private func endBulkPersistenceDeferral() {
2204 historyPersistenceSuspended = false
2205 trackedDomainsPersistenceSuspended = false
2206
2207 if trackedDomainsPersistenceDirty {
2208 trackedDomainsPersistenceDirty = false
2209 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
2210 CloudSyncService.shared.scheduleSyncIfNeeded()
2211 }
2212
2213 if historyPersistenceDirty {
2214 historyPersistenceDirty = false
2215 DomainDataPortabilityService.saveHistoryEntries(history)
2216 }
2217
2218 refreshDataLifecycleSummary()
2219 }
2220
2221 private func trimHistoryToLimit() {
2222 let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory
2223 history = Array(history.prefix(min(hardLimit, Self.maxHistory)))
2224 }
2225
2226 private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int {
2227 let siblings = history.filter { entry in
2228 if let trackedDomainID {
2229 return entry.trackedDomainID == trackedDomainID
2230 }
2231 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
2232 }
2233 let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count
2234 return existingMax + 1
2235 }
2236
20932237 private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
20942238 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
20952239 MonitoringStorage.saveSettings(monitoringSettings)
@@ -2328,6 +2472,7 @@ final class DomainViewModel {
23282472 private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
23292473 let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
23302474 var nextIndex = 0
2475 beginBulkPersistenceDeferral()
23312476
23322477 await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
23332478 for _ in 0..<concurrencyLimit {
@@ -2348,6 +2493,7 @@ final class DomainViewModel {
23482493 }
23492494 }
23502495
2496 endBulkPersistenceDeferral()
23512497 finishBatchLookup(source: source)
23522498 }
23532499
@@ -2396,7 +2542,7 @@ final class DomainViewModel {
23962542 }
23972543 }
23982544 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
2399 let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
2545 let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
24002546 let quickStatus: String
24012547 if entry?.changeSummary?.hasChanges == true {
24022548 quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
@@ -2646,15 +2792,105 @@ final class DomainViewModel {
26462792 }
26472793
26482794 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
2649 let siblings = history.filter { candidate in
2650 if let trackedDomainID = entry.trackedDomainID {
2651 return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
2795 previousHistoryEntry(for: entry)?.snapshot
2796 }
2797
2798 func timelineEntries(for domain: String) -> [SnapshotSummary] {
2799 historyEntries(for: domain).map(\.snapshotSummary)
2800 }
2801
2802 func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] {
2803 let entries = timelineEntries(for: domain)
2804 let grouping = grouping ?? timelineGrouping
2805
2806 guard grouping == .relativeDay else {
2807 return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)]
2808 }
2809
2810 let calendar = Calendar.current
2811 let today = Date()
2812 let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
2813 let grouped = Dictionary(grouping: entries) { entry -> String in
2814 if calendar.isDate(entry.timestamp, inSameDayAs: today) {
2815 return "Today"
26522816 }
2653 return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
2817 if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) {
2818 return "Yesterday"
2819 }
2820 return "Older"
2821 }
2822
2823 return ["Today", "Yesterday", "Older"].compactMap { title in
2824 guard let items = grouped[title], !items.isEmpty else { return nil }
2825 return TimelineSection(id: title.lowercased(), title: title, entries: items)
2826 }
2827 }
2828
2829 func historyEntries(for domain: String) -> [HistoryEntry] {
2830 history
2831 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2832 .sorted { $0.timestamp > $1.timestamp }
2833 }
2834
2835 func historyEntry(withID id: UUID) -> HistoryEntry? {
2836 history.first(where: { $0.id == id })
2837 }
2838
2839 func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? {
2840 let siblings = historyEntries(for: entry.domain)
2841 guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil }
2842 let nextIndex = index + 1
2843 guard siblings.indices.contains(nextIndex) else { return nil }
2844 return siblings[nextIndex]
2845 }
2846
2847 func toggleSnapshotSelection(_ entry: HistoryEntry) {
2848 if selectedSnapshotIDs.contains(entry.id) {
2849 selectedSnapshotIDs.remove(entry.id)
2850 } else if selectedSnapshotIDs.count < 2 {
2851 selectedSnapshotIDs.insert(entry.id)
2852 } else if let oldest = selectedSnapshotIDs.first {
2853 selectedSnapshotIDs.remove(oldest)
2854 selectedSnapshotIDs.insert(entry.id)
2855 }
2856 }
2857
2858 func clearSnapshotSelection() {
2859 selectedSnapshotIDs.removeAll()
2860 }
2861
2862 var selectedSnapshots: [HistoryEntry] {
2863 selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp }
2864 }
2865
2866 @discardableResult
2867 func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? {
2868 guard selectedSnapshots.count == 2 else {
2869 activeDomainDiff = nil
2870 activeDiffChangeIndex = 0
2871 return nil
2872 }
2873
2874 let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot)
2875 activeDomainDiff = diff
2876 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
2877 activeDiffChangeIndex = index
2878 } else {
2879 activeDiffChangeIndex = 0
26542880 }
2655 .sorted { $0.timestamp > $1.timestamp }
2881 return diff
2882 }
26562883
2657 return siblings.first?.snapshot
2884 func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff {
2885 selectedSnapshotIDs = [olderEntry.id, newerEntry.id]
2886 let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot)
2887 activeDomainDiff = diff
2888 if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) {
2889 activeDiffChangeIndex = index
2890 } else {
2891 activeDiffChangeIndex = 0
2892 }
2893 return diff
26582894 }
26592895
26602896 func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
@@ -2662,6 +2898,23 @@ final class DomainViewModel {
26622898 return history.first(where: { $0.id == historyEntryID })
26632899 }
26642900
2901 var currentDiffTargetSectionID: String? {
2902 guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else {
2903 return nil
2904 }
2905 return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex]
2906 }
2907
2908 func moveToNextDiffChange() {
2909 guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return }
2910 activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1)
2911 }
2912
2913 func moveToPreviousDiffChange() {
2914 guard activeDomainDiff != nil else { return }
2915 activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0)
2916 }
2917
26652918 private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
26662919 let latestEntries = latestSnapshots(for: domains)
26672920
@@ -2709,6 +2962,10 @@ final class DomainViewModel {
27092962 }
27102963 }
27112964
2965 private func timelineReports(for domain: String) -> [DomainReport] {
2966 historyEntries(for: domain).map { report(for: $0) }
2967 }
2968
27122969 private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
27132970 reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
27142971 }
@@ -2745,6 +3002,10 @@ final class DomainViewModel {
27453002 isPartialSnapshot: true,
27463003 validationIssues: ["No stored snapshot data available"],
27473004 totalLookupDurationMs: nil,
3005 snapshotIndex: nil,
3006 previousSnapshotID: nil,
3007 changeCount: 0,
3008 severitySummary: trackedDomain.lastChangeSeverity,
27483009 dnsSections: [],
27493010 dnsError: nil,
27503011 availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
@@ -2795,6 +3056,14 @@ final class DomainViewModel {
27953056 return DomainDataPortabilityService.loadHistoryEntries()
27963057 }
27973058
3059 private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption {
3060 guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey),
3061 let option = HistoryAutoPruneOption(rawValue: rawValue) else {
3062 return .unlimited
3063 }
3064 return option
3065 }
3066
27983067 private static func loadTrackedDomains() -> [TrackedDomain] {
27993068 DataMigrationService.migrateIfNeeded()
28003069 return DomainDataPortabilityService.loadTrackedDomains()
DomainDig/HistoryView.swift +38 −47
@@ -7,8 +7,12 @@ struct HistoryView: View {
77 @State private var showClearAllConfirmation = false
88 @State private var showWorkflowAddSheet = false
99
10 private var groupedHistory: [HistoryGroup] {
11 HistoryGroup.groups(for: viewModel.filteredHistory)
10 private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] {
11 viewModel.timelineDomains.compactMap { domain in
12 let entries = viewModel.timelineEntries(for: domain)
13 guard let latest = entries.first else { return nil }
14 return (domain, latest, entries.count)
15 }
1216 }
1317
1418 var body: some View {
@@ -22,55 +26,41 @@ struct HistoryView: View {
2226 )
2327 .listRowBackground(Color(.systemGray6).opacity(0.5))
2428 } else {
25 ForEach(groupedHistory) { group in
26 Section(group.title) {
27 ForEach(group.entries) { entry in
28 NavigationLink {
29 HistoryDetailView(viewModel: viewModel, entry: entry)
30 } label: {
31 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
32 HStack(alignment: .center, spacing: 8) {
33 Text(entry.domain)
34 .font(appDensity.font(.callout))
35 .foregroundStyle(.primary)
36 Spacer()
37 AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary))
38 }
39
40 HStack(spacing: 8) {
41 AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status))
42 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError))
43 if entry.isPartialSnapshot {
44 AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)))
45 }
46 }
29 Section("Domains") {
30 ForEach(domainSummaries, id: \.domain) { item in
31 NavigationLink {
32 TimelineView(viewModel: viewModel, domain: item.domain)
33 } label: {
34 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
35 HStack(alignment: .center, spacing: 8) {
36 Text(item.domain)
37 .font(appDensity.font(.callout))
38 .foregroundStyle(.primary)
39 Spacer()
40 Text("\(item.count) snapshots")
41 .font(appDensity.font(.caption2))
42 .foregroundStyle(.secondary)
43 }
4744
48 HStack(spacing: 8) {
49 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
50 Text(entry.timestamp.formatted(.relative(presentation: .named)))
51 Text(entry.resolverDisplayName)
52 if let totalLookupDurationMs = entry.totalLookupDurationMs {
53 Text("\(totalLookupDurationMs) ms")
54 }
55 }
56 .font(appDensity.font(.caption2))
45 Text(item.latest.changeSummaryMessage ?? "No change summary")
46 .font(appDensity.font(.caption))
5747 .foregroundStyle(.secondary)
48 .lineLimit(2)
5849
59 if let note = entry.note, !note.isEmpty {
60 Text(note)
61 .font(appDensity.font(.caption2))
62 .foregroundStyle(.secondary)
63 .lineLimit(1)
50 HStack(spacing: 8) {
51 AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability))
52 if let severity = item.latest.severitySummary {
53 AppStatusBadgeView(
54 model: .init(
55 title: severity.title,
56 systemImage: "arrow.triangle.2.circlepath",
57 foregroundColor: severity == .high ? .red : .yellow,
58 backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
59 )
60 )
6461 }
6562 }
6663 }
67 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
68 Button(role: .destructive) {
69 viewModel.removeHistoryEntries(withIDs: [entry.id])
70 } label: {
71 Label("Delete", systemImage: "trash")
72 }
73 }
7464 }
7565 .listRowBackground(Color(.systemGray6).opacity(0.5))
7666 }
@@ -80,7 +70,7 @@ struct HistoryView: View {
8070 .scrollContentBackground(.hidden)
8171 .background(Color.black)
8272 .navigationTitle("History")
83 .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
73 .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains")
8474 .toolbar {
8575 if !viewModel.history.isEmpty {
8676 ToolbarItemGroup(placement: .topBarTrailing) {
@@ -244,7 +234,8 @@ struct HistoryDetailView: View {
244234 title: "Compared With Previous Snapshot",
245235 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
246236 contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot),
247 showsUnchanged: false
237 showsUnchanged: false,
238 highlightedSectionID: nil
248239 )
249240 .padding(.top, appDensity.metrics.sectionSpacing)
250241 }
DomainDig/Models.swift +113 −1
@@ -541,6 +541,83 @@ enum HistorySortOption: String, CaseIterable, Identifiable {
541541 }
542542}
543543
544enum TimelineGroupingOption: String, CaseIterable, Identifiable {
545 case none
546 case relativeDay
547
548 var id: String { rawValue }
549
550 var title: String {
551 switch self {
552 case .none:
553 return "Ungrouped"
554 case .relativeDay:
555 return "Today / Yesterday / Older"
556 }
557 }
558}
559
560enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable {
561 case keep50
562 case keep100
563 case unlimited
564
565 var id: String { rawValue }
566
567 var title: String {
568 switch self {
569 case .keep50:
570 return "Keep Last 50"
571 case .keep100:
572 return "Keep Last 100"
573 case .unlimited:
574 return "Unlimited"
575 }
576 }
577
578 var keepCount: Int? {
579 switch self {
580 case .keep50:
581 return 50
582 case .keep100:
583 return 100
584 case .unlimited:
585 return nil
586 }
587 }
588}
589
590struct SnapshotSummary: Identifiable, Codable, Equatable {
591 let id: UUID
592 let domain: String
593 let timestamp: Date
594 let trackedDomainID: UUID?
595 let snapshotIndex: Int?
596 let previousSnapshotID: UUID?
597 let changeCount: Int
598 let severitySummary: ChangeSeverity?
599 let changeSummaryMessage: String?
600 let availability: DomainAvailabilityStatus?
601 let primaryIP: String?
602 let tlsStatus: String?
603 let riskScore: Int?
604 let historyEntryID: UUID
605
606 var hasChanges: Bool {
607 changeCount > 0
608 }
609}
610
611struct FullSnapshot: Codable {
612 let historyEntry: HistoryEntry
613}
614
615struct TimelineSection: Identifiable, Equatable {
616 let id: String
617 let title: String
618 let entries: [SnapshotSummary]
619}
620
544621enum WatchlistFilterOption: String, CaseIterable, Identifiable {
545622 case all
546623 case pinnedOnly
@@ -1304,6 +1381,10 @@ struct HistoryEntry: Identifiable, Codable {
13041381 var emailSecuritySummary: String?
13051382 var httpGradeSummary: String?
13061383 var changeSummary: DomainChangeSummary?
1384 var snapshotIndex: Int?
1385 var previousSnapshotID: UUID?
1386 var changeCount: Int
1387 var severitySummary: ChangeSeverity?
13071388 var sslError: String?
13081389 var httpHeadersError: String?
13091390 var reachabilityError: String?
@@ -1339,7 +1420,8 @@ struct HistoryEntry: Identifiable, Codable {
13391420 validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
13401421 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
13411422 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
1342 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
1423 changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
1424 changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
13431425 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
13441426 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
13451427 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
@@ -1389,6 +1471,10 @@ struct HistoryEntry: Identifiable, Codable {
13891471 self.emailSecuritySummary = emailSecuritySummary
13901472 self.httpGradeSummary = httpGradeSummary
13911473 self.changeSummary = changeSummary
1474 self.snapshotIndex = snapshotIndex
1475 self.previousSnapshotID = previousSnapshotID
1476 self.changeCount = changeCount
1477 self.severitySummary = severitySummary
13921478 self.sslError = sslError
13931479 self.httpHeadersError = httpHeadersError
13941480 self.reachabilityError = reachabilityError
@@ -1452,6 +1538,13 @@ struct HistoryEntry: Identifiable, Codable {
14521538 emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
14531539 httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
14541540 changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
1541 snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
1542 previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
1543 changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
1544 ?? changeSummary?.changedSections.count
1545 ?? 0
1546 severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
1547 ?? changeSummary?.severity
14551548 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
14561549 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
14571550 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
@@ -1478,6 +1571,25 @@ struct HistoryEntry: Identifiable, Codable {
14781571 }
14791572 return issues
14801573 }
1574
1575 var snapshotSummary: SnapshotSummary {
1576 SnapshotSummary(
1577 id: id,
1578 domain: domain,
1579 timestamp: timestamp,
1580 trackedDomainID: trackedDomainID,
1581 snapshotIndex: snapshotIndex,
1582 previousSnapshotID: previousSnapshotID,
1583 changeCount: changeCount,
1584 severitySummary: severitySummary ?? changeSummary?.severity,
1585 changeSummaryMessage: changeSummary?.message,
1586 availability: availabilityResult?.status,
1587 primaryIP: primaryIP,
1588 tlsStatus: tlsStatusSummary,
1589 riskScore: changeSummary?.riskAssessment?.score,
1590 historyEntryID: id
1591 )
1592 }
14811593}
14821594
14831595// MARK: - Cloudflare DNS-over-HTTPS Response
DomainDig/PortScanService.swift +1 −1
@@ -124,7 +124,7 @@ struct PortScanService {
124124 }
125125
126126 private static func probe(domain: String, port: UInt16) async -> PortProbeResult {
127 await probe(domain: domain, port: port, timeout: 5)
127 await probe(domain: domain, port: port, timeout: 1.5)
128128 }
129129
130130 private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult {
DomainDig/RDAPService.swift +10
@@ -48,6 +48,7 @@ enum RDAPService {
4848}
4949
5050private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
51 let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain)
5152 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
5253 return .error("Unavailable")
5354 }
@@ -55,9 +56,11 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
5556 do {
5657 var request = URLRequest(url: url, timeoutInterval: 8)
5758 request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
59 DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8")
5860
5961 let (data, response) = try await URLSession.shared.data(for: request)
6062 guard let httpResponse = response as? HTTPURLResponse else {
63 DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil")
6164 return .error(URLError(.badServerResponse).localizedDescription)
6265 }
6366
@@ -66,15 +69,22 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo
6669 let decoder = JSONDecoder()
6770 let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
6871 guard rdapResponse.isDomainRecord else {
72 DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord")
6973 return .empty("Unavailable")
7074 }
75 DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)")
7176 return .success(rdapResponse)
7277 case 404:
78 DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404")
7379 return .empty("Unavailable")
7480 default:
81 DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)")
82 DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)")
7583 return .error("Unavailable")
7684 }
7785 } catch {
86 DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)")
87 DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error")
7888 return .error(error.localizedDescription)
7989 }
8090}
DomainDig/ReachabilityService.swift +1 −1
@@ -23,7 +23,7 @@ struct ReachabilityService {
2323 let queue = DispatchQueue(label: "reachability.\(port)")
2424 connection.start(queue: queue)
2525
26 queue.asyncAfter(deadline: .now() + 5) {
26 queue.asyncAfter(deadline: .now() + 2) {
2727 context.finish(reachable: false)
2828 }
2929 }
DomainDig/SubdomainDiscoveryService.swift +11
@@ -17,6 +17,7 @@ enum SubdomainDiscoveryService {
1717 }
1818
1919 private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
20 let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain)
2021 var components = URLComponents(string: "https://crt.sh/")!
2122 components.queryItems = [
2223 URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -29,15 +30,25 @@ enum SubdomainDiscoveryService {
2930
3031 do {
3132 let request = URLRequest(url: url, timeoutInterval: 10)
33 DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10")
3234 let (data, response) = try await URLSession.shared.data(for: request)
3335 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
36 DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)")
3437 return .error("Subdomain discovery unavailable")
3538 }
3639
3740 let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
3841 let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
42 DomainDebugLog.signpostEnd(
43 "SubdomainDiscovery.fetch",
44 start: startedAt,
45 domain: domain,
46 extra: "entries=\(entries.count) subdomains=\(subdomains.count)"
47 )
3948 return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
4049 } catch {
50 DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)")
51 DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error")
4152 return .error(error.localizedDescription)
4253 }
4354 }
DomainDig/TimelineView.swift added +224
@@ -0,0 +1,224 @@
1import SwiftUI
2
3struct TimelineView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 let domain: String
7
8 @State private var presentedDiff: DomainDiff?
9 @State private var focusedSectionID: String?
10
11 private var timelineSections: [TimelineSection] {
12 viewModel.timelineSections(for: domain)
13 }
14
15 var body: some View {
16 List {
17 ForEach(timelineSections) { section in
18 Section(section.title) {
19 ForEach(section.entries) { summary in
20 if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
21 NavigationLink {
22 HistoryDetailView(viewModel: viewModel, entry: entry)
23 } label: {
24 TimelineRow(summary: summary)
25 }
26 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
27 Button {
28 viewModel.toggleSnapshotSelection(entry)
29 } label: {
30 Label(
31 viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
32 systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
33 )
34 }
35
36 Button(role: .destructive) {
37 viewModel.removeHistoryEntries(withIDs: [entry.id])
38 } label: {
39 Label("Delete", systemImage: "trash")
40 }
41 }
42 }
43 }
44 .listRowBackground(Color(.systemGray6).opacity(0.5))
45 }
46 }
47 }
48 .scrollContentBackground(.hidden)
49 .background(Color.black)
50 .navigationTitle(domain)
51 .toolbar {
52 ToolbarItemGroup(placement: .topBarTrailing) {
53 Menu {
54 Picker("Grouping", selection: $viewModel.timelineGrouping) {
55 ForEach(TimelineGroupingOption.allCases) { option in
56 Text(option.title).tag(option)
57 }
58 }
59 } label: {
60 Image(systemName: "line.3.horizontal.decrease.circle")
61 }
62
63 Button("Compare") {
64 presentedDiff = viewModel.generateDiffForSelectedSnapshots()
65 focusedSectionID = viewModel.currentDiffTargetSectionID
66 }
67 .disabled(viewModel.selectedSnapshots.count != 2)
68
69 Menu("Export") {
70 Button("Export TXT") {
71 ExportPresenter.share(
72 filename: "\(domain)-timeline.txt",
73 contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
74 )
75 }
76
77 Button("Export JSON") {
78 guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
79 ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
80 }
81 }
82 }
83 }
84 .sheet(item: $presentedDiff) { diff in
85 NavigationStack {
86 TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
87 }
88 }
89 }
90}
91
92private struct TimelineRow: View {
93 @Environment(\.appDensity) private var appDensity
94 let summary: SnapshotSummary
95
96 var body: some View {
97 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
98 HStack(alignment: .center, spacing: 8) {
99 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
100 .font(appDensity.font(.callout))
101 .foregroundStyle(.primary)
102 Spacer()
103 if let severity = summary.severitySummary {
104 AppStatusBadgeView(
105 model: .init(
106 title: severity.title,
107 systemImage: "arrow.triangle.2.circlepath",
108 foregroundColor: severity == .high ? .red : .yellow,
109 backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
110 )
111 )
112 }
113 }
114
115 Text(summary.changeSummaryMessage ?? "No change summary")
116 .font(appDensity.font(.caption))
117 .foregroundStyle(.secondary)
118 .lineLimit(2)
119
120 HStack(spacing: 8) {
121 AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
122 if let primaryIP = summary.primaryIP {
123 AppStatusBadgeView(
124 model: .init(
125 title: primaryIP,
126 systemImage: "network",
127 foregroundColor: .blue,
128 backgroundColor: .blue.opacity(0.16)
129 )
130 )
131 }
132 if let tlsStatus = summary.tlsStatus {
133 AppStatusBadgeView(
134 model: .init(
135 title: tlsStatus.capitalized,
136 systemImage: "lock.shield",
137 foregroundColor: .green,
138 backgroundColor: .green.opacity(0.16)
139 )
140 )
141 }
142 if let riskScore = summary.riskScore {
143 AppStatusBadgeView(
144 model: .init(
145 title: "Risk \(riskScore)",
146 systemImage: "exclamationmark.shield",
147 foregroundColor: riskScore >= 70 ? .red : .orange,
148 backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
149 )
150 )
151 }
152 }
153
154 HStack(spacing: 8) {
155 if let snapshotIndex = summary.snapshotIndex {
156 Text("#\(snapshotIndex)")
157 }
158 Text(summary.timestamp.formatted(.relative(presentation: .named)))
159 if summary.changeCount > 0 {
160 Text("\(summary.changeCount) changes")
161 }
162 }
163 .font(appDensity.font(.caption2))
164 .foregroundStyle(.secondary)
165 }
166 }
167}
168
169struct TimelineDiffView: View {
170 @Bindable var viewModel: DomainViewModel
171 let diff: DomainDiff
172 @Binding var focusedSectionID: String?
173
174 var body: some View {
175 ScrollViewReader { proxy in
176 ScrollView {
177 VStack(alignment: .leading, spacing: 12) {
178 HStack {
179 Button("Previous Change") {
180 viewModel.moveToPreviousDiffChange()
181 focusedSectionID = viewModel.currentDiffTargetSectionID
182 scroll(proxy: proxy)
183 }
184 .disabled(viewModel.activeDiffChangeIndex == 0)
185
186 Button("Next Change") {
187 viewModel.moveToNextDiffChange()
188 focusedSectionID = viewModel.currentDiffTargetSectionID
189 scroll(proxy: proxy)
190 }
191 .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
192
193 Spacer()
194 }
195
196 DomainDiffView(
197 title: "Snapshot Diff",
198 sections: diff.sections,
199 contextNote: diff.contextNote,
200 showsUnchanged: false,
201 highlightedSectionID: focusedSectionID
202 )
203 }
204 .padding()
205 }
206 .background(Color.black)
207 .navigationTitle("Compare Snapshots")
208 .navigationBarTitleDisplayMode(.inline)
209 .onAppear {
210 scroll(proxy: proxy)
211 }
212 .onChange(of: focusedSectionID) { _, _ in
213 scroll(proxy: proxy)
214 }
215 }
216 }
217
218 private func scroll(proxy: ScrollViewProxy) {
219 guard let focusedSectionID else { return }
220 withAnimation {
221 proxy.scrollTo(focusedSectionID, anchor: .top)
222 }
223 }
224}
DomainDig/WatchlistView.swift +2 −1
@@ -483,7 +483,8 @@ struct TrackedDomainDetailView: View {
483483 contextNote: latestSnapshots.count >= 2
484484 ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
485485 : nil,
486 showsUnchanged: false
486 showsUnchanged: false,
487 highlightedSectionID: nil
487488 )
488489 }
489490 .listRowBackground(Color.clear)
DomainDigCLI.swift +117
@@ -17,6 +17,16 @@ struct DomainDigCLI {
1717 return
1818 }
1919
20 if arguments.first == "history" {
21 runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
22 return
23 }
24
25 if arguments.first == "diff" {
26 runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON)
27 return
28 }
29
2030 if arguments.first == "monitor" {
2131 await runMonitorCommand(wantsJSON: wantsJSON)
2232 return
@@ -216,6 +226,10 @@ struct DomainDigCLI {
216226 isPartialSnapshot: snapshot.isPartialSnapshot,
217227 validationIssues: snapshot.validationIssues,
218228 totalLookupDurationMs: snapshot.totalLookupDurationMs,
229 snapshotIndex: snapshot.snapshotIndex,
230 previousSnapshotID: snapshot.previousSnapshotID,
231 changeCount: snapshot.changeCount,
232 severitySummary: snapshot.severitySummary,
219233 dnsSections: snapshot.dnsSections,
220234 dnsError: snapshot.dnsError,
221235 availabilityResult: snapshot.availabilityResult,
@@ -265,6 +279,107 @@ struct DomainDigCLI {
265279 DomainDataPortabilityService.loadHistoryEntries()
266280 }
267281
282 private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) {
283 guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
284 fputs("usage: domaindig history <domain> [--json]\n", stderr)
285 Foundation.exit(1)
286 }
287
288 let entries = loadHistoryEntries()
289 .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
290 .sorted { $0.timestamp > $1.timestamp }
291
292 if wantsJSON {
293 let encoder = JSONEncoder()
294 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
295 encoder.dateEncodingStrategy = .iso8601
296 let payload = entries.map { entry in
297 [
298 "id": entry.id.uuidString,
299 "timestamp": ISO8601DateFormatter().string(from: entry.timestamp),
300 "changeSummary": entry.changeSummary?.message ?? "No change summary",
301 "changeCount": "\(entry.changeCount)",
302 "severity": entry.severitySummary?.title ?? "N/A"
303 ]
304 }
305 if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) {
306 FileHandle.standardOutput.write(data)
307 FileHandle.standardOutput.write(Data([0x0A]))
308 return
309 }
310 }
311
312 let lines = entries.map { entry in
313 [
314 entry.id.uuidString,
315 entry.timestamp.formatted(date: .abbreviated, time: .shortened),
316 entry.changeSummary?.message ?? "No change summary"
317 ].joined(separator: " | ")
318 }
319
320 FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8))
321 }
322
323 private static func runDiffCommand(arguments: [String], wantsJSON: Bool) {
324 guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else {
325 fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr)
326 Foundation.exit(1)
327 }
328
329 guard let fromID = optionValue(named: "--from", in: arguments),
330 let toID = optionValue(named: "--to", in: arguments),
331 let fromUUID = UUID(uuidString: fromID),
332 let toUUID = UUID(uuidString: toID) else {
333 fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr)
334 Foundation.exit(1)
335 }
336
337 let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
338 guard let fromEntry = entries.first(where: { $0.id == fromUUID }),
339 let toEntry = entries.first(where: { $0.id == toUUID }) else {
340 fputs("domaindig diff: snapshots not found for domain\n", stderr)
341 Foundation.exit(1)
342 }
343
344 let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp }
345 let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot)
346
347 if wantsJSON {
348 let encoder = JSONEncoder()
349 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
350 encoder.dateEncodingStrategy = .iso8601
351 if let data = try? encoder.encode(diff) {
352 FileHandle.standardOutput.write(data)
353 FileHandle.standardOutput.write(Data([0x0A]))
354 return
355 }
356 }
357
358 var lines = [
359 "DomainDig Diff",
360 "Domain: \(domain)",
361 "From: \(orderedEntries[0].id.uuidString)",
362 "To: \(orderedEntries[1].id.uuidString)"
363 ]
364
365 for section in diff.sections where section.hasChanges {
366 lines.append("")
367 lines.append(section.title)
368 for item in section.items where item.hasChanges {
369 lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")")
370 }
371 }
372
373 FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8))
374 }
375
376 private static func optionValue(named name: String, in arguments: [String]) -> String? {
377 guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else {
378 return nil
379 }
380 return arguments[index + 1]
381 }
382
268383 private static func runBackupCommand(arguments: [String], wantsJSON: Bool) {
269384 guard let subcommand = arguments.first else {
270385 fputs(usageText, stderr)
@@ -424,6 +539,8 @@ struct DomainDigCLI {
424539 private static var usageText: String {
425540 """
426541 usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]
542 domaindig history <domain> [--json]
543 domaindig diff <domain> --from <id> --to <id> [--json]
427544 domaindig monitor [--json]
428545 domaindig backup export [path]
429546 domaindig backup import <path> [--replace]
DomainInspectionService.swift +72 −31
@@ -15,6 +15,7 @@ struct DomainInspectionService {
1515
1616 func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
1717 let normalizedDomain = normalize(domain)
18 let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain)
1819 let startedAt = Date()
1920 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
2021 let resolverURLString = DNSLookupService.currentResolverURLString()
@@ -29,13 +30,12 @@ struct DomainInspectionService {
2930 async let sslFetch = runtime.ssl(domain: normalizedDomain)
3031 async let hstsFetch = runtime.hsts(domain: normalizedDomain)
3132 async let httpFetch = runtime.http(domain: normalizedDomain)
32 async let reachabilityFetch = runtime.reachability(domain: normalizedDomain)
3333 async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
3434 async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
3535 async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
36 async let portScanFetch = runtime.portScan(domain: normalizedDomain)
3736
3837 let resolvedDNS = await dnsFetch
38 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)")
3939 let dnsResult = normalizeErrors(in: resolvedDNS.value)
4040 track(
4141 .dns,
@@ -49,6 +49,7 @@ struct DomainInspectionService {
4949 captureFailure(for: .dns, result: dnsResult, into: &errorDetails)
5050
5151 let availability = await availabilityFetch
52 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)")
5253 track(
5354 .availability,
5455 source: availability.source,
@@ -60,6 +61,7 @@ struct DomainInspectionService {
6061 )
6162
6263 let resolvedSSL = await sslFetch
64 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)")
6365 let sslResult = normalizeErrors(in: resolvedSSL.value)
6466 track(
6567 .ssl,
@@ -73,6 +75,7 @@ struct DomainInspectionService {
7375 captureFailure(for: .ssl, result: sslResult, into: &errorDetails)
7476
7577 let hsts = await hstsFetch
78 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)")
7679 track(
7780 .hsts,
7881 source: hsts.source,
@@ -84,6 +87,7 @@ struct DomainInspectionService {
8487 )
8588
8689 let http = await httpFetch
90 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)")
8791 let httpResult = normalizeErrors(in: http.value)
8892 track(
8993 .httpHeaders,
@@ -96,20 +100,8 @@ struct DomainInspectionService {
96100 )
97101 captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails)
98102
99 let reachability = await reachabilityFetch
100 let reachabilityResult = normalizeErrors(in: reachability.value)
101 track(
102 .reachability,
103 source: reachability.source,
104 provenance: provenance(for: .reachability, source: reachability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
105 cachedSections: &cachedSections,
106 sectionSources: &sectionSources,
107 provenanceBySection: &provenanceBySection,
108 dataSources: &dataSources
109 )
110 captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
111
112103 let resolvedOwnership = await ownershipFetch
104 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)")
113105 let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
114106 track(
115107 .ownership,
@@ -123,6 +115,7 @@ struct DomainInspectionService {
123115 captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails)
124116
125117 let redirects = await redirectFetch
118 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)")
126119 let redirectResult = normalizeErrors(in: redirects.value)
127120 track(
128121 .redirectChain,
@@ -136,6 +129,7 @@ struct DomainInspectionService {
136129 captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails)
137130
138131 let resolvedSubdomains = await subdomainFetch
132 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)")
139133 let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
140134 track(
141135 .subdomains,
@@ -148,33 +142,68 @@ struct DomainInspectionService {
148142 )
149143 captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails)
150144
151 let ports = await portScanFetch
152 let portScanResult = normalizeErrors(in: ports.value)
153 track(
154 .portScan,
155 source: ports.source,
156 provenance: provenance(for: .portScan, source: ports.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
157 cachedSections: &cachedSections,
158 sectionSources: &sectionSources,
159 provenanceBySection: &provenanceBySection,
160 dataSources: &dataSources
161 )
162 captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
163
164145 let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
165146 let sslInfo = mapOptionalValueServiceResult(sslResult)
166147 let httpHeadersResult = mapHTTPResult(httpResult)
167 let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
168148 let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
169149 let ownership = mapOptionalValueServiceResult(ownershipResult)
170150 let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
171 let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain)
172151
173152 let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
174153 let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
154 let hasNetworkTarget = dnsSections.value.contains { section in
155 (section.recordType == .A || section.recordType == .AAAA)
156 && (!section.records.isEmpty || !section.wildcardRecords.isEmpty)
157 }
175158 let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
176159 let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
177160
161 let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>>
162 if hasNetworkTarget {
163 reachabilityOutcome = await runtime.reachability(domain: normalizedDomain)
164 } else {
165 reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
166 }
167 let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value)
168 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)")
169 track(
170 .reachability,
171 source: reachabilityOutcome.source,
172 provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
173 cachedSections: &cachedSections,
174 sectionSources: &sectionSources,
175 provenanceBySection: &provenanceBySection,
176 dataSources: &dataSources
177 )
178 if hasNetworkTarget {
179 captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
180 }
181 let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
182
183 let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>>
184 if hasNetworkTarget {
185 portScanOutcome = await runtime.portScan(domain: normalizedDomain)
186 } else {
187 portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
188 }
189 let portScanResult = normalizeErrors(in: portScanOutcome.value)
190 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)")
191 track(
192 .portScan,
193 source: portScanOutcome.source,
194 provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
195 cachedSections: &cachedSections,
196 sectionSources: &sectionSources,
197 provenanceBySection: &provenanceBySection,
198 dataSources: &dataSources
199 )
200 if hasNetworkTarget {
201 captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
202 }
203 let portScanResults = hasNetworkTarget
204 ? await mapPortScanResult(portScanResult, domain: normalizedDomain)
205 : (value: [], message: nil)
206
178207 let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
179208 if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
180209 emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
@@ -184,6 +213,7 @@ struct DomainInspectionService {
184213 emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
185214 }
186215 let normalizedEmailResult = normalizeErrors(in: emailOutcome.value)
216 DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)")
187217 track(
188218 .emailSecurity,
189219 source: emailOutcome.source,
@@ -271,7 +301,7 @@ struct DomainInspectionService {
271301 let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message)
272302 let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection)
273303
274 return LookupSnapshot(
304 let snapshot = LookupSnapshot(
275305 historyEntryID: nil,
276306 domain: availability.value.domain,
277307 timestamp: Date(),
@@ -291,6 +321,10 @@ struct DomainInspectionService {
291321 isPartialSnapshot: !validationIssues.isEmpty,
292322 validationIssues: validationIssues,
293323 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
324 snapshotIndex: nil,
325 previousSnapshotID: previousSnapshot?.historyEntryID,
326 changeCount: 0,
327 severitySummary: nil,
294328 dnsSections: dnsSections.value,
295329 dnsError: dnsSections.message,
296330 availabilityResult: availability.value,
@@ -334,6 +368,13 @@ struct DomainInspectionService {
334368 cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
335369 statusMessage: nil
336370 )
371 DomainDebugLog.signpostEnd(
372 "Inspection.inspectSnapshot",
373 start: inspectionStartedAt,
374 domain: normalizedDomain,
375 extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)"
376 )
377 return snapshot
337378 }
338379
339380 private func normalize(_ domain: String) -> String {
DomainReportBuilder.swift +98 −21
@@ -62,6 +62,10 @@ struct DomainReportMetadata: Codable {
6262 let isPartialSnapshot: Bool
6363 let errorDetails: [LookupSectionKind: InspectionFailure]
6464 let statusMessage: String?
65 let snapshotIndex: Int?
66 let previousSnapshotID: UUID?
67 let changeCount: Int
68 let severitySummary: ChangeSeverity?
6569}
6670
6771struct DNSResultSummary: Codable {
@@ -123,26 +127,103 @@ struct DomainReportBuilder {
123127 func build(
124128 from snapshot: LookupSnapshot,
125129 previousSnapshot: LookupSnapshot? = nil,
126 workflowContext: DomainWorkflowContext? = nil
130 workflowContext: DomainWorkflowContext? = nil,
131 deriveChangeSummary: Bool = true
127132 ) -> DomainReport {
133 let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
128134 let primaryIP = primaryIPAddress(from: snapshot)
129135 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
130136 let changeSummary: DomainChangeSummary?
131137 if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil {
132138 changeSummary = existingChangeSummary
133 } else {
134 changeSummary = previousSnapshot.map {
135 DomainDiffService.summary(
136 from: $0,
137 to: snapshot,
138 generatedAt: snapshot.timestamp,
139 riskAssessment: analysis.riskAssessment,
140 insights: analysis.insights
141 )
139 } else if deriveChangeSummary, let previousSnapshot {
140 let previousReport = build(
141 from: previousSnapshot,
142 workflowContext: workflowContext,
143 deriveChangeSummary: false
144 )
145 let currentReport = buildBaseReport(
146 from: snapshot,
147 previousSnapshot: previousSnapshot,
148 workflowContext: workflowContext,
149 analysis: analysis,
150 primaryIP: primaryIP,
151 changeSummary: nil as DomainChangeSummary?
152 )
153 let diff = DiffService.compare(from: previousReport, to: currentReport)
154 let changedItems = diff.sections.flatMap { $0.items }.filter { $0.hasChanges }
155 let highlights = diff.changedSectionTitles
156 let severity = changedItems.map { $0.severity }.max() ?? .low
157 let message = DiffService.summaryMessage(from: highlights, changeCount: changedItems.count)
158 let observedFacts = changedItems.prefix(4).map { item in
159 "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
142160 }
161 let previousRiskScore = previousReport.riskAssessment.score
162 let riskScoreDelta = analysis.riskAssessment.score - previousRiskScore
163 let impactClassification = DomainInsightEngine.impactClassification(
164 severity: severity,
165 riskDelta: riskScoreDelta,
166 changedSections: highlights
167 )
168
169 changeSummary = DomainChangeSummary(
170 hasChanges: !changedItems.isEmpty,
171 changedSections: highlights,
172 message: message,
173 severity: severity,
174 impactClassification: impactClassification,
175 generatedAt: snapshot.timestamp,
176 observedFacts: observedFacts,
177 inferredConclusions: highlights.isEmpty ? [] : [message],
178 contextNote: diff.contextNote,
179 riskAssessment: analysis.riskAssessment,
180 insights: analysis.insights,
181 riskScoreDelta: riskScoreDelta
182 )
183 } else {
184 changeSummary = nil
143185 }
144186
145 return DomainReport(
187 let report = buildBaseReport(
188 from: snapshot,
189 previousSnapshot: previousSnapshot,
190 workflowContext: workflowContext,
191 analysis: analysis,
192 primaryIP: primaryIP,
193 changeSummary: changeSummary
194 )
195 DomainDebugLog.signpostEnd(
196 "DomainReportBuilder.build",
197 start: buildStartedAt,
198 domain: snapshot.domain,
199 extra: "risk=\(report.riskAssessment.score) insights=\(report.insights.count)"
200 )
201 return report
202 }
203
204 func build(
205 from entry: HistoryEntry,
206 previousSnapshot: LookupSnapshot? = nil,
207 workflowContext: DomainWorkflowContext? = nil,
208 deriveChangeSummary: Bool = true
209 ) -> DomainReport {
210 build(
211 from: entry.snapshot,
212 previousSnapshot: previousSnapshot,
213 workflowContext: workflowContext,
214 deriveChangeSummary: deriveChangeSummary
215 )
216 }
217
218 private func buildBaseReport(
219 from snapshot: LookupSnapshot,
220 previousSnapshot: LookupSnapshot?,
221 workflowContext: DomainWorkflowContext?,
222 analysis: DomainAnalysisBundle,
223 primaryIP: String?,
224 changeSummary: DomainChangeSummary?
225 ) -> DomainReport {
226 DomainReport(
146227 domain: snapshot.domain,
147228 timestamp: snapshot.timestamp,
148229 provenance: DomainReportProvenance(
@@ -230,7 +311,7 @@ struct DomainReportBuilder {
230311 changeSummary: changeSummary,
231312 workflowContext: workflowContext,
232313 metadata: DomainReportMetadata(
233 schemaVersion: "3.2.0",
314 schemaVersion: "3.7.0",
234315 resolverDisplayName: snapshot.resolverDisplayName,
235316 resolverURLString: snapshot.resolverURLString,
236317 appVersion: snapshot.appVersion,
@@ -239,19 +320,15 @@ struct DomainReportBuilder {
239320 validationIssues: snapshot.validationIssues,
240321 isPartialSnapshot: snapshot.isPartialSnapshot,
241322 errorDetails: snapshot.errorDetails,
242 statusMessage: snapshot.statusMessage
323 statusMessage: snapshot.statusMessage,
324 snapshotIndex: snapshot.snapshotIndex,
325 previousSnapshotID: snapshot.previousSnapshotID ?? previousSnapshot?.historyEntryID,
326 changeCount: snapshot.changeCount == 0 ? (changeSummary?.changedSections.count ?? 0) : snapshot.changeCount,
327 severitySummary: snapshot.severitySummary ?? changeSummary?.severity
243328 )
244329 )
245330 }
246331
247 func build(
248 from entry: HistoryEntry,
249 previousSnapshot: LookupSnapshot? = nil,
250 workflowContext: DomainWorkflowContext? = nil
251 ) -> DomainReport {
252 build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext)
253 }
254
255332 private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
256333 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
257334 }
DomainReportExporter.swift +56
@@ -504,6 +504,62 @@ enum DomainReportExporter {
504504 .joined(separator: "\n")
505505 }
506506
507 static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
508 guard !reports.isEmpty else {
509 return "Timeline Export\nNo snapshots available for \(domain)."
510 }
511
512 var lines = [
513 "DomainDig Timeline Export",
514 "Domain: \(domain)",
515 "Snapshots: \(reports.count)"
516 ]
517
518 for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
519 lines.append("")
520 lines.append("\(textDateFormatter.string(from: report.timestamp))")
521 lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
522 lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
523 if includeDiffSummary, let changeSummary = report.changeSummary {
524 lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
525 }
526 }
527
528 return lines.joined(separator: "\n")
529 }
530
531 static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
532 struct TimelineExportEntry: Codable {
533 let timestamp: Date
534 let summary: String?
535 let severity: String?
536 let changedSections: [String]?
537 }
538
539 struct TimelineExportPayload: Codable {
540 let domain: String
541 let exportedAt: Date
542 let snapshots: [TimelineExportEntry]
543 }
544
545 let payload = TimelineExportPayload(
546 domain: domain,
547 exportedAt: Date(),
548 snapshots: reports
549 .sorted(by: { $0.timestamp > $1.timestamp })
550 .map { report in
551 TimelineExportEntry(
552 timestamp: report.timestamp,
553 summary: report.changeSummary?.message,
554 severity: report.changeSummary?.severity.title,
555 changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
556 )
557 }
558 )
559
560 return try jsonEncoder.encode(payload)
561 }
562
507563 private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
508564 lines.append("")
509565 lines.append(title)
LookupSnapshot.swift +8
@@ -20,6 +20,10 @@ struct LookupSnapshot {
2020 let isPartialSnapshot: Bool
2121 let validationIssues: [String]
2222 let totalLookupDurationMs: Int?
23 let snapshotIndex: Int?
24 let previousSnapshotID: UUID?
25 let changeCount: Int
26 let severitySummary: ChangeSeverity?
2327 let dnsSections: [DNSSection]
2428 let dnsError: String?
2529 let availabilityResult: DomainAvailabilityResult?
@@ -90,6 +94,10 @@ extension HistoryEntry {
9094 isPartialSnapshot: isPartialSnapshot,
9195 validationIssues: validationIssues,
9296 totalLookupDurationMs: totalLookupDurationMs,
97 snapshotIndex: snapshotIndex,
98 previousSnapshotID: previousSnapshotID,
99 changeCount: changeCount,
100 severitySummary: severitySummary,
93101 dnsSections: dnsSections,
94102 dnsError: nil,
95103 availabilityResult: availabilityResult,