From 9999f876feb9a2d419a79ad601f78053c44d44f8 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Apr 2026 16:24:20 -0500 Subject: feat(v2.0.0): introduce tracked domains, diffing, and monitoring foundations - 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 --- DomainDig/DomainDiffService.swift | 197 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 DomainDig/DomainDiffService.swift (limited to 'DomainDig/DomainDiffService.swift') 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: "|") + } +} -- cgit v1.2.3