Commit 9999f876fe

9999f876feb9a2d419a79ad601f78053c44d44f8

parent: 397c7c420c

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-20 21:24 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -267,7 +267,7 @@
267267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 12;
270 CURRENT_PROJECT_VERSION = 13;
271271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272272 ENABLE_PREVIEWS = YES;
273273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284284 "$(inherited)",
285285 "@executable_path/Frameworks",
286286 );
287 MARKETING_VERSION = 1.9.0;
287 MARKETING_VERSION = 2.0.0;
288288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289289 PRODUCT_NAME = "$(TARGET_NAME)";
290290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 12;
306 CURRENT_PROJECT_VERSION = 13;
307307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308308 ENABLE_PREVIEWS = YES;
309309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320320 "$(inherited)",
321321 "@executable_path/Frameworks",
322322 );
323 MARKETING_VERSION = 1.9.0;
323 MARKETING_VERSION = 2.0.0;
324324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325325 PRODUCT_NAME = "$(TARGET_NAME)";
326326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +262 −25
@@ -3,12 +3,16 @@ import SwiftUI
33
44struct ContentView: View {
55 @State private var viewModel = DomainViewModel()
6 @State private var navigationPath = NavigationPath()
67 @FocusState private var domainFieldFocused: Bool
78 @State private var customPortInput = ""
89 @State private var customPortsExpanded = false
10 @State private var trackingNoteDraft = ""
11 @State private var editingTrackedDomain: TrackedDomain?
12 @State private var showTrackLimitAlert = false
913
1014 var body: some View {
11 NavigationStack {
15 NavigationStack(path: $navigationPath) {
1216 ScrollView(.vertical) {
1317 VStack(spacing: 0) {
1418 inputSection
@@ -16,16 +20,42 @@ struct ContentView: View {
1620 actionButtons
1721 SummaryView(fields: viewModel.summaryFields)
1822 .padding(.top, 8)
23 if let changeSummary = viewModel.currentChangeSummary {
24 DomainChangeSummaryView(summary: changeSummary)
25 .padding(.top, 12)
26 }
1927 DomainSectionView(
2028 rows: viewModel.domainRows,
2129 suggestions: viewModel.suggestionRows,
2230 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
2331 availabilityLoading: viewModel.availabilityLoading,
2432 suggestionsLoading: viewModel.suggestionsLoading,
25 isWatched: viewModel.isCurrentDomainWatched,
26 onToggleWatch: { viewModel.toggleWatchedDomain() }
33 trackedDomain: viewModel.currentTrackedDomain,
34 trackingLimitMessage: viewModel.trackingLimitMessage,
35 onTrack: {
36 if !viewModel.trackCurrentDomain() {
37 showTrackLimitAlert = true
38 }
39 },
40 onTogglePinned: {
41 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
42 viewModel.togglePinned(for: trackedDomain)
43 },
44 onEditNote: {
45 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
46 trackingNoteDraft = trackedDomain.note ?? ""
47 editingTrackedDomain = trackedDomain
48 }
2749 )
2850 .padding(.top, 16)
51 if !viewModel.currentDiffSections.isEmpty {
52 DomainDiffView(
53 title: "Latest Changes",
54 sections: viewModel.currentDiffSections,
55 showsUnchanged: false
56 )
57 .padding(.top, 16)
58 }
2959 DNSSectionView(
3060 dnssecLabel: viewModel.dnssecLabel,
3161 sections: viewModel.dnsRows,
@@ -96,28 +126,32 @@ struct ContentView: View {
96126 .foregroundStyle(.secondary)
97127 }
98128 }
99 NavigationLink {
100 SavedDomainsView(viewModel: viewModel)
101 } label: {
102 Image(systemName: "bookmark")
103 .foregroundStyle(.secondary)
104 }
105 NavigationLink {
106 HistoryView(viewModel: viewModel)
107 } label: {
108 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
109 .foregroundStyle(.secondary)
110 }
111129 NavigationLink {
112130 WatchlistView(viewModel: viewModel)
113131 } label: {
114132 Image(systemName: "eye")
115133 .foregroundStyle(.secondary)
116134 }
117 NavigationLink {
118 SettingsView()
135 Menu {
136 NavigationLink {
137 HistoryView(viewModel: viewModel)
138 } label: {
139 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
140 }
141
142 NavigationLink {
143 SavedDomainsView(viewModel: viewModel)
144 } label: {
145 Label("Saved Domains", systemImage: "bookmark")
146 }
147
148 NavigationLink {
149 SettingsView()
150 } label: {
151 Label("Settings", systemImage: "gearshape")
152 }
119153 } label: {
120 Image(systemName: "gearshape")
154 Image(systemName: "ellipsis.circle")
121155 .foregroundStyle(.secondary)
122156 }
123157 }
@@ -126,6 +160,41 @@ struct ContentView: View {
126160 .onAppear {
127161 domainFieldFocused = true
128162 }
163 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
164 navigationPath = NavigationPath()
165 domainFieldFocused = false
166 }
167 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
168 Button("OK", role: .cancel) {}
169 } message: {
170 Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.")
171 }
172 .sheet(item: $editingTrackedDomain) { trackedDomain in
173 NavigationStack {
174 Form {
175 Section("Tracking Note") {
176 TextField("Optional note", text: $trackingNoteDraft, axis: .vertical)
177 .lineLimit(3...6)
178 .textInputAutocapitalization(.never)
179 .autocorrectionDisabled()
180 }
181 }
182 .navigationTitle(trackedDomain.domain)
183 .toolbar {
184 ToolbarItem(placement: .cancellationAction) {
185 Button("Cancel") {
186 editingTrackedDomain = nil
187 }
188 }
189 ToolbarItem(placement: .confirmationAction) {
190 Button("Save") {
191 viewModel.updateNote(trackingNoteDraft, for: trackedDomain)
192 editingTrackedDomain = nil
193 }
194 }
195 }
196 }
197 }
129198 }
130199
131200 private var inputSection: some View {
@@ -292,30 +361,192 @@ struct SummaryView: View {
292361 }
293362}
294363
364struct DomainChangeSummaryView: View {
365 let summary: DomainChangeSummary
366
367 var body: some View {
368 CardView(allowsHorizontalScroll: false) {
369 HStack {
370 Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
371 .font(.system(.caption, design: .monospaced))
372 .foregroundStyle(summary.hasChanges ? .yellow : .green)
373 Spacer()
374 Text(summary.generatedAt, style: .time)
375 .font(.system(.caption2, design: .monospaced))
376 .foregroundStyle(.secondary)
377 }
378
379 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
380 .font(.system(.caption, design: .monospaced))
381 .foregroundStyle(.primary)
382 }
383 }
384}
385
386struct DomainDiffView: View {
387 let title: String
388 let sections: [DomainDiffSection]
389 let showsUnchanged: Bool
390
391 private var filteredSections: [DomainDiffSection] {
392 guard !showsUnchanged else { return sections }
393 return sections
394 .map { section in
395 DomainDiffSection(
396 title: section.title,
397 items: section.items.filter { $0.changeType != .unchanged }
398 )
399 }
400 .filter { !$0.items.isEmpty }
401 }
402
403 var body: some View {
404 VStack(alignment: .leading, spacing: 12) {
405 SectionTitleView(title: title)
406 if filteredSections.isEmpty {
407 MessageCardView(text: "No comparison data available", isError: false)
408 } else {
409 ForEach(filteredSections) { section in
410 CardView(allowsHorizontalScroll: false) {
411 Text(section.title)
412 .font(.system(.subheadline, design: .monospaced))
413 .fontWeight(.semibold)
414 .foregroundStyle(.cyan)
415
416 ForEach(section.items) { item in
417 VStack(alignment: .leading, spacing: 4) {
418 HStack {
419 Text(item.label)
420 .font(.system(.caption, design: .monospaced))
421 .foregroundStyle(.secondary)
422 Spacer()
423 Text(changeLabel(for: item.changeType))
424 .font(.system(.caption2, design: .monospaced))
425 .foregroundStyle(changeColor(for: item.changeType))
426 }
427 if let oldValue = item.oldValue {
428 Text("Old: \(oldValue)")
429 .font(.system(.caption2, design: .monospaced))
430 .foregroundStyle(.secondary)
431 .textSelection(.enabled)
432 }
433 if let newValue = item.newValue {
434 Text("New: \(newValue)")
435 .font(.system(.caption, design: .monospaced))
436 .foregroundStyle(.primary)
437 .textSelection(.enabled)
438 }
439 }
440 }
441 }
442 }
443 }
444 }
445 }
446
447 private func changeLabel(for changeType: DiffChangeType) -> String {
448 switch changeType {
449 case .added:
450 return "Added"
451 case .removed:
452 return "Removed"
453 case .changed:
454 return "Changed"
455 case .unchanged:
456 return "Unchanged"
457 }
458 }
459
460 private func changeColor(for changeType: DiffChangeType) -> Color {
461 switch changeType {
462 case .added:
463 return .green
464 case .removed:
465 return .red
466 case .changed:
467 return .yellow
468 case .unchanged:
469 return .secondary
470 }
471 }
472}
473
474struct TrackedDomainDetailHeaderView: View {
475 let trackedDomain: TrackedDomain
476
477 var body: some View {
478 VStack(alignment: .leading, spacing: 4) {
479 if let note = trackedDomain.note?.nilIfEmpty {
480 LabeledValueRow(row: InfoRowViewData(label: "Tracking Note", value: note, tone: .secondary))
481 }
482 HStack(spacing: 8) {
483 if trackedDomain.isPinned {
484 Label("Pinned", systemImage: "pin.fill")
485 }
486 Text("Last refresh \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
487 }
488 .font(.system(.caption2, design: .monospaced))
489 .foregroundStyle(.secondary)
490 }
491 }
492}
493
295494struct DomainSectionView: View {
296495 let rows: [InfoRowViewData]
297496 let suggestions: [DomainSuggestionViewData]
298497 let showSuggestions: Bool
299498 let availabilityLoading: Bool
300499 let suggestionsLoading: Bool
301 let isWatched: Bool
302 let onToggleWatch: () -> Void
500 let trackedDomain: TrackedDomain?
501 let trackingLimitMessage: String?
502 let onTrack: () -> Void
503 let onTogglePinned: () -> Void
504 let onEditNote: (() -> Void)?
303505
304506 var body: some View {
305507 VStack(alignment: .leading, spacing: 12) {
306508 HStack {
307509 SectionTitleView(title: "Domain")
308510 Spacer()
309 Button(isWatched ? "Watching" : "Watch") {
310 onToggleWatch()
511 if let trackedDomain {
512 HStack(spacing: 8) {
513 Text("Tracked")
514 .font(.system(.caption, design: .monospaced))
515 .foregroundStyle(.green)
516 Button {
517 onTogglePinned()
518 } label: {
519 Image(systemName: trackedDomain.isPinned ? "pin.fill" : "pin")
520 }
521 .buttonStyle(.bordered)
522 .font(.system(.caption, design: .monospaced))
523 if let onEditNote {
524 Button("Note") {
525 onEditNote()
526 }
527 .buttonStyle(.bordered)
528 .font(.system(.caption, design: .monospaced))
529 }
530 }
531 } else {
532 Button("Track") {
533 onTrack()
534 }
535 .buttonStyle(.bordered)
536 .font(.system(.caption, design: .monospaced))
311537 }
312 .buttonStyle(.bordered)
313 .font(.system(.caption, design: .monospaced))
314538 }
315 CardView {
539 CardView(allowsHorizontalScroll: false) {
316540 ForEach(rows) { row in
317541 LabeledValueRow(row: row)
318542 }
543 if let trackedDomain {
544 TrackedDomainDetailHeaderView(trackedDomain: trackedDomain)
545 .padding(.top, 4)
546 } else if let trackingLimitMessage {
547 MessageRowView(text: trackingLimitMessage, isError: false)
548 .padding(.top, 4)
549 }
319550 if availabilityLoading {
320551 ProgressView("Checking availability…")
321552 .appLoadingStyle()
@@ -888,6 +1119,12 @@ private extension View {
8881119 }
8891120}
8901121
1122private extension String {
1123 var nilIfEmpty: String? {
1124 isEmpty ? nil : self
1125 }
1126}
1127
8911128private struct SettingsView: View {
8921129 @AppStorage(DNSResolverOption.userDefaultsKey)
8931130 private var storedResolverURL = DNSResolverOption.defaultURLString
DomainDig/DomainDiffService.swift added +197
@@ -0,0 +1,197 @@
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}
17
18struct DomainDiffSection: Identifiable, Equatable {
19 let id = UUID()
20 let title: String
21 let items: [DomainDiffItem]
22}
23
24enum DomainDiffService {
25 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
26 [
27 section(title: "Availability", item: compare(
28 label: "Status",
29 oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
30 newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
31 )),
32 section(title: "Primary IP", item: compare(
33 label: "Address",
34 oldValue: primaryIP(from: oldSnapshot),
35 newValue: primaryIP(from: newSnapshot)
36 )),
37 dnsSection(from: oldSnapshot, to: newSnapshot),
38 section(title: "Redirect", item: compare(
39 label: "Final Target",
40 oldValue: finalRedirectURL(from: oldSnapshot),
41 newValue: finalRedirectURL(from: newSnapshot)
42 )),
43 tlsSection(from: oldSnapshot, to: newSnapshot),
44 httpSection(from: oldSnapshot, to: newSnapshot),
45 emailSection(from: oldSnapshot, to: newSnapshot)
46 ]
47 .filter { !$0.items.isEmpty }
48 }
49
50 static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
51 let changedSections = diff(from: oldSnapshot, to: newSnapshot)
52 .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
53 .map(\.title)
54
55 return DomainChangeSummary(
56 hasChanges: !changedSections.isEmpty,
57 changedSections: changedSections,
58 generatedAt: generatedAt
59 )
60 }
61
62 private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
63 DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
64 }
65
66 private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
67 let oldValue = normalizedDNSSummary(from: oldSnapshot)
68 let newValue = normalizedDNSSummary(from: newSnapshot)
69 return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
70 }
71
72 private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
73 var items: [DomainDiffItem] = []
74 if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
75 items.append(item)
76 }
77 if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
78 items.append(item)
79 }
80 return DomainDiffSection(title: "TLS Certificate", items: items)
81 }
82
83 private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
84 var items: [DomainDiffItem] = []
85 if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
86 items.append(item)
87 }
88 if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
89 items.append(item)
90 }
91 return DomainDiffSection(title: "HTTP", items: items)
92 }
93
94 private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
95 section(
96 title: "Email Security",
97 item: compare(
98 label: "Summary",
99 oldValue: normalized(emailSummary(from: oldSnapshot)),
100 newValue: normalized(emailSummary(from: newSnapshot))
101 )
102 )
103 }
104
105 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
106 let oldValue = normalized(oldValue)
107 let newValue = normalized(newValue)
108
109 guard oldValue != nil || newValue != nil else {
110 return nil
111 }
112
113 let changeType: DiffChangeType
114 switch (oldValue, newValue) {
115 case let (old?, new?) where old == new:
116 changeType = .unchanged
117 case (nil, _?):
118 changeType = .added
119 case (_?, nil):
120 changeType = .removed
121 default:
122 changeType = .changed
123 }
124
125 return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
126 }
127
128 private static func normalized(_ value: String?) -> String? {
129 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
130 return nil
131 }
132 return value.lowercased()
133 }
134
135 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
136 switch status {
137 case .available:
138 return "available"
139 case .registered:
140 return "registered"
141 case .unknown:
142 return "unknown"
143 case .none:
144 return nil
145 }
146 }
147
148 private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
149 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
150 }
151
152 private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
153 snapshot.redirectChain.last?.url
154 }
155
156 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
157 if let sslInfo = snapshot.sslInfo {
158 return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
159 }
160 return snapshot.sslError
161 }
162
163 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
164 if let httpStatusCode = snapshot.httpStatusCode {
165 return "\(httpStatusCode)"
166 }
167 return snapshot.httpHeadersError
168 }
169
170 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
171 if let emailSecurity = snapshot.emailSecurity {
172 return [
173 "spf:\(emailSecurity.spf.found)",
174 "dmarc:\(emailSecurity.dmarc.found)",
175 "dkim:\(emailSecurity.dkim.found)",
176 "bimi:\(emailSecurity.bimi.found)",
177 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
178 ].joined(separator: "|")
179 }
180 return snapshot.emailSecurityError
181 }
182
183 private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
184 let parts = snapshot.dnsSections
185 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
186 .map { section in
187 let values = (section.records + section.wildcardRecords)
188 .map(\.value)
189 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
190 .sorted()
191 .joined(separator: ",")
192 return "\(section.recordType.rawValue):\(values)"
193 }
194 .filter { !$0.hasSuffix(":") }
195 return parts.isEmpty ? nil : parts.joined(separator: "|")
196 }
197}
DomainDig/DomainViewModel.swift +340 −41
@@ -80,8 +80,10 @@ struct DomainSuggestionViewData: Identifiable {
8080}
8181
8282struct LookupSnapshot {
83 let historyEntryID: UUID?
8384 let domain: String
8485 let timestamp: Date
86 let trackedDomainID: UUID?
8587 let resolverDisplayName: String
8688 let resolverURLString: String
8789 let totalLookupDurationMs: Int?
@@ -111,14 +113,17 @@ struct LookupSnapshot {
111113 let redirectChainError: String?
112114 let portScanResults: [PortScanResult]
113115 let portScanError: String?
116 let changeSummary: DomainChangeSummary?
114117 let isLive: Bool
115118}
116119
117120extension HistoryEntry {
118121 var snapshot: LookupSnapshot {
119122 LookupSnapshot(
123 historyEntryID: id,
120124 domain: domain,
121125 timestamp: timestamp,
126 trackedDomainID: trackedDomainID,
122127 resolverDisplayName: resolverDisplayName,
123128 resolverURLString: resolverURLString,
124129 totalLookupDurationMs: totalLookupDurationMs,
@@ -148,6 +153,7 @@ extension HistoryEntry {
148153 redirectChainError: redirectChainError,
149154 portScanResults: portScanResults,
150155 portScanError: portScanError,
156 changeSummary: changeSummary,
151157 isLive: false
152158 )
153159 }
@@ -211,6 +217,10 @@ final class DomainViewModel {
211217 var hasRun = false
212218 private(set) var searchedDomain: String = ""
213219 private(set) var lastLookupDurationMs: Int?
220 private(set) var currentDiffSections: [DomainDiffSection] = []
221 private(set) var currentChangeSummary: DomainChangeSummary?
222 private(set) var refreshingTrackedDomainID: UUID?
223 private(set) var rerunNavigationToken = UUID()
214224
215225 private var lookupTask: Task<Void, Never>?
216226 private var customPortScanTask: Task<Void, Never>?
@@ -224,14 +234,9 @@ final class DomainViewModel {
224234 private static let savedDomainsKey = "savedDomains"
225235 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
226236
227 private static let watchedDomainsKey = "watchedDomains"
228 var watchedDomains: [WatchedDomain] = {
229 guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey),
230 let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else {
231 return []
232 }
233 return domains
234 }()
237 private static let trackedDomainsKey = "trackedDomains"
238 private static let legacyWatchedDomainsKey = "watchedDomains"
239 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
235240
236241 private static let historyKey = "lookupHistory"
237242 private static let maxHistory = 50
@@ -276,8 +281,35 @@ final class DomainViewModel {
276281 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
277282 }
278283
279 var isCurrentDomainWatched: Bool {
280 !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() })
284 var sortedTrackedDomains: [TrackedDomain] {
285 trackedDomains.sorted {
286 if $0.isPinned != $1.isPinned {
287 return $0.isPinned && !$1.isPinned
288 }
289 if $0.updatedAt != $1.updatedAt {
290 return $0.updatedAt > $1.updatedAt
291 }
292 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
293 }
294 }
295
296 var currentTrackedDomain: TrackedDomain? {
297 guard !searchedDomain.isEmpty else { return nil }
298 return trackedDomain(for: searchedDomain)
299 }
300
301 var isCurrentDomainTracked: Bool {
302 currentTrackedDomain != nil
303 }
304
305 var trackingLimitMessage: String? {
306 guard currentTrackedDomain == nil else { return nil }
307 guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
308 return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
309 }
310
311 var canTrackCurrentDomain: Bool {
312 currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
281313 }
282314
283315 var resolverDisplayName: String {
@@ -299,8 +331,10 @@ final class DomainViewModel {
299331
300332 var currentSnapshot: LookupSnapshot {
301333 LookupSnapshot(
334 historyEntryID: nil,
302335 domain: searchedDomain,
303336 timestamp: Date(),
337 trackedDomainID: currentTrackedDomain?.id,
304338 resolverDisplayName: resolverDisplayName,
305339 resolverURLString: resolverURLString,
306340 totalLookupDurationMs: lastLookupDurationMs,
@@ -330,6 +364,7 @@ final class DomainViewModel {
330364 redirectChainError: redirectChainError,
331365 portScanResults: allPortScanResults,
332366 portScanError: combinedPortScanError,
367 changeSummary: currentChangeSummary,
333368 isLive: true
334369 )
335370 }
@@ -408,31 +443,84 @@ final class DomainViewModel {
408443 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
409444 }
410445
411 func toggleWatchedDomain() {
412 guard !searchedDomain.isEmpty else { return }
413 toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
446 @discardableResult
447 func trackCurrentDomain() -> Bool {
448 guard !searchedDomain.isEmpty else { return false }
449 return trackDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status)
414450 }
415451
416 func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) {
417 guard !domain.isEmpty else { return }
452 @discardableResult
453 func trackDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) -> Bool {
454 let normalizedDomain = normalizedDomain(domain)
455 guard !normalizedDomain.isEmpty else { return false }
418456
419 if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) {
420 watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() }
421 } else {
422 watchedDomains.insert(
423 WatchedDomain(
424 domain: domain,
425 lastKnownAvailability: availabilityStatus
426 ),
427 at: 0
428 )
457 if trackedDomain(for: normalizedDomain) != nil {
458 return true
429459 }
430 persistWatchedDomains()
460
461 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
462 return false
463 }
464
465 trackedDomains.insert(
466 TrackedDomain(
467 domain: normalizedDomain,
468 createdAt: Date(),
469 updatedAt: Date(),
470 lastKnownAvailability: availabilityStatus
471 ),
472 at: 0
473 )
474 persistTrackedDomains()
475 linkTrackedDomainHistory(for: normalizedDomain)
476 return true
431477 }
432478
433 func removeWatchedDomains(at offsets: IndexSet) {
434 watchedDomains.remove(atOffsets: offsets)
435 persistWatchedDomains()
479 func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
480 refreshingTrackedDomainID = trackedDomain.id
481 domain = trackedDomain.domain
482 run()
483 }
484
485 func rerunInspection(for trackedDomain: TrackedDomain) {
486 domain = trackedDomain.domain
487 run()
488 rerunNavigationToken = UUID()
489 }
490
491 func deleteTrackedDomains(at offsets: IndexSet) {
492 let ids = offsets.map { sortedTrackedDomains[$0].id }
493 trackedDomains.removeAll { ids.contains($0.id) }
494 history.indices.forEach { index in
495 if let trackedDomainID = history[index].trackedDomainID, ids.contains(trackedDomainID) {
496 history[index].trackedDomainID = nil
497 }
498 }
499 persistTrackedDomains()
500 persistHistory()
501 }
502
503 func deleteTrackedDomain(_ trackedDomain: TrackedDomain) {
504 trackedDomains.removeAll { $0.id == trackedDomain.id }
505 history.indices.forEach { index in
506 if history[index].trackedDomainID == trackedDomain.id {
507 history[index].trackedDomainID = nil
508 }
509 }
510 persistTrackedDomains()
511 persistHistory()
512 }
513
514 func togglePinned(for trackedDomain: TrackedDomain) {
515 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
516 trackedDomains[index].isPinned.toggle()
517 persistTrackedDomains()
518 }
519
520 func updateNote(_ note: String, for trackedDomain: TrackedDomain) {
521 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
522 trackedDomains[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
523 persistTrackedDomains()
436524 }
437525
438526 func removeHistoryEntries(at offsets: IndexSet) {
@@ -440,6 +528,11 @@ final class DomainViewModel {
440528 persistHistory()
441529 }
442530
531 func clearHistory() {
532 history.removeAll()
533 persistHistory()
534 }
535
443536 func clearRecentSearches() {
444537 recentSearches.removeAll()
445538 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -449,6 +542,7 @@ final class DomainViewModel {
449542 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
450543 domain = entry.domain
451544 run()
545 rerunNavigationToken = UUID()
452546 }
453547
454548 func reset() {
@@ -457,6 +551,9 @@ final class DomainViewModel {
457551 hasRun = false
458552 searchedDomain = ""
459553 lastLookupDurationMs = nil
554 currentDiffSections = []
555 currentChangeSummary = nil
556 refreshingTrackedDomainID = nil
460557 clearLookupState()
461558 }
462559
@@ -474,6 +571,8 @@ final class DomainViewModel {
474571 addRecentSearch(target)
475572 searchedDomain = target
476573 hasRun = true
574 currentDiffSections = []
575 currentChangeSummary = nil
477576 clearLookupState()
478577 setAllLoadingStates(true)
479578 customPortScanLoading = false
@@ -513,7 +612,12 @@ final class DomainViewModel {
513612 }
514613
515614 func exportText() -> String {
516 Self.formatExportText(from: currentSnapshot)
615 Self.formatExportText(
616 from: currentSnapshot,
617 trackedDomain: currentTrackedDomain,
618 changeSummary: currentChangeSummary,
619 diffSections: currentDiffSections
620 )
517621 }
518622
519623 private func performLookup(domain: String, lookupID: UUID) async {
@@ -555,6 +659,7 @@ final class DomainViewModel {
555659 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
556660 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
557661 saveHistoryEntry(replaceLatest: false)
662 refreshingTrackedDomainID = nil
558663 }
559664
560665 private func runDNS(domain: String, lookupID: UUID) async {
@@ -579,7 +684,7 @@ final class DomainViewModel {
579684 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
580685 availabilityResult = result
581686 availabilityLoading = false
582 updateWatchedDomainAvailability(for: result.domain, status: result.status)
687 updateTrackedDomainAvailability(for: result.domain, status: result.status)
583688 }
584689
585690 private func runSSL(domain: String, lookupID: UUID) async {
@@ -801,9 +906,20 @@ final class DomainViewModel {
801906
802907 private func saveHistoryEntry(replaceLatest: Bool) {
803908 guard !searchedDomain.isEmpty else { return }
909
910 let trackedDomainID = trackedDomain(for: searchedDomain)?.id
911 let timestamp = Date()
912 let snapshot = currentSnapshot
913 let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
914 let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
915
916 currentChangeSummary = changeSummary
917 currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
918
804919 let entry = HistoryEntry(
805920 domain: searchedDomain,
806 timestamp: Date(),
921 timestamp: timestamp,
922 trackedDomainID: trackedDomainID,
807923 dnsSections: dnsSections,
808924 sslInfo: sslInfo,
809925 httpHeaders: httpHeaders,
@@ -820,6 +936,12 @@ final class DomainViewModel {
820936 resolverDisplayName: resolverDisplayName,
821937 resolverURLString: resolverURLString,
822938 totalLookupDurationMs: lastLookupDurationMs,
939 primaryIP: Self.primaryIPAddress(from: snapshot),
940 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
941 tlsStatusSummary: Self.httpsSummary(from: snapshot),
942 emailSecuritySummary: Self.emailSummary(from: snapshot),
943 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
944 changeSummary: changeSummary,
823945 sslError: sslError,
824946 httpHeadersError: httpHeadersError,
825947 reachabilityError: reachabilityError,
@@ -838,6 +960,14 @@ final class DomainViewModel {
838960 history = Array(history.prefix(Self.maxHistory))
839961 }
840962 }
963
964 updateTrackedDomainSnapshotMetadata(
965 domain: searchedDomain,
966 snapshotID: entry.id,
967 availabilityStatus: availabilityResult?.status,
968 updatedAt: timestamp,
969 changeSummary: changeSummary
970 )
841971 persistHistory()
842972 }
843973
@@ -847,18 +977,82 @@ final class DomainViewModel {
847977 }
848978 }
849979
850 private func persistWatchedDomains() {
851 if let data = try? JSONEncoder().encode(watchedDomains) {
852 UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey)
980 private func persistTrackedDomains() {
981 if let data = try? JSONEncoder().encode(trackedDomains) {
982 UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
853983 }
854984 }
855985
856 private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
857 guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else {
986 private func updateTrackedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) {
987 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
858988 return
859989 }
860 watchedDomains[index].lastKnownAvailability = status
861 persistWatchedDomains()
990 trackedDomains[index].lastKnownAvailability = status
991 persistTrackedDomains()
992 }
993
994 private func updateTrackedDomainSnapshotMetadata(
995 domain: String,
996 snapshotID: UUID,
997 availabilityStatus: DomainAvailabilityStatus?,
998 updatedAt: Date,
999 changeSummary: DomainChangeSummary?
1000 ) {
1001 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
1002 return
1003 }
1004 trackedDomains[index].lastSnapshotID = snapshotID
1005 trackedDomains[index].lastKnownAvailability = availabilityStatus
1006 trackedDomains[index].updatedAt = updatedAt
1007 trackedDomains[index].lastChangeSummary = changeSummary
1008 persistTrackedDomains()
1009 }
1010
1011 private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
1012 let matchingEntries = history.filter { entry in
1013 if let trackedDomainID {
1014 return entry.trackedDomainID == trackedDomainID
1015 }
1016 return entry.domain.caseInsensitiveCompare(domain) == .orderedSame
1017 }
1018
1019 if replacingLatest {
1020 return matchingEntries.dropFirst().first?.snapshot
1021 }
1022 return matchingEntries.first?.snapshot
1023 }
1024
1025 private func trackedDomain(for domain: String) -> TrackedDomain? {
1026 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
1027 }
1028
1029 private func normalizedDomain(_ domain: String) -> String {
1030 domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1031 }
1032
1033 private func linkTrackedDomainHistory(for domain: String) {
1034 guard let trackedDomain = trackedDomain(for: domain) else { return }
1035 var didChange = false
1036
1037 for index in history.indices where history[index].domain.caseInsensitiveCompare(domain) == .orderedSame {
1038 if history[index].trackedDomainID != trackedDomain.id {
1039 history[index].trackedDomainID = trackedDomain.id
1040 didChange = true
1041 }
1042 }
1043
1044 if didChange {
1045 persistHistory()
1046 }
1047
1048 if let latestEntry = history.first(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }),
1049 let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
1050 trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
1051 trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
1052 trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
1053 trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
1054 persistTrackedDomains()
1055 }
8621056 }
8631057
8641058 private func addRecentSearch(_ domain: String) {
@@ -937,6 +1131,71 @@ final class DomainViewModel {
9371131 activeLookupID == lookupID
9381132 }
9391133
1134 func recentSnapshots(for trackedDomain: TrackedDomain, limit: Int = 6) -> [HistoryEntry] {
1135 history
1136 .filter { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }
1137 .sorted { $0.timestamp > $1.timestamp }
1138 .prefix(limit)
1139 .map { $0 }
1140 }
1141
1142 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
1143 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
1144 guard snapshots.count == 2 else { return [] }
1145 return DomainDiffService.diff(from: snapshots[1].snapshot, to: snapshots[0].snapshot)
1146 }
1147
1148 func latestChangeSummary(for trackedDomain: TrackedDomain) -> DomainChangeSummary? {
1149 trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
1150 }
1151
1152 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
1153 let siblings = history.filter { candidate in
1154 if let trackedDomainID = entry.trackedDomainID {
1155 return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id
1156 }
1157 return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id
1158 }
1159 .sorted { $0.timestamp > $1.timestamp }
1160
1161 return siblings.first?.snapshot
1162 }
1163
1164 private static func loadTrackedDomains() -> [TrackedDomain] {
1165 let defaults = UserDefaults.standard
1166 let decoder = JSONDecoder()
1167
1168 if let data = defaults.data(forKey: trackedDomainsKey),
1169 let domains = try? decoder.decode([TrackedDomain].self, from: data) {
1170 return deduplicatedTrackedDomains(domains)
1171 }
1172
1173 if let legacyData = defaults.data(forKey: legacyWatchedDomainsKey),
1174 let legacyDomains = try? decoder.decode([WatchedDomain].self, from: legacyData) {
1175 return deduplicatedTrackedDomains(
1176 legacyDomains.map {
1177 TrackedDomain(
1178 id: $0.id,
1179 domain: $0.domain.lowercased(),
1180 createdAt: $0.createdAt,
1181 updatedAt: $0.createdAt,
1182 lastKnownAvailability: $0.lastKnownAvailability
1183 )
1184 }
1185 )
1186 }
1187
1188 return []
1189 }
1190
1191 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
1192 var seen = Set<String>()
1193 return domains.filter { domain in
1194 let key = domain.domain.lowercased()
1195 return seen.insert(key).inserted
1196 }
1197 }
1198
9401199 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
9411200 [
9421201 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1110,7 +1369,12 @@ final class DomainViewModel {
11101369 }
11111370 }
11121371
1113 static func formatExportText(from snapshot: LookupSnapshot) -> String {
1372 static func formatExportText(
1373 from snapshot: LookupSnapshot,
1374 trackedDomain: TrackedDomain?,
1375 changeSummary: DomainChangeSummary?,
1376 diffSections: [DomainDiffSection]
1377 ) -> String {
11141378 let exportDateFormatter = DateFormatter()
11151379 exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
11161380
@@ -1120,9 +1384,14 @@ final class DomainViewModel {
11201384 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
11211385 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
11221386 "Resolver: \(snapshot.resolverDisplayName)",
1123 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))"
1387 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
1388 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
11241389 ]
11251390
1391 if let note = trackedDomain?.note?.nilIfEmpty {
1392 lines.append("Tracking Note: \(note)")
1393 }
1394
11261395 func appendSection(_ title: String, body: () -> Void) {
11271396 lines.append("")
11281397 lines.append(title)
@@ -1134,6 +1403,36 @@ final class DomainViewModel {
11341403 for item in summaryFields(from: snapshot) {
11351404 lines.append(" \(item.label): \(item.value)")
11361405 }
1406 if let changeSummary {
1407 lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
1408 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
1409 }
1410 }
1411
1412 appendSection("Tracking") {
1413 if let trackedDomain {
1414 lines.append(" Pinned: \(trackedDomain.isPinned ? "Yes" : "No")")
1415 lines.append(" Last Refresh: \(exportDateFormatter.string(from: trackedDomain.updatedAt))")
1416 lines.append(" Last Known Availability: \(availabilityLabel(trackedDomain.lastKnownAvailability))")
1417 if let note = trackedDomain.note?.nilIfEmpty {
1418 lines.append(" Note: \(note)")
1419 }
1420 } else {
1421 lines.append(" This domain is not currently tracked.")
1422 }
1423 }
1424
1425 appendSection("Diff Summary") {
1426 if diffSections.isEmpty {
1427 lines.append(" No comparison available")
1428 } else {
1429 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
1430 lines.append(" \(section.title)")
1431 for item in section.items where item.changeType != .unchanged {
1432 lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
1433 }
1434 }
1435 }
11371436 }
11381437
11391438 appendSection("Domain") {
DomainDig/HistoryView.swift +50 −5
@@ -2,6 +2,8 @@ import SwiftUI
22
33struct HistoryView: View {
44 @Bindable var viewModel: DomainViewModel
5 @Environment(\.dismiss) private var dismiss
6 @State private var showClearAllConfirmation = false
57
68 private let dateFormatter: DateFormatter = {
79 let formatter = DateFormatter()
@@ -50,8 +52,29 @@ struct HistoryView: View {
5052 .navigationTitle("History")
5153 .toolbar {
5254 if !viewModel.history.isEmpty {
53 EditButton()
55 ToolbarItemGroup(placement: .topBarTrailing) {
56 Menu {
57 Button("Clear All", role: .destructive) {
58 showClearAllConfirmation = true
59 }
60 } label: {
61 Image(systemName: "ellipsis.circle")
62 }
63
64 EditButton()
65 }
66 }
67 }
68 .alert("Clear history?", isPresented: $showClearAllConfirmation) {
69 Button("Clear All", role: .destructive) {
70 viewModel.clearHistory()
5471 }
72 Button("Cancel", role: .cancel) {}
73 } message: {
74 Text("This will delete all saved history entries. This cannot be undone.")
75 }
76 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
77 dismiss()
5578 }
5679 .preferredColorScheme(.dark)
5780 }
@@ -60,6 +83,7 @@ struct HistoryView: View {
6083struct HistoryDetailView: View {
6184 @Bindable var viewModel: DomainViewModel
6285 let entry: HistoryEntry
86 @Environment(\.dismiss) private var dismiss
6387
6488 private let dateFormatter: DateFormatter = {
6589 let formatter = DateFormatter()
@@ -84,12 +108,30 @@ struct HistoryDetailView: View {
84108 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
85109 availabilityLoading: false,
86110 suggestionsLoading: false,
87 isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
88 onToggleWatch: {
89 viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
90 }
111 trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
112 trackingLimitMessage: nil,
113 onTrack: {
114 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
115 },
116 onTogglePinned: {
117 guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
118 viewModel.togglePinned(for: trackedDomain)
119 },
120 onEditNote: nil
91121 )
92122 .padding(.top, 16)
123 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
124 if let changeSummary = entry.changeSummary {
125 DomainChangeSummaryView(summary: changeSummary)
126 .padding(.top, 16)
127 }
128 DomainDiffView(
129 title: "Compared With Previous Snapshot",
130 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
131 showsUnchanged: false
132 )
133 .padding(.top, 16)
134 }
93135 DNSSectionView(
94136 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
95137 sections: DomainViewModel.dnsRows(from: snapshot),
@@ -150,6 +192,9 @@ struct HistoryDetailView: View {
150192 viewModel.rerunLookup(from: entry)
151193 }
152194 }
195 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
196 dismiss()
197 }
153198 .preferredColorScheme(.dark)
154199 }
155200
DomainDig/Models.swift +73 −2
@@ -48,6 +48,54 @@ struct WatchedDomain: Codable, Identifiable {
4848 }
4949}
5050
51struct DomainChangeSummary: Codable, Equatable {
52 let hasChanges: Bool
53 let changedSections: [String]
54 let generatedAt: Date
55}
56
57enum PremiumCapability: String, Codable {
58 case unlimitedTrackedDomains
59 case automatedMonitoring
60 case pushAlerts
61 case batchTracking
62 case advancedExports
63}
64
65struct TrackedDomain: Codable, Identifiable, Equatable {
66 let id: UUID
67 var domain: String
68 var createdAt: Date
69 var updatedAt: Date
70 var note: String?
71 var isPinned: Bool
72 var lastKnownAvailability: DomainAvailabilityStatus?
73 var lastSnapshotID: UUID?
74 var lastChangeSummary: DomainChangeSummary?
75
76 init(
77 id: UUID = UUID(),
78 domain: String,
79 createdAt: Date = Date(),
80 updatedAt: Date = Date(),
81 note: String? = nil,
82 isPinned: Bool = false,
83 lastKnownAvailability: DomainAvailabilityStatus? = nil,
84 lastSnapshotID: UUID? = nil,
85 lastChangeSummary: DomainChangeSummary? = nil
86 ) {
87 self.id = id
88 self.domain = domain
89 self.createdAt = createdAt
90 self.updatedAt = updatedAt
91 self.note = note
92 self.isPinned = isPinned
93 self.lastKnownAvailability = lastKnownAvailability
94 self.lastSnapshotID = lastSnapshotID
95 self.lastChangeSummary = lastChangeSummary
96 }
97}
98
5199// MARK: - DNS Models
52100
53101enum DNSRecordType: String, CaseIterable, Codable {
@@ -321,6 +369,7 @@ struct HistoryEntry: Identifiable, Codable {
321369 var id = UUID()
322370 let domain: String
323371 let timestamp: Date
372 var trackedDomainID: UUID?
324373 let dnsSections: [DNSSection]
325374 let sslInfo: SSLCertificateInfo?
326375 let httpHeaders: [HTTPHeader]
@@ -337,6 +386,12 @@ struct HistoryEntry: Identifiable, Codable {
337386 var resolverDisplayName: String
338387 var resolverURLString: String
339388 var totalLookupDurationMs: Int?
389 var primaryIP: String?
390 var finalRedirectURL: String?
391 var tlsStatusSummary: String?
392 var emailSecuritySummary: String?
393 var httpGradeSummary: String?
394 var changeSummary: DomainChangeSummary?
340395 var sslError: String?
341396 var httpHeadersError: String?
342397 var reachabilityError: String?
@@ -346,19 +401,22 @@ struct HistoryEntry: Identifiable, Codable {
346401 var redirectChainError: String?
347402 var portScanError: String?
348403
349 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
404 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
350405 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
351406 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
352407 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
353408 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
354409 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
355410 suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
356 totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
411 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
412 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
413 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
357414 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
358415 emailSecurityError: String? = nil, ptrError: String? = nil,
359416 redirectChainError: String? = nil, portScanError: String? = nil) {
360417 self.domain = domain
361418 self.timestamp = timestamp
419 self.trackedDomainID = trackedDomainID
362420 self.dnsSections = dnsSections
363421 self.sslInfo = sslInfo
364422 self.httpHeaders = httpHeaders
@@ -375,6 +433,12 @@ struct HistoryEntry: Identifiable, Codable {
375433 self.resolverDisplayName = resolverDisplayName
376434 self.resolverURLString = resolverURLString
377435 self.totalLookupDurationMs = totalLookupDurationMs
436 self.primaryIP = primaryIP
437 self.finalRedirectURL = finalRedirectURL
438 self.tlsStatusSummary = tlsStatusSummary
439 self.emailSecuritySummary = emailSecuritySummary
440 self.httpGradeSummary = httpGradeSummary
441 self.changeSummary = changeSummary
378442 self.sslError = sslError
379443 self.httpHeadersError = httpHeadersError
380444 self.reachabilityError = reachabilityError
@@ -390,6 +454,7 @@ struct HistoryEntry: Identifiable, Codable {
390454 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
391455 domain = try container.decode(String.self, forKey: .domain)
392456 timestamp = try container.decode(Date.self, forKey: .timestamp)
457 trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
393458 dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections)
394459 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
395460 httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
@@ -406,6 +471,12 @@ struct HistoryEntry: Identifiable, Codable {
406471 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
407472 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
408473 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
474 primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
475 finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
476 tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
477 emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
478 httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
479 changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
409480 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
410481 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
411482 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
DomainDig/PremiumAccessService.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2
3enum PremiumAccessService {
4 static let freeTrackedDomainLimit = 3
5
6 static func hasAccess(to capability: PremiumCapability) -> Bool {
7 switch capability {
8 case .unlimitedTrackedDomains,
9 .automatedMonitoring,
10 .pushAlerts,
11 .batchTracking,
12 .advancedExports:
13 return false
14 }
15 }
16
17 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
18 guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else {
19 return nil
20 }
21 return "More tracked domains will be available in a future Pro upgrade."
22 }
23
24 static func canAddTrackedDomain(currentCount: Int) -> Bool {
25 hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit
26 }
27}
DomainDig/WatchlistView.swift +291 −25
@@ -6,31 +6,86 @@ struct WatchlistView: View {
66
77 var body: some View {
88 List {
9 if viewModel.watchedDomains.isEmpty {
10 Text("No watched domains")
11 .font(.system(.callout, design: .monospaced))
12 .foregroundStyle(.secondary)
13 .listRowBackground(Color(.systemGray6).opacity(0.5))
9 if viewModel.sortedTrackedDomains.isEmpty {
10 Section {
11 VStack(alignment: .leading, spacing: 8) {
12 Text("No tracked domains yet")
13 .font(.system(.callout, design: .monospaced))
14 .foregroundStyle(.primary)
15 Text("Tracked domains appear here. Tracking is local and manual for now.")
16 .font(.system(.caption, design: .monospaced))
17 .foregroundStyle(.secondary)
18 }
19 .padding(.vertical, 8)
20 }
21 .listRowBackground(Color(.systemGray6).opacity(0.5))
1422 } else {
15 ForEach(viewModel.watchedDomains) { watchedDomain in
16 Button {
17 viewModel.domain = watchedDomain.domain
18 dismiss()
19 viewModel.run()
20 } label: {
21 VStack(alignment: .leading, spacing: 4) {
22 Text(watchedDomain.domain)
23 .font(.system(.callout, design: .monospaced))
24 .foregroundStyle(.primary)
25 Text(statusLabel(watchedDomain.lastKnownAvailability))
26 .font(.system(.caption2, design: .monospaced))
27 .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability))
28 }
23 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
24 Section {
25 Text(limitMessage)
26 .font(.system(.caption, design: .monospaced))
27 .foregroundStyle(.secondary)
2928 }
3029 .listRowBackground(Color(.systemGray6).opacity(0.5))
3130 }
32 .onDelete { offsets in
33 viewModel.removeWatchedDomains(at: offsets)
31
32 Section {
33 ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
34 NavigationLink {
35 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
36 } label: {
37 WatchlistRowView(
38 trackedDomain: trackedDomain,
39 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
40 )
41 }
42 .buttonStyle(.plain)
43 .swipeActions(edge: .leading, allowsFullSwipe: false) {
44 Button {
45 viewModel.togglePinned(for: trackedDomain)
46 } label: {
47 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
48 }
49 .tint(.yellow)
50 }
51 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
52 Button(role: .destructive) {
53 viewModel.deleteTrackedDomain(trackedDomain)
54 } label: {
55 Label("Delete", systemImage: "trash")
56 }
57 }
58 .contextMenu {
59 Button {
60 viewModel.refreshTrackedDomain(trackedDomain)
61 } label: {
62 Label("Refresh", systemImage: "arrow.clockwise")
63 }
64
65 Button {
66 dismiss()
67 viewModel.rerunInspection(for: trackedDomain)
68 } label: {
69 Label("Open Inspection", systemImage: "magnifyingglass")
70 }
71
72 Button {
73 viewModel.togglePinned(for: trackedDomain)
74 } label: {
75 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
76 }
77
78 Button(role: .destructive) {
79 viewModel.deleteTrackedDomain(trackedDomain)
80 } label: {
81 Label("Delete", systemImage: "trash")
82 }
83 }
84 .listRowBackground(Color(.systemGray6).opacity(0.5))
85 }
86 .onDelete(perform: viewModel.deleteTrackedDomains)
87 } header: {
88 Text("Tracked Domains")
3489 }
3590 }
3691 }
@@ -38,14 +93,59 @@ struct WatchlistView: View {
3893 .background(Color.black)
3994 .navigationTitle("Watchlist")
4095 .toolbar {
41 if !viewModel.watchedDomains.isEmpty {
96 if !viewModel.sortedTrackedDomains.isEmpty {
4297 EditButton()
4398 }
4499 }
100 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
101 dismiss()
102 }
45103 .preferredColorScheme(.dark)
46104 }
105}
106
107struct WatchlistRowView: View {
108 let trackedDomain: TrackedDomain
109 let isRefreshing: Bool
110
111 var body: some View {
112 VStack(alignment: .leading, spacing: 6) {
113 HStack(alignment: .firstTextBaseline, spacing: 8) {
114 if trackedDomain.isPinned {
115 Image(systemName: "pin.fill")
116 .font(.caption2)
117 .foregroundStyle(.yellow)
118 }
119 Text(trackedDomain.domain)
120 .font(.system(.callout, design: .monospaced))
121 .foregroundStyle(.primary)
122 .lineLimit(2)
123 .multilineTextAlignment(.leading)
124 Spacer(minLength: 8)
125 statusBadge
126 }
127
128 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
129 .font(.system(.caption2, design: .monospaced))
130 .foregroundStyle(.secondary)
131
132 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
133 Text(note)
134 .font(.system(.caption, design: .monospaced))
135 .foregroundStyle(.secondary)
136 .lineLimit(2)
137 } else if let summary = trackedDomain.lastChangeSummary {
138 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
139 .font(.system(.caption, design: .monospaced))
140 .foregroundStyle(.secondary)
141 .lineLimit(2)
142 }
143 }
144 .frame(maxWidth: .infinity, alignment: .leading)
145 .padding(.vertical, 4)
146 }
47147
48 private func statusLabel(_ status: DomainAvailabilityStatus?) -> String {
148 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
49149 switch status {
50150 case .available:
51151 return "Available"
@@ -56,8 +156,33 @@ struct WatchlistView: View {
56156 }
57157 }
58158
59 private func statusColor(_ status: DomainAvailabilityStatus?) -> Color {
60 switch status {
159 @ViewBuilder
160 private var statusBadge: some View {
161 if isRefreshing {
162 HStack(spacing: 6) {
163 ProgressView()
164 .controlSize(.small)
165 Text("Refreshing")
166 .font(.system(.caption2, design: .monospaced))
167 .foregroundStyle(.secondary)
168 }
169 .padding(.horizontal, 8)
170 .padding(.vertical, 4)
171 .background(Color(.systemGray5).opacity(0.6))
172 .clipShape(Capsule())
173 } else {
174 Text(availabilityLabel(trackedDomain.lastKnownAvailability))
175 .font(.system(.caption2, design: .monospaced))
176 .foregroundStyle(badgeColor)
177 .padding(.horizontal, 8)
178 .padding(.vertical, 4)
179 .background(badgeBackground)
180 .clipShape(Capsule())
181 }
182 }
183
184 private var badgeColor: Color {
185 switch trackedDomain.lastKnownAvailability {
61186 case .available:
62187 return .green
63188 case .registered:
@@ -66,4 +191,145 @@ struct WatchlistView: View {
66191 return .secondary
67192 }
68193 }
194
195 private var badgeBackground: Color {
196 switch trackedDomain.lastKnownAvailability {
197 case .available:
198 return .green.opacity(0.16)
199 case .registered:
200 return .yellow.opacity(0.16)
201 case .unknown, .none:
202 return Color(.systemGray5).opacity(0.6)
203 }
204 }
205}
206
207struct TrackedDomainDetailView: View {
208 @Bindable var viewModel: DomainViewModel
209 let trackedDomain: TrackedDomain
210 @Environment(\.dismiss) private var dismiss
211
212 @State private var noteDraft = ""
213 @State private var isEditingNote = false
214
215 private var liveTrackedDomain: TrackedDomain {
216 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
217 }
218
219 private var latestSnapshots: [HistoryEntry] {
220 viewModel.recentSnapshots(for: liveTrackedDomain)
221 }
222
223 private var latestDiffSections: [DomainDiffSection] {
224 viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
225 }
226
227 var body: some View {
228 List {
229 Section {
230 WatchlistRowView(
231 trackedDomain: liveTrackedDomain,
232 isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
233 )
234 }
235 .listRowBackground(Color(.systemGray6).opacity(0.5))
236
237 Section {
238 Button {
239 viewModel.refreshTrackedDomain(liveTrackedDomain)
240 } label: {
241 Label("Manual Refresh", systemImage: "arrow.clockwise")
242 }
243
244 Button {
245 viewModel.rerunInspection(for: liveTrackedDomain)
246 } label: {
247 Label("Re-run Inspection", systemImage: "magnifyingglass")
248 }
249
250 Button {
251 viewModel.togglePinned(for: liveTrackedDomain)
252 } label: {
253 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
254 }
255
256 Button {
257 noteDraft = liveTrackedDomain.note ?? ""
258 isEditingNote = true
259 } label: {
260 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
261 }
262 }
263 .listRowBackground(Color(.systemGray6).opacity(0.5))
264
265 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
266 Section("Latest Change Summary") {
267 DomainChangeSummaryView(summary: summary)
268 }
269 .listRowBackground(Color.clear)
270 }
271
272 if !latestDiffSections.isEmpty {
273 Section("Latest Diff") {
274 DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false)
275 }
276 .listRowBackground(Color.clear)
277 }
278
279 Section("Recent Snapshots") {
280 if latestSnapshots.isEmpty {
281 Text("No snapshots yet")
282 .font(.system(.caption, design: .monospaced))
283 .foregroundStyle(.secondary)
284 } else {
285 ForEach(latestSnapshots) { entry in
286 NavigationLink {
287 HistoryDetailView(viewModel: viewModel, entry: entry)
288 } label: {
289 VStack(alignment: .leading, spacing: 4) {
290 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
291 .font(.system(.caption, design: .monospaced))
292 .foregroundStyle(.primary)
293 Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
294 .font(.system(.caption2, design: .monospaced))
295 .foregroundStyle(.secondary)
296 }
297 }
298 }
299 }
300 }
301 .listRowBackground(Color(.systemGray6).opacity(0.5))
302 }
303 .scrollContentBackground(.hidden)
304 .background(Color.black)
305 .navigationTitle(liveTrackedDomain.domain)
306 .preferredColorScheme(.dark)
307 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
308 dismiss()
309 }
310 .sheet(isPresented: $isEditingNote) {
311 NavigationStack {
312 Form {
313 Section("Tracking Note") {
314 TextField("Optional note", text: $noteDraft, axis: .vertical)
315 .lineLimit(3...6)
316 }
317 }
318 .navigationTitle("Edit Note")
319 .toolbar {
320 ToolbarItem(placement: .cancellationAction) {
321 Button("Cancel") {
322 isEditingNote = false
323 }
324 }
325 ToolbarItem(placement: .confirmationAction) {
326 Button("Save") {
327 viewModel.updateNote(noteDraft, for: liveTrackedDomain)
328 isEditingNote = false
329 }
330 }
331 }
332 }
333 }
334 }
69335}