summaryrefslogtreecommitdiff
path: root/DomainDig/DomainDiffService.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 16:24:20 -0500
committerChristian Cleberg <[email protected]>2026-04-20 16:24:20 -0500
commit9999f876feb9a2d419a79ad601f78053c44d44f8 (patch)
treeb920aacd1192646dcca71ddae17879355ed4f040 /DomainDig/DomainDiffService.swift
parent397c7c420c22fc1c235893bd85b2029da9145730 (diff)
downloaddomain-dig-9f469637d75b8649425750604b2db186ccec0816.tar.gz
domain-dig-9f469637d75b8649425750604b2db186ccec0816.tar.bz2
domain-dig-9f469637d75b8649425750604b2db186ccec0816.zip
feat(v2.0.0): introduce tracked domains, diffing, and monitoring foundationsv2.0.0
- replace basic watchlist with structured TrackedDomain model - add manual refresh flow for tracked domains - implement snapshot diffing (DNS, IP, TLS, HTTP, redirect, email, availability) - add change summaries (changed/unchanged + affected sections) - link tracked domains to history snapshots - enhance HistoryEntry with normalized summary fields - persist availability + suggestions in history and export - add WatchlistView with pinning, notes, and status display - introduce premium capability scaffolding (no StoreKit) - enforce free-tier tracked domain limit (local only) - refactor view model to support tracking, diffing, and refresh flows notes: - no background monitoring, notifications, or paid APIs yet - all features remain local-first and manual
Diffstat (limited to 'DomainDig/DomainDiffService.swift')
-rw-r--r--DomainDig/DomainDiffService.swift197
1 files changed, 197 insertions, 0 deletions
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
new file mode 100644
index 0000000..fa3d8b5
--- /dev/null
+++ b/DomainDig/DomainDiffService.swift
@@ -0,0 +1,197 @@
+import Foundation
+
+enum DiffChangeType: String, Codable {
+ case added
+ case removed
+ case changed
+ case unchanged
+}
+
+struct DomainDiffItem: Identifiable, Equatable {
+ let id = UUID()
+ let label: String
+ let changeType: DiffChangeType
+ let oldValue: String?
+ let newValue: String?
+}
+
+struct DomainDiffSection: Identifiable, Equatable {
+ let id = UUID()
+ let title: String
+ let items: [DomainDiffItem]
+}
+
+enum DomainDiffService {
+ static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
+ [
+ section(title: "Availability", item: compare(
+ label: "Status",
+ oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
+ newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
+ )),
+ section(title: "Primary IP", item: compare(
+ label: "Address",
+ oldValue: primaryIP(from: oldSnapshot),
+ newValue: primaryIP(from: newSnapshot)
+ )),
+ dnsSection(from: oldSnapshot, to: newSnapshot),
+ section(title: "Redirect", item: compare(
+ label: "Final Target",
+ oldValue: finalRedirectURL(from: oldSnapshot),
+ newValue: finalRedirectURL(from: newSnapshot)
+ )),
+ tlsSection(from: oldSnapshot, to: newSnapshot),
+ httpSection(from: oldSnapshot, to: newSnapshot),
+ emailSection(from: oldSnapshot, to: newSnapshot)
+ ]
+ .filter { !$0.items.isEmpty }
+ }
+
+ static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
+ let changedSections = diff(from: oldSnapshot, to: newSnapshot)
+ .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
+ .map(\.title)
+
+ return DomainChangeSummary(
+ hasChanges: !changedSections.isEmpty,
+ changedSections: changedSections,
+ generatedAt: generatedAt
+ )
+ }
+
+ private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
+ DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
+ }
+
+ private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ let oldValue = normalizedDNSSummary(from: oldSnapshot)
+ let newValue = normalizedDNSSummary(from: newSnapshot)
+ return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
+ }
+
+ private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ var items: [DomainDiffItem] = []
+ if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
+ items.append(item)
+ }
+ if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
+ items.append(item)
+ }
+ return DomainDiffSection(title: "TLS Certificate", items: items)
+ }
+
+ private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ var items: [DomainDiffItem] = []
+ if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
+ items.append(item)
+ }
+ if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
+ items.append(item)
+ }
+ return DomainDiffSection(title: "HTTP", items: items)
+ }
+
+ private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ section(
+ title: "Email Security",
+ item: compare(
+ label: "Summary",
+ oldValue: normalized(emailSummary(from: oldSnapshot)),
+ newValue: normalized(emailSummary(from: newSnapshot))
+ )
+ )
+ }
+
+ private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
+ let oldValue = normalized(oldValue)
+ let newValue = normalized(newValue)
+
+ guard oldValue != nil || newValue != nil else {
+ return nil
+ }
+
+ let changeType: DiffChangeType
+ switch (oldValue, newValue) {
+ case let (old?, new?) where old == new:
+ changeType = .unchanged
+ case (nil, _?):
+ changeType = .added
+ case (_?, nil):
+ changeType = .removed
+ default:
+ changeType = .changed
+ }
+
+ return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
+ }
+
+ private static func normalized(_ value: String?) -> String? {
+ guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+ return value.lowercased()
+ }
+
+ private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
+ switch status {
+ case .available:
+ return "available"
+ case .registered:
+ return "registered"
+ case .unknown:
+ return "unknown"
+ case .none:
+ return nil
+ }
+ }
+
+ private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
+ snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+ }
+
+ private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
+ snapshot.redirectChain.last?.url
+ }
+
+ private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
+ if let sslInfo = snapshot.sslInfo {
+ return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
+ }
+ return snapshot.sslError
+ }
+
+ private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
+ if let httpStatusCode = snapshot.httpStatusCode {
+ return "\(httpStatusCode)"
+ }
+ return snapshot.httpHeadersError
+ }
+
+ private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
+ if let emailSecurity = snapshot.emailSecurity {
+ return [
+ "spf:\(emailSecurity.spf.found)",
+ "dmarc:\(emailSecurity.dmarc.found)",
+ "dkim:\(emailSecurity.dkim.found)",
+ "bimi:\(emailSecurity.bimi.found)",
+ "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
+ ].joined(separator: "|")
+ }
+ return snapshot.emailSecurityError
+ }
+
+ private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
+ let parts = snapshot.dnsSections
+ .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
+ .map { section in
+ let values = (section.records + section.wildcardRecords)
+ .map(\.value)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .sorted()
+ .joined(separator: ",")
+ return "\(section.recordType.rawValue):\(values)"
+ }
+ .filter { !$0.hasSuffix(":") }
+ return parts.isEmpty ? nil : parts.joined(separator: "|")
+ }
+}