krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

d62480233819184db2e55741b05375818ebf3881

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T15:25:53Z

v4.7.0: Add domain-vs-domain comparison

- DiffService.compare(domainA:domainB:) reuses the existing section-diff
  builders to compare two distinct domains' latest reports, returning a new
  DomainComparisonResult (parallel to the time-based DomainDiff).
- DomainCompareView: pick two tracked domains and render the comparison with
  the existing DomainDiffView section renderer.
- Entry point: "Compare Domains" in the Watchlist toolbar menu, shown once at
  least two domains are tracked.
 DomainDig/DiffService.swift       | 51 ++++++++++++++++++++++++
 DomainDig/DomainCompareView.swift | 82 +++++++++++++++++++++++++++++++++++++++
 DomainDig/WatchlistView.swift     |  6 +++
 3 files changed, 139 insertions(+)

diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift
index 211a16e..cf25b6f 100644
--- a/DomainDig/DiffService.swift
+++ b/DomainDig/DiffService.swift
@@ -105,6 +105,32 @@ struct DomainDiff: Identifiable, Equatable, Codable {
 typealias DomainDiffItem = DiffItem
 typealias DomainDiffSection = DiffSection
 
+/// Result of comparing two distinct domains' latest reports side by side, as
+/// opposed to `DomainDiff`, which compares the same domain across time.
+struct DomainComparisonResult: Identifiable, Equatable {
+    let domainA: String
+    let domainB: String
+    let generatedAt: Date
+    let sections: [DiffSection]
+    let contextNote: String?
+
+    var id: String {
+        "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)"
+    }
+
+    var changedSections: [DiffSection] {
+        sections.filter(\.hasChanges)
+    }
+
+    var changeCount: Int {
+        sections.reduce(0) { $0 + $1.changeCount }
+    }
+
+    var severity: ChangeSeverity {
+        sections.map(\.severity).max() ?? .low
+    }
+}
+
 enum DiffService {
     static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
         let sections = [
@@ -181,6 +207,31 @@ enum DiffService {
         )
     }
 
+    /// Compares two distinct domains' reports section by section. Reuses the same
+    /// field-level diff logic as time-based comparison; the two reports are simply
+    /// unrelated domains rather than the same domain at different times.
+    static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult {
+        let sections = [
+            availabilitySection(from: domainA, to: domainB),
+            ownershipSection(from: domainA, to: domainB),
+            dnsSection(from: domainA, to: domainB),
+            webSection(from: domainA, to: domainB),
+            emailSection(from: domainA, to: domainB),
+            networkSection(from: domainA, to: domainB),
+            subdomainsSection(from: domainA, to: domainB),
+            intelligenceSection(from: domainA, to: domainB),
+            riskSection(from: domainA, to: domainB)
+        ]
+
+        return DomainComparisonResult(
+            domainA: domainA.domain,
+            domainB: domainB.domain,
+            generatedAt: Date(),
+            sections: sections,
+            contextNote: comparisonContextNote(from: domainA, to: domainB)
+        )
+    }
+
     static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
         var notes: [String] = []
         if oldReport.resolverURLString != newReport.resolverURLString {
diff --git a/DomainDig/DomainCompareView.swift b/DomainDig/DomainCompareView.swift
new file mode 100644
index 0000000..96341c2
--- /dev/null
+++ b/DomainDig/DomainCompareView.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+/// Side-by-side comparison of two tracked domains' latest reports, reusing the
+/// same section-diff rendering (`DomainDiffView`) that time-based history diffs
+/// use, via `DiffService.compare(domainA:domainB:)`.
+struct DomainCompareView: View {
+    @Bindable var viewModel: DomainViewModel
+    @State private var domainAID: UUID?
+    @State private var domainBID: UUID?
+
+    private var states: [PortfolioDomainStatus] {
+        viewModel.portfolioDashboardData.domainStates
+    }
+
+    private var stateA: PortfolioDomainStatus? {
+        states.first { $0.trackedDomain.id == domainAID }
+    }
+
+    private var stateB: PortfolioDomainStatus? {
+        states.first { $0.trackedDomain.id == domainBID }
+    }
+
+    private var result: DomainComparisonResult? {
+        guard let stateA, let stateB, stateA.trackedDomain.id != stateB.trackedDomain.id else {
+            return nil
+        }
+        return DiffService.compare(domainA: stateA.report, domainB: stateB.report)
+    }
+
+    var body: some View {
+        List {
+            Section("Domains") {
+                domainPicker("Domain A", selection: $domainAID)
+                domainPicker("Domain B", selection: $domainBID)
+            }
+
+            if domainAID != nil, domainAID == domainBID {
+                Section {
+                    Text("Choose two different domains to compare.")
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            if let result {
+                Section {
+                    DomainDiffView(
+                        title: "\(result.domainA) vs \(result.domainB)",
+                        sections: result.sections,
+                        contextNote: result.contextNote,
+                        showsUnchanged: true,
+                        highlightedSectionID: nil
+                    )
+                }
+                .listRowBackground(Color.clear)
+            } else if domainAID == nil || domainBID == nil {
+                Section {
+                    EmptyStateCardView(
+                        title: "Pick Two Domains",
+                        message: "Select a domain in each slot to see a side-by-side comparison across DNS, TLS, ownership, and risk.",
+                        suggestion: "Both domains must already be tracked.",
+                        systemImage: "arrow.left.arrow.right",
+                        showsCardBackground: false
+                    )
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("Compare Domains")
+    }
+
+    private func domainPicker(_ label: String, selection: Binding<UUID?>) -> some View {
+        Picker(label, selection: selection) {
+            Text("Select a domain").tag(UUID?.none)
+            ForEach(states) { state in
+                Text(state.trackedDomain.domain).tag(Optional(state.trackedDomain.id))
+            }
+        }
+    }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index d91dd5d..43dc66f 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -120,6 +120,12 @@ struct WatchlistView: View {
                             showWorkflowAddSheet = true
                         }
 
+                        if viewModel.trackedDomains.count >= 2 {
+                            NavigationLink("Compare Domains") {
+                                DomainCompareView(viewModel: viewModel)
+                            }
+                        }
+
                         Button("Export TXT") {
                             shareTrackedDomains(format: .text)
                         }