summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/ContentView.swift287
-rw-r--r--DomainDig/DomainDiffService.swift197
-rw-r--r--DomainDig/DomainViewModel.swift381
-rw-r--r--DomainDig/HistoryView.swift55
-rw-r--r--DomainDig/Models.swift75
-rw-r--r--DomainDig/PremiumAccessService.swift27
-rw-r--r--DomainDig/WatchlistView.swift316
8 files changed, 1244 insertions, 102 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index b08ad23..58f2c35 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -267,7 +267,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 12;
+ CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.9.0;
+ MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 12;
+ CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.9.0;
+ MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index f70bc6a..69f3af9 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -3,12 +3,16 @@ import SwiftUI
struct ContentView: View {
@State private var viewModel = DomainViewModel()
+ @State private var navigationPath = NavigationPath()
@FocusState private var domainFieldFocused: Bool
@State private var customPortInput = ""
@State private var customPortsExpanded = false
+ @State private var trackingNoteDraft = ""
+ @State private var editingTrackedDomain: TrackedDomain?
+ @State private var showTrackLimitAlert = false
var body: some View {
- NavigationStack {
+ NavigationStack(path: $navigationPath) {
ScrollView(.vertical) {
VStack(spacing: 0) {
inputSection
@@ -16,16 +20,42 @@ struct ContentView: View {
actionButtons
SummaryView(fields: viewModel.summaryFields)
.padding(.top, 8)
+ if let changeSummary = viewModel.currentChangeSummary {
+ DomainChangeSummaryView(summary: changeSummary)
+ .padding(.top, 12)
+ }
DomainSectionView(
rows: viewModel.domainRows,
suggestions: viewModel.suggestionRows,
showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
availabilityLoading: viewModel.availabilityLoading,
suggestionsLoading: viewModel.suggestionsLoading,
- isWatched: viewModel.isCurrentDomainWatched,
- onToggleWatch: { viewModel.toggleWatchedDomain() }
+ trackedDomain: viewModel.currentTrackedDomain,
+ trackingLimitMessage: viewModel.trackingLimitMessage,
+ onTrack: {
+ if !viewModel.trackCurrentDomain() {
+ showTrackLimitAlert = true
+ }
+ },
+ onTogglePinned: {
+ guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+ viewModel.togglePinned(for: trackedDomain)
+ },
+ onEditNote: {
+ guard let trackedDomain = viewModel.currentTrackedDomain else { return }
+ trackingNoteDraft = trackedDomain.note ?? ""
+ editingTrackedDomain = trackedDomain
+ }
)
.padding(.top, 16)
+ if !viewModel.currentDiffSections.isEmpty {
+ DomainDiffView(
+ title: "Latest Changes",
+ sections: viewModel.currentDiffSections,
+ showsUnchanged: false
+ )
+ .padding(.top, 16)
+ }
DNSSectionView(
dnssecLabel: viewModel.dnssecLabel,
sections: viewModel.dnsRows,
@@ -97,27 +127,31 @@ struct ContentView: View {
}
}
NavigationLink {
- SavedDomainsView(viewModel: viewModel)
- } label: {
- Image(systemName: "bookmark")
- .foregroundStyle(.secondary)
- }
- NavigationLink {
- HistoryView(viewModel: viewModel)
- } label: {
- Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
- .foregroundStyle(.secondary)
- }
- NavigationLink {
WatchlistView(viewModel: viewModel)
} label: {
Image(systemName: "eye")
.foregroundStyle(.secondary)
}
- NavigationLink {
- SettingsView()
+ Menu {
+ NavigationLink {
+ HistoryView(viewModel: viewModel)
+ } label: {
+ Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ }
+
+ NavigationLink {
+ SavedDomainsView(viewModel: viewModel)
+ } label: {
+ Label("Saved Domains", systemImage: "bookmark")
+ }
+
+ NavigationLink {
+ SettingsView()
+ } label: {
+ Label("Settings", systemImage: "gearshape")
+ }
} label: {
- Image(systemName: "gearshape")
+ Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
}
}
@@ -126,6 +160,41 @@ struct ContentView: View {
.onAppear {
domainFieldFocused = true
}
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ navigationPath = NavigationPath()
+ domainFieldFocused = false
+ }
+ .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
+ }
+ .sheet(item: $editingTrackedDomain) { trackedDomain in
+ NavigationStack {
+ Form {
+ Section("Tracking Note") {
+ TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
+ .lineLimit(3...6)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ }
+ .navigationTitle(trackedDomain.domain)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ editingTrackedDomain = nil
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
+ editingTrackedDomain = nil
+ }
+ }
+ }
+ }
+ }
}
private var inputSection: some View {
@@ -292,30 +361,192 @@ struct SummaryView: View {
}
}
+struct DomainChangeSummaryView: View {
+ let summary: DomainChangeSummary
+
+ var body: some View {
+ CardView(allowsHorizontalScroll: false) {
+ HStack {
+ Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(summary.hasChanges ? .yellow : .green)
+ Spacer()
+ Text(summary.generatedAt, style: .time)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ }
+ }
+}
+
+struct DomainDiffView: View {
+ let title: String
+ let sections: [DomainDiffSection]
+ let showsUnchanged: Bool
+
+ private var filteredSections: [DomainDiffSection] {
+ guard !showsUnchanged else { return sections }
+ return sections
+ .map { section in
+ DomainDiffSection(
+ title: section.title,
+ items: section.items.filter { $0.changeType != .unchanged }
+ )
+ }
+ .filter { !$0.items.isEmpty }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ SectionTitleView(title: title)
+ if filteredSections.isEmpty {
+ MessageCardView(text: "No comparison data available", isError: false)
+ } else {
+ ForEach(filteredSections) { section in
+ CardView(allowsHorizontalScroll: false) {
+ Text(section.title)
+ .font(.system(.subheadline, design: .monospaced))
+ .fontWeight(.semibold)
+ .foregroundStyle(.cyan)
+
+ ForEach(section.items) { item in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(item.label)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(changeLabel(for: item.changeType))
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(changeColor(for: item.changeType))
+ }
+ if let oldValue = item.oldValue {
+ Text("Old: \(oldValue)")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ if let newValue = item.newValue {
+ Text("New: \(newValue)")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func changeLabel(for changeType: DiffChangeType) -> String {
+ switch changeType {
+ case .added:
+ return "Added"
+ case .removed:
+ return "Removed"
+ case .changed:
+ return "Changed"
+ case .unchanged:
+ return "Unchanged"
+ }
+ }
+
+ private func changeColor(for changeType: DiffChangeType) -> Color {
+ switch changeType {
+ case .added:
+ return .green
+ case .removed:
+ return .red
+ case .changed:
+ return .yellow
+ case .unchanged:
+ return .secondary
+ }
+ }
+}
+
+struct TrackedDomainDetailHeaderView: View {
+ let trackedDomain: TrackedDomain
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ if let note = trackedDomain.note?.nilIfEmpty {
+ LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
+ }
+ HStack(spacing: 8) {
+ if trackedDomain.isPinned {
+ Label("Pinned", systemImage: "pin.fill")
+ }
+ Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+ }
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
struct DomainSectionView: View {
let rows: [InfoRowViewData]
let suggestions: [DomainSuggestionViewData]
let showSuggestions: Bool
let availabilityLoading: Bool
let suggestionsLoading: Bool
- let isWatched: Bool
- let onToggleWatch: () -> Void
+ let trackedDomain: TrackedDomain?
+ let trackingLimitMessage: String?
+ let onTrack: () -> Void
+ let onTogglePinned: () -> Void
+ let onEditNote: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitleView(title: "Domain")
Spacer()
- Button(isWatched ? "Watching" : "Watch") {
- onToggleWatch()
+ if let trackedDomain {
+ HStack(spacing: 8) {
+ Text("Tracked")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.green)
+ Button {
+ onTogglePinned()
+ } label: {
+ Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ if let onEditNote {
+ Button("Note") {
+ onEditNote()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ }
+ }
+ } else {
+ Button("Track") {
+ onTrack()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
}
- .buttonStyle(.bordered)
- .font(.system(.caption, design: .monospaced))
}
- CardView {
+ CardView(allowsHorizontalScroll: false) {
ForEach(rows) { row in
LabeledValueRow(row: row)
}
+ if let trackedDomain {
+ TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
+ .padding(.top, 4)
+ } else if let trackingLimitMessage {
+ MessageRowView(text: trackingLimitMessage, isError: false)
+ .padding(.top, 4)
+ }
if availabilityLoading {
ProgressView("Checking availability…")
.appLoadingStyle()
@@ -888,6 +1119,12 @@ private extension View {
}
}
+private extension String {
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
+ }
+}
+
private struct SettingsView: View {
@AppStorage(DNSResolverOption.userDefaultsKey)
private var storedResolverURL = DNSResolverOption.defaultURLString
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: "|")
+ }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 839790b..4a20338 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -80,8 +80,10 @@ struct DomainSuggestionViewData: Identifiable {
}
struct LookupSnapshot {
+ let historyEntryID: UUID?
let domain: String
let timestamp: Date
+ let trackedDomainID: UUID?
let resolverDisplayName: String
let resolverURLString: String
let totalLookupDurationMs: Int?
@@ -111,14 +113,17 @@ struct LookupSnapshot {
let redirectChainError: String?
let portScanResults: [PortScanResult]
let portScanError: String?
+ let changeSummary: DomainChangeSummary?
let isLive: Bool
}
extension HistoryEntry {
var snapshot: LookupSnapshot {
LookupSnapshot(
+ historyEntryID: id,
domain: domain,
timestamp: timestamp,
+ trackedDomainID: trackedDomainID,
resolverDisplayName: resolverDisplayName,
resolverURLString: resolverURLString,
totalLookupDurationMs: totalLookupDurationMs,
@@ -148,6 +153,7 @@ extension HistoryEntry {
redirectChainError: redirectChainError,
portScanResults: portScanResults,
portScanError: portScanError,
+ changeSummary: changeSummary,
isLive: false
)
}
@@ -211,6 +217,10 @@ final class DomainViewModel {
var hasRun = false
private(set) var searchedDomain: String = ""
private(set) var lastLookupDurationMs: Int?
+ private(set) var currentDiffSections: [DomainDiffSection] = []
+ private(set) var currentChangeSummary: DomainChangeSummary?
+ private(set) var refreshingTrackedDomainID: UUID?
+ private(set) var rerunNavigationToken = UUID()
private var lookupTask: Task<Void, Never>?
private var customPortScanTask: Task<Void, Never>?
@@ -224,14 +234,9 @@ final class DomainViewModel {
private static let savedDomainsKey = "savedDomains"
var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
- private static let watchedDomainsKey = "watchedDomains"
- var watchedDomains: [WatchedDomain] = {
- guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey),
- let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else {
- return []
- }
- return domains
- }()
+ private static let trackedDomainsKey = "trackedDomains"
+ private static let legacyWatchedDomainsKey = "watchedDomains"
+ var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
private static let historyKey = "lookupHistory"
private static let maxHistory = 50
@@ -276,8 +281,35 @@ final class DomainViewModel {
!searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
}
- var isCurrentDomainWatched: Bool {
- !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() })
+ var sortedTrackedDomains: [TrackedDomain] {
+ trackedDomains.sorted {
+ if $0.isPinned != $1.isPinned {
+ return $0.isPinned && !$1.isPinned
+ }
+ if $0.updatedAt != $1.updatedAt {
+ return $0.updatedAt > $1.updatedAt
+ }
+ return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+ }
+ }
+
+ var currentTrackedDomain: TrackedDomain? {
+ guard !searchedDomain.isEmpty else { return nil }
+ return trackedDomain(for: searchedDomain)
+ }
+
+ var isCurrentDomainTracked: Bool {
+ currentTrackedDomain != nil
+ }
+
+ var trackingLimitMessage: String? {
+ guard currentTrackedDomain == nil else { return nil }
+ guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
+ return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
+ }
+
+ var canTrackCurrentDomain: Bool {
+ currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
}
var resolverDisplayName: String {
@@ -299,8 +331,10 @@ final class DomainViewModel {
var currentSnapshot: LookupSnapshot {
LookupSnapshot(
+ historyEntryID: nil,
domain: searchedDomain,
timestamp: Date(),
+ trackedDomainID: currentTrackedDomain?.id,
resolverDisplayName: resolverDisplayName,
resolverURLString: resolverURLString,
totalLookupDurationMs: lastLookupDurationMs,
@@ -330,6 +364,7 @@ final class DomainViewModel {
redirectChainError: redirectChainError,
portScanResults: allPortScanResults,
portScanError: combinedPortScanError,
+ changeSummary: currentChangeSummary,
isLive: true
)
}
@@ -408,31 +443,84 @@ final class DomainViewModel {
UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
}
- func toggleWatchedDomain() {
- guard !searchedDomain.isEmpty else { return }
- toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
+ @discardableResult
+ func trackCurrentDomain() -> Bool {
+ guard !searchedDomain.isEmpty else { return false }
+ return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
}
- func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) {
- guard !domain.isEmpty else { return }
+ @discardableResult
+ func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
+ let normalizedDomain = normalizedDomain(domain)
+ guard !normalizedDomain.isEmpty else { return false }
- if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) {
- watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() }
- } else {
- watchedDomains.insert(
- WatchedDomain(
- domain: domain,
- lastKnownAvailability: availabilityStatus
- ),
- at: 0
- )
+ if trackedDomain(for: normalizedDomain) != nil {
+ return true
}
- persistWatchedDomains()
+
+ guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
+ return false
+ }
+
+ trackedDomains.insert(
+ TrackedDomain(
+ domain: normalizedDomain,
+ createdAt: Date(),
+ updatedAt: Date(),
+ lastKnownAvailability: availabilityStatus
+ ),
+ at: 0
+ )
+ persistTrackedDomains()
+ linkTrackedDomainHistory(for: normalizedDomain)
+ return true
}
- func removeWatchedDomains(at offsets: IndexSet) {
- watchedDomains.remove(atOffsets: offsets)
- persistWatchedDomains()
+ func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
+ refreshingTrackedDomainID = trackedDomain.id
+ domain = trackedDomain.domain
+ run()
+ }
+
+ func rerunInspection(for trackedDomain: TrackedDomain) {
+ domain = trackedDomain.domain
+ run()
+ rerunNavigationToken = UUID()
+ }
+
+ func deleteTrackedDomains(at offsets: IndexSet) {
+ let ids = offsets.map { sortedTrackedDomains[$0].id }
+ trackedDomains.removeAll { ids.contains($0.id) }
+ history.indices.forEach { index in
+ if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
+ history[index].trackedDomainID = nil
+ }
+ }
+ persistTrackedDomains()
+ persistHistory()
+ }
+
+ func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
+ trackedDomains.removeAll { $0.id == trackedDomain.id }
+ history.indices.forEach { index in
+ if history[index].trackedDomainID == trackedDomain.id {
+ history[index].trackedDomainID = nil
+ }
+ }
+ persistTrackedDomains()
+ persistHistory()
+ }
+
+ func togglePinned(for trackedDomain: TrackedDomain) {
+ guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+ trackedDomains[index].isPinned.toggle()
+ persistTrackedDomains()
+ }
+
+ func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
+ guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
+ trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ persistTrackedDomains()
}
func removeHistoryEntries(at offsets: IndexSet) {
@@ -440,6 +528,11 @@ final class DomainViewModel {
persistHistory()
}
+ func clearHistory() {
+ history.removeAll()
+ persistHistory()
+ }
+
func clearRecentSearches() {
recentSearches.removeAll()
UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -449,6 +542,7 @@ final class DomainViewModel {
UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
domain = entry.domain
run()
+ rerunNavigationToken = UUID()
}
func reset() {
@@ -457,6 +551,9 @@ final class DomainViewModel {
hasRun = false
searchedDomain = ""
lastLookupDurationMs = nil
+ currentDiffSections = []
+ currentChangeSummary = nil
+ refreshingTrackedDomainID = nil
clearLookupState()
}
@@ -474,6 +571,8 @@ final class DomainViewModel {
addRecentSearch(target)
searchedDomain = target
hasRun = true
+ currentDiffSections = []
+ currentChangeSummary = nil
clearLookupState()
setAllLoadingStates(true)
customPortScanLoading = false
@@ -513,7 +612,12 @@ final class DomainViewModel {
}
func exportText() -> String {
- Self.formatExportText(from: currentSnapshot)
+ Self.formatExportText(
+ from: currentSnapshot,
+ trackedDomain: currentTrackedDomain,
+ changeSummary: currentChangeSummary,
+ diffSections: currentDiffSections
+ )
}
private func performLookup(domain: String, lookupID: UUID) async {
@@ -555,6 +659,7 @@ final class DomainViewModel {
guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
saveHistoryEntry(replaceLatest: false)
+ refreshingTrackedDomainID = nil
}
private func runDNS(domain: String, lookupID: UUID) async {
@@ -579,7 +684,7 @@ final class DomainViewModel {
guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
availabilityResult = result
availabilityLoading = false
- updateWatchedDomainAvailability(for: result.domain, status: result.status)
+ updateTrackedDomainAvailability(for: result.domain, status: result.status)
}
private func runSSL(domain: String, lookupID: UUID) async {
@@ -801,9 +906,20 @@ final class DomainViewModel {
private func saveHistoryEntry(replaceLatest: Bool) {
guard !searchedDomain.isEmpty else { return }
+
+ let trackedDomainID = trackedDomain(for: searchedDomain)?.id
+ let timestamp = Date()
+ let snapshot = currentSnapshot
+ let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
+ let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
+
+ currentChangeSummary = changeSummary
+ currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+
let entry = HistoryEntry(
domain: searchedDomain,
- timestamp: Date(),
+ timestamp: timestamp,
+ trackedDomainID: trackedDomainID,
dnsSections: dnsSections,
sslInfo: sslInfo,
httpHeaders: httpHeaders,
@@ -820,6 +936,12 @@ final class DomainViewModel {
resolverDisplayName: resolverDisplayName,
resolverURLString: resolverURLString,
totalLookupDurationMs: lastLookupDurationMs,
+ primaryIP: Self.primaryIPAddress(from: snapshot),
+ finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
+ tlsStatusSummary: Self.httpsSummary(from: snapshot),
+ emailSecuritySummary: Self.emailSummary(from: snapshot),
+ httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
+ changeSummary: changeSummary,
sslError: sslError,
httpHeadersError: httpHeadersError,
reachabilityError: reachabilityError,
@@ -838,6 +960,14 @@ final class DomainViewModel {
history = Array(history.prefix(Self.maxHistory))
}
}
+
+ updateTrackedDomainSnapshotMetadata(
+ domain: searchedDomain,
+ snapshotID: entry.id,
+ availabilityStatus: availabilityResult?.status,
+ updatedAt: timestamp,
+ changeSummary: changeSummary
+ )
persistHistory()
}
@@ -847,18 +977,82 @@ final class DomainViewModel {
}
}
- private func persistWatchedDomains() {
- if let data = try? JSONEncoder().encode(watchedDomains) {
- UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey)
+ private func persistTrackedDomains() {
+ if let data = try? JSONEncoder().encode(trackedDomains) {
+ UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
}
}
- private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
- guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else {
+ private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
+ guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
return
}
- watchedDomains[index].lastKnownAvailability = status
- persistWatchedDomains()
+ trackedDomains[index].lastKnownAvailability = status
+ persistTrackedDomains()
+ }
+
+ private func updateTrackedDomainSnapshotMetadata(
+ domain: String,
+ snapshotID: UUID,
+ availabilityStatus: DomainAvailabilityStatus?,
+ updatedAt: Date,
+ changeSummary: DomainChangeSummary?
+ ) {
+ guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
+ return
+ }
+ trackedDomains[index].lastSnapshotID = snapshotID
+ trackedDomains[index].lastKnownAvailability = availabilityStatus
+ trackedDomains[index].updatedAt = updatedAt
+ trackedDomains[index].lastChangeSummary = changeSummary
+ persistTrackedDomains()
+ }
+
+ private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
+ let matchingEntries = history.filter { entry in
+ if let trackedDomainID {
+ return entry.trackedDomainID == trackedDomainID
+ }
+ return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
+ }
+
+ if replacingLatest {
+ return matchingEntries.dropFirst().first?.snapshot
+ }
+ return matchingEntries.first?.snapshot
+ }
+
+ private func trackedDomain(for domain: String) -> TrackedDomain? {
+ trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ }
+
+ private func normalizedDomain(_ domain: String) -> String {
+ domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ }
+
+ private func linkTrackedDomainHistory(for domain: String) {
+ guard let trackedDomain = trackedDomain(for: domain) else { return }
+ var didChange = false
+
+ for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
+ if history[index].trackedDomainID != trackedDomain.id {
+ history[index].trackedDomainID = trackedDomain.id
+ didChange = true
+ }
+ }
+
+ if didChange {
+ persistHistory()
+ }
+
+ if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
+ let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+ trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
+ trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
+ trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
+ trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
+ persistTrackedDomains()
+ }
}
private func addRecentSearch(_ domain: String) {
@@ -937,6 +1131,71 @@ final class DomainViewModel {
activeLookupID == lookupID
}
+ func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
+ history
+ .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
+ .sorted { $0.timestamp > $1.timestamp }
+ .prefix(limit)
+ .map { $0 }
+ }
+
+ func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
+ let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
+ guard snapshots.count == 2 else { return [] }
+ return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
+ }
+
+ func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
+ trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
+ }
+
+ func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
+ let siblings = history.filter { candidate in
+ if let trackedDomainID = entry.trackedDomainID {
+ return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
+ }
+ return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
+ }
+ .sorted { $0.timestamp > $1.timestamp }
+
+ return siblings.first?.snapshot
+ }
+
+ private static func loadTrackedDomains() -> [TrackedDomain] {
+ let defaults = UserDefaults.standard
+ let decoder = JSONDecoder()
+
+ if let data = defaults.data(forKey: trackedDomainsKey),
+ let domains = try? decoder.decode([TrackedDomain].self, from: data) {
+ return deduplicatedTrackedDomains(domains)
+ }
+
+ if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
+ let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
+ return deduplicatedTrackedDomains(
+ legacyDomains.map {
+ TrackedDomain(
+ id: $0.id,
+ domain: $0.domain.lowercased(),
+ createdAt: $0.createdAt,
+ updatedAt: $0.createdAt,
+ lastKnownAvailability: $0.lastKnownAvailability
+ )
+ }
+ )
+ }
+
+ return []
+ }
+
+ private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
+ var seen = Set<String>()
+ return domains.filter { domain in
+ let key = domain.domain.lowercased()
+ return seen.insert(key).inserted
+ }
+ }
+
static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
[
SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1110,7 +1369,12 @@ final class DomainViewModel {
}
}
- static func formatExportText(from snapshot: LookupSnapshot) -> String {
+ static func formatExportText(
+ from snapshot: LookupSnapshot,
+ trackedDomain: TrackedDomain?,
+ changeSummary: DomainChangeSummary?,
+ diffSections: [DomainDiffSection]
+ ) -> String {
let exportDateFormatter = DateFormatter()
exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
@@ -1120,9 +1384,14 @@ final class DomainViewModel {
"Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
"Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
"Resolver: \(snapshot.resolverDisplayName)",
- "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
+ "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
+ "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
]
+ if let note = trackedDomain?.note?.nilIfEmpty {
+ lines.append("Tracking Note: \(note)")
+ }
+
func appendSection(_ title: String, body: () -> Void) {
lines.append("")
lines.append(title)
@@ -1134,6 +1403,36 @@ final class DomainViewModel {
for item in summaryFields(from: snapshot) {
lines.append(" \(item.label): \(item.value)")
}
+ if let changeSummary {
+ lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
+ lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
+ }
+ }
+
+ appendSection("Tracking") {
+ if let trackedDomain {
+ lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
+ lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
+ lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
+ if let note = trackedDomain.note?.nilIfEmpty {
+ lines.append(" Note: \(note)")
+ }
+ } else {
+ lines.append(" This domain is not currently tracked.")
+ }
+ }
+
+ appendSection("Diff Summary") {
+ if diffSections.isEmpty {
+ lines.append(" No comparison available")
+ } else {
+ for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
+ lines.append(" \(section.title)")
+ for item in section.items where item.changeType != .unchanged {
+ lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
+ }
+ }
+ }
}
appendSection("Domain") {
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 5bbbb91..bb9d255 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct HistoryView: View {
@Bindable var viewModel: DomainViewModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var showClearAllConfirmation = false
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
@@ -50,8 +52,29 @@ struct HistoryView: View {
.navigationTitle("History")
.toolbar {
if !viewModel.history.isEmpty {
- EditButton()
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Menu {
+ Button("Clear All", role: .destructive) {
+ showClearAllConfirmation = true
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+
+ EditButton()
+ }
+ }
+ }
+ .alert("Clear history?", isPresented: $showClearAllConfirmation) {
+ Button("Clear All", role: .destructive) {
+ viewModel.clearHistory()
}
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This will delete all saved history entries. This cannot be undone.")
+ }
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ dismiss()
}
.preferredColorScheme(.dark)
}
@@ -60,6 +83,7 @@ struct HistoryView: View {
struct HistoryDetailView: View {
@Bindable var viewModel: DomainViewModel
let entry: HistoryEntry
+ @Environment(\.dismiss) private var dismiss
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
@@ -84,12 +108,30 @@ struct HistoryDetailView: View {
showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
availabilityLoading: false,
suggestionsLoading: false,
- isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
- onToggleWatch: {
- viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
- }
+ trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
+ trackingLimitMessage: nil,
+ onTrack: {
+ _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
+ },
+ onTogglePinned: {
+ guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
+ viewModel.togglePinned(for: trackedDomain)
+ },
+ onEditNote: nil
)
.padding(.top, 16)
+ if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
+ if let changeSummary = entry.changeSummary {
+ DomainChangeSummaryView(summary: changeSummary)
+ .padding(.top, 16)
+ }
+ DomainDiffView(
+ title: "Compared With Previous Snapshot",
+ sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
+ showsUnchanged: false
+ )
+ .padding(.top, 16)
+ }
DNSSectionView(
dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
sections: DomainViewModel.dnsRows(from: snapshot),
@@ -150,6 +192,9 @@ struct HistoryDetailView: View {
viewModel.rerunLookup(from: entry)
}
}
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ dismiss()
+ }
.preferredColorScheme(.dark)
}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 5a836ba..e571da9 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -48,6 +48,54 @@ struct WatchedDomain: Codable, Identifiable {
}
}
+struct DomainChangeSummary: Codable, Equatable {
+ let hasChanges: Bool
+ let changedSections: [String]
+ let generatedAt: Date
+}
+
+enum PremiumCapability: String, Codable {
+ case unlimitedTrackedDomains
+ case automatedMonitoring
+ case pushAlerts
+ case batchTracking
+ case advancedExports
+}
+
+struct TrackedDomain: Codable, Identifiable, Equatable {
+ let id: UUID
+ var domain: String
+ var createdAt: Date
+ var updatedAt: Date
+ var note: String?
+ var isPinned: Bool
+ var lastKnownAvailability: DomainAvailabilityStatus?
+ var lastSnapshotID: UUID?
+ var lastChangeSummary: DomainChangeSummary?
+
+ init(
+ id: UUID = UUID(),
+ domain: String,
+ createdAt: Date = Date(),
+ updatedAt: Date = Date(),
+ note: String? = nil,
+ isPinned: Bool = false,
+ lastKnownAvailability: DomainAvailabilityStatus? = nil,
+ lastSnapshotID: UUID? = nil,
+ lastChangeSummary: DomainChangeSummary? = nil
+ ) {
+ self.id = id
+ self.domain = domain
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.note = note
+ self.isPinned = isPinned
+ self.lastKnownAvailability = lastKnownAvailability
+ self.lastSnapshotID = lastSnapshotID
+ self.lastChangeSummary = lastChangeSummary
+ }
+}
+
// MARK: - DNS Models
enum DNSRecordType: String, CaseIterable, Codable {
@@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable {
var id = UUID()
let domain: String
let timestamp: Date
+ var trackedDomainID: UUID?
let dnsSections: [DNSSection]
let sslInfo: SSLCertificateInfo?
let httpHeaders: [HTTPHeader]
@@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable {
var resolverDisplayName: String
var resolverURLString: String
var totalLookupDurationMs: Int?
+ var primaryIP: String?
+ var finalRedirectURL: String?
+ var tlsStatusSummary: String?
+ var emailSecuritySummary: String?
+ var httpGradeSummary: String?
+ var changeSummary: DomainChangeSummary?
var sslError: String?
var httpHeadersError: String?
var reachabilityError: String?
@@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable {
var redirectChainError: String?
var portScanError: String?
- init(domain: String, timestamp: Date, dnsSections: [DNSSection],
+ init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
- totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
+ totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
+ tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
+ changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
reachabilityError: String? = nil, ipGeolocationError: String? = nil,
emailSecurityError: String? = nil, ptrError: String? = nil,
redirectChainError: String? = nil, portScanError: String? = nil) {
self.domain = domain
self.timestamp = timestamp
+ self.trackedDomainID = trackedDomainID
self.dnsSections = dnsSections
self.sslInfo = sslInfo
self.httpHeaders = httpHeaders
@@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable {
self.resolverDisplayName = resolverDisplayName
self.resolverURLString = resolverURLString
self.totalLookupDurationMs = totalLookupDurationMs
+ self.primaryIP = primaryIP
+ self.finalRedirectURL = finalRedirectURL
+ self.tlsStatusSummary = tlsStatusSummary
+ self.emailSecuritySummary = emailSecuritySummary
+ self.httpGradeSummary = httpGradeSummary
+ self.changeSummary = changeSummary
self.sslError = sslError
self.httpHeadersError = httpHeadersError
self.reachabilityError = reachabilityError
@@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable {
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
domain = try container.decode(String.self, forKey: .domain)
timestamp = try container.decode(Date.self, forKey: .timestamp)
+ trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections)
sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
@@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable {
resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
+ primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
+ finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
+ tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
+ emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
+ httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
+ changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift
new file mode 100644
index 0000000..698a2d2
--- /dev/null
+++ b/DomainDig/PremiumAccessService.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+enum PremiumAccessService {
+ static let freeTrackedDomainLimit = 3
+
+ static func hasAccess(to capability: PremiumCapability) -> Bool {
+ switch capability {
+ case .unlimitedTrackedDomains,
+ .automatedMonitoring,
+ .pushAlerts,
+ .batchTracking,
+ .advancedExports:
+ return false
+ }
+ }
+
+ static func trackedDomainLimitMessage(currentCount: Int) -> String? {
+ guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else {
+ return nil
+ }
+ return "More tracked domains will be available in a future Pro upgrade."
+ }
+
+ static func canAddTrackedDomain(currentCount: Int) -> Bool {
+ hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit
+ }
+}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index c44fc65..aea8707 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -6,31 +6,86 @@ struct WatchlistView: View {
var body: some View {
List {
- if viewModel.watchedDomains.isEmpty {
- Text("No watched domains")
- .font(.system(.callout, design: .monospaced))
- .foregroundStyle(.secondary)
- .listRowBackground(Color(.systemGray6).opacity(0.5))
+ if viewModel.sortedTrackedDomains.isEmpty {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("No tracked domains yet")
+ .font(.system(.callout, design: .monospaced))
+ .foregroundStyle(.primary)
+ Text("Tracked domains appear here. Tracking is local and manual for now.")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 8)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
} else {
- ForEach(viewModel.watchedDomains) { watchedDomain in
- Button {
- viewModel.domain = watchedDomain.domain
- dismiss()
- viewModel.run()
- } label: {
- VStack(alignment: .leading, spacing: 4) {
- Text(watchedDomain.domain)
- .font(.system(.callout, design: .monospaced))
- .foregroundStyle(.primary)
- Text(statusLabel(watchedDomain.lastKnownAvailability))
- .font(.system(.caption2, design: .monospaced))
- .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability))
- }
+ if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
+ Section {
+ Text(limitMessage)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
}
.listRowBackground(Color(.systemGray6).opacity(0.5))
}
- .onDelete { offsets in
- viewModel.removeWatchedDomains(at: offsets)
+
+ Section {
+ ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
+ NavigationLink {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+ } label: {
+ WatchlistRowView(
+ trackedDomain: trackedDomain,
+ isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
+ )
+ }
+ .buttonStyle(.plain)
+ .swipeActions(edge: .leading, allowsFullSwipe: false) {
+ Button {
+ viewModel.togglePinned(for: trackedDomain)
+ } label: {
+ Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
+ }
+ .tint(.yellow)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ viewModel.deleteTrackedDomain(trackedDomain)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ .contextMenu {
+ Button {
+ viewModel.refreshTrackedDomain(trackedDomain)
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+
+ Button {
+ dismiss()
+ viewModel.rerunInspection(for: trackedDomain)
+ } label: {
+ Label("Open Inspection", systemImage: "magnifyingglass")
+ }
+
+ Button {
+ viewModel.togglePinned(for: trackedDomain)
+ } label: {
+ Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
+ }
+
+ Button(role: .destructive) {
+ viewModel.deleteTrackedDomain(trackedDomain)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ .onDelete(perform: viewModel.deleteTrackedDomains)
+ } header: {
+ Text("Tracked Domains")
}
}
}
@@ -38,14 +93,59 @@ struct WatchlistView: View {
.background(Color.black)
.navigationTitle("Watchlist")
.toolbar {
- if !viewModel.watchedDomains.isEmpty {
+ if !viewModel.sortedTrackedDomains.isEmpty {
EditButton()
}
}
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ dismiss()
+ }
.preferredColorScheme(.dark)
}
+}
+
+struct WatchlistRowView: View {
+ let trackedDomain: TrackedDomain
+ let isRefreshing: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ if trackedDomain.isPinned {
+ Image(systemName: "pin.fill")
+ .font(.caption2)
+ .foregroundStyle(.yellow)
+ }
+ Text(trackedDomain.domain)
+ .font(.system(.callout, design: .monospaced))
+ .foregroundStyle(.primary)
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ Spacer(minLength: 8)
+ statusBadge
+ }
+
+ Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+
+ if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
+ Text(note)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ } else if let summary = trackedDomain.lastChangeSummary {
+ Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ }
- private func statusLabel(_ status: DomainAvailabilityStatus?) -> String {
+ private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
switch status {
case .available:
return "Available"
@@ -56,8 +156,33 @@ struct WatchlistView: View {
}
}
- private func statusColor(_ status: DomainAvailabilityStatus?) -> Color {
- switch status {
+ @ViewBuilder
+ private var statusBadge: some View {
+ if isRefreshing {
+ HStack(spacing: 6) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Refreshing")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color(.systemGray5).opacity(0.6))
+ .clipShape(Capsule())
+ } else {
+ Text(availabilityLabel(trackedDomain.lastKnownAvailability))
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(badgeColor)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(badgeBackground)
+ .clipShape(Capsule())
+ }
+ }
+
+ private var badgeColor: Color {
+ switch trackedDomain.lastKnownAvailability {
case .available:
return .green
case .registered:
@@ -66,4 +191,145 @@ struct WatchlistView: View {
return .secondary
}
}
+
+ private var badgeBackground: Color {
+ switch trackedDomain.lastKnownAvailability {
+ case .available:
+ return .green.opacity(0.16)
+ case .registered:
+ return .yellow.opacity(0.16)
+ case .unknown, .none:
+ return Color(.systemGray5).opacity(0.6)
+ }
+ }
+}
+
+struct TrackedDomainDetailView: View {
+ @Bindable var viewModel: DomainViewModel
+ let trackedDomain: TrackedDomain
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var noteDraft = ""
+ @State private var isEditingNote = false
+
+ private var liveTrackedDomain: TrackedDomain {
+ viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
+ }
+
+ private var latestSnapshots: [HistoryEntry] {
+ viewModel.recentSnapshots(for: liveTrackedDomain)
+ }
+
+ private var latestDiffSections: [DomainDiffSection] {
+ viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
+ }
+
+ var body: some View {
+ List {
+ Section {
+ WatchlistRowView(
+ trackedDomain: liveTrackedDomain,
+ isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ Section {
+ Button {
+ viewModel.refreshTrackedDomain(liveTrackedDomain)
+ } label: {
+ Label("Manual Refresh", systemImage: "arrow.clockwise")
+ }
+
+ Button {
+ viewModel.rerunInspection(for: liveTrackedDomain)
+ } label: {
+ Label("Re-run Inspection", systemImage: "magnifyingglass")
+ }
+
+ Button {
+ viewModel.togglePinned(for: liveTrackedDomain)
+ } label: {
+ Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
+ }
+
+ Button {
+ noteDraft = liveTrackedDomain.note ?? ""
+ isEditingNote = true
+ } label: {
+ Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+
+ if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
+ Section("Latest Change Summary") {
+ DomainChangeSummaryView(summary: summary)
+ }
+ .listRowBackground(Color.clear)
+ }
+
+ if !latestDiffSections.isEmpty {
+ Section("Latest Diff") {
+ DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false)
+ }
+ .listRowBackground(Color.clear)
+ }
+
+ Section("Recent Snapshots") {
+ if latestSnapshots.isEmpty {
+ Text("No snapshots yet")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(latestSnapshots) { entry in
+ NavigationLink {
+ HistoryDetailView(viewModel: viewModel, entry: entry)
+ } label: {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle(liveTrackedDomain.domain)
+ .preferredColorScheme(.dark)
+ .onChange(of: viewModel.rerunNavigationToken) { _, _ in
+ dismiss()
+ }
+ .sheet(isPresented: $isEditingNote) {
+ NavigationStack {
+ Form {
+ Section("Tracking Note") {
+ TextField("Optional note", text: $noteDraft, axis: .vertical)
+ .lineLimit(3...6)
+ }
+ }
+ .navigationTitle("Edit Note")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isEditingNote = false
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ viewModel.updateNote(noteDraft, for: liveTrackedDomain)
+ isEditingNote = false
+ }
+ }
+ }
+ }
+ }
+ }
}