Commit 1f58092dcb

1f58092dcb67cded75a850db364f3b46d86181f3

parent: 2e90ee4cec

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 02:53 UTC

feat(v2.2.0): add foreground monitoring, notifications, and smarter diffs
- implement “Check All” sweep for tracked domains
- add local notifications for changes and certificate expiration
- introduce change severity filtering (low/medium/high)
- enhance change summaries and diff readability
- add certificate expiration tracking and alerts
- improve watchlist indicators for changes
- add sweep summary screen
- optimize performance for larger watchlists

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +6 −6
@@ -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 = 14;
270 CURRENT_PROJECT_VERSION = 15;
271271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272272 ENABLE_PREVIEWS = YES;
273273 GENERATE_INFOPLIST_FILE = YES;
@@ -279,12 +279,12 @@
279279 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
280280 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
281281 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
282 IPHONEOS_DEPLOYMENT_TARGET = 26.0;
282 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
283283 LD_RUNPATH_SEARCH_PATHS = (
284284 "$(inherited)",
285285 "@executable_path/Frameworks",
286286 );
287 MARKETING_VERSION = 2.1.0;
287 MARKETING_VERSION = 2.2.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 = 14;
306 CURRENT_PROJECT_VERSION = 15;
307307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308308 ENABLE_PREVIEWS = YES;
309309 GENERATE_INFOPLIST_FILE = YES;
@@ -315,12 +315,12 @@
315315 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
316316 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
317317 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
318 IPHONEOS_DEPLOYMENT_TARGET = 26.0;
318 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
319319 LD_RUNPATH_SEARCH_PATHS = (
320320 "$(inherited)",
321321 "@executable_path/Frameworks",
322322 );
323 MARKETING_VERSION = 2.1.0;
323 MARKETING_VERSION = 2.2.0;
324324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325325 PRODUCT_NAME = "$(TARGET_NAME)";
326326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift +10 −1
@@ -75,6 +75,12 @@ struct BatchResultRowView: View {
7575 .font(.system(.caption2, design: .monospaced))
7676 .foregroundStyle(.secondary)
7777
78 if let summaryMessage = result.summaryMessage {
79 Text(summaryMessage)
80 .font(.system(.caption2, design: .monospaced))
81 .foregroundStyle(.secondary)
82 }
83
7884 if let errorMessage = result.errorMessage {
7985 Text(errorMessage)
8086 .font(.system(.caption2, design: .monospaced))
@@ -103,7 +109,10 @@ struct BatchResultRowView: View {
103109 case .running:
104110 return .cyan
105111 case .completed:
106 if result.quickStatus == "Changed" {
112 if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
113 return .red
114 }
115 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
107116 return .yellow
108117 }
109118 return .green
DomainDig/BatchSweepSummaryView.swift added +68
@@ -0,0 +1,68 @@
1import SwiftUI
2
3struct BatchSweepSummaryView: View {
4 @Bindable var viewModel: DomainViewModel
5 let summary: BatchSweepSummary
6
7 @State private var showUnchangedDomains = false
8
9 private var visibleResults: [BatchLookupResult] {
10 if showUnchangedDomains {
11 return summary.results
12 }
13
14 return summary.results.filter {
15 ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
16 }
17 }
18
19 var body: some View {
20 NavigationStack {
21 List {
22 Section("Overview") {
23 statRow(label: "Checked", value: "\(summary.totalDomains)")
24 statRow(label: "Changed", value: "\(summary.changedDomains)")
25 statRow(label: "Warnings", value: "\(summary.warningDomains)")
26 statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
27 }
28
29 Section {
30 Toggle("Show unchanged domains", isOn: $showUnchangedDomains)
31 }
32
33 Section(visibleResults.isEmpty ? "Changed Domains" : "Results") {
34 if visibleResults.isEmpty {
35 Text("No domains with changes or warnings")
36 .font(.system(.caption, design: .monospaced))
37 .foregroundStyle(.secondary)
38 } else {
39 ForEach(visibleResults) { result in
40 if let entry = viewModel.historyEntry(for: result) {
41 NavigationLink {
42 HistoryDetailView(viewModel: viewModel, entry: entry)
43 } label: {
44 BatchResultRowView(result: result)
45 }
46 } else {
47 BatchResultRowView(result: result)
48 }
49 }
50 }
51 }
52 }
53 .navigationTitle(summary.source == .watchlistRefresh ? "Sweep Summary" : "Batch Summary")
54 }
55 }
56
57 private func statRow(label: String, value: String) -> some View {
58 HStack {
59 Text(label)
60 .font(.system(.caption, design: .monospaced))
61 .foregroundStyle(.secondary)
62 Spacer()
63 Text(value)
64 .font(.system(.callout, design: .monospaced))
65 .foregroundStyle(.primary)
66 }
67 }
68}
DomainDig/ContentView.swift +86 −29
@@ -308,6 +308,13 @@ struct ContentView: View {
308308 VStack(alignment: .leading, spacing: 12) {
309309 HStack {
310310 Spacer()
311 if viewModel.batchLookupRunning {
312 Button("Cancel") {
313 viewModel.cancelBatchLookup()
314 }
315 .buttonStyle(.bordered)
316 .font(.system(.caption, design: .monospaced))
317 }
311318 if !viewModel.currentBatchResultEntries.isEmpty {
312319 Menu {
313320 Button("Export Batch TXT") {
@@ -455,45 +462,82 @@ struct DomainChangeSummaryView: View {
455462 var body: some View {
456463 CardView(allowsHorizontalScroll: false) {
457464 HStack {
458 Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
465 Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
459466 .font(.system(.caption, design: .monospaced))
460 .foregroundStyle(summary.hasChanges ? .yellow : .green)
467 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
461468 Spacer()
469 Text(summary.severity.title.uppercased())
470 .font(.system(.caption2, design: .monospaced))
471 .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
472 .padding(.horizontal, 8)
473 .padding(.vertical, 4)
474 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
475 .clipShape(Capsule())
462476 Text(summary.generatedAt, style: .time)
463477 .font(.system(.caption2, design: .monospaced))
464478 .foregroundStyle(.secondary)
465479 }
466480
467 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
481 Text(summary.message)
468482 .font(.system(.caption, design: .monospaced))
469483 .foregroundStyle(.primary)
470484 }
471485 }
486
487 private func severityColor(_ severity: ChangeSeverity) -> Color {
488 switch severity {
489 case .low:
490 return .secondary
491 case .medium:
492 return .yellow
493 case .high:
494 return .red
495 }
496 }
472497}
473498
474499struct DomainDiffView: View {
475500 let title: String
476501 let sections: [DomainDiffSection]
477502 let showsUnchanged: Bool
503
478504 @State private var collapsedSections = Set<UUID>()
505 @State private var showsLowSeverity = false
479506
480507 private var filteredSections: [DomainDiffSection] {
481 guard showsUnchanged else {
482 return sections
483 .map { section in
484 DomainDiffSection(
485 title: section.title,
486 items: section.items.filter(\.hasChanges)
487 )
508 sections
509 .map { section in
510 let items = section.items.filter { item in
511 if !showsUnchanged, !item.hasChanges {
512 return false
513 }
514 if showsLowSeverity {
515 return true
516 }
517 return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
488518 }
489 .filter { !$0.items.isEmpty }
490 }
491 return sections
519 return DomainDiffSection(title: section.title, items: items)
520 }
521 .filter { !$0.items.isEmpty }
522 }
523
524 private var hasLowSeverityChanges: Bool {
525 sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
492526 }
493527
494528 var body: some View {
495529 VStack(alignment: .leading, spacing: 12) {
496 SectionTitleView(title: title)
530 HStack {
531 SectionTitleView(title: title)
532 Spacer()
533 if hasLowSeverityChanges {
534 Button(showsLowSeverity ? "Hide Low" : "Show Low") {
535 showsLowSeverity.toggle()
536 }
537 .buttonStyle(.bordered)
538 .font(.system(.caption, design: .monospaced))
539 }
540 }
497541 if filteredSections.isEmpty {
498542 MessageCardView(text: "No comparison data available", isError: false)
499543 } else {
@@ -509,12 +553,12 @@ struct DomainDiffView: View {
509553 .font(.system(.caption, design: .monospaced))
510554 .foregroundStyle(.secondary)
511555 Spacer()
512 Text(changeLabel(for: item.changeType))
556 Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
513557 .font(.system(.caption2, design: .monospaced))
514 .foregroundStyle(changeColor(for: item.changeType))
558 .foregroundStyle(changeColor(for: item))
515559 .padding(.horizontal, 8)
516560 .padding(.vertical, 4)
517 .background(changeColor(for: item.changeType).opacity(0.16))
561 .background(changeColor(for: item).opacity(0.16))
518562 .clipShape(Capsule())
519563 }
520564
@@ -543,7 +587,7 @@ struct DomainDiffView: View {
543587 }
544588 }
545589 .padding(10)
546 .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25))
590 .background(item.hasChanges ? changeColor(for: item).opacity(0.08) : Color(.systemGray6).opacity(0.25))
547591 .cornerRadius(8)
548592 }
549593 } label: {
@@ -551,11 +595,11 @@ struct DomainDiffView: View {
551595 Text(section.title)
552596 .font(.system(.subheadline, design: .monospaced))
553597 .fontWeight(.semibold)
554 .foregroundStyle(.cyan)
598 .foregroundStyle(sectionColor(section))
555599 Spacer()
556 Text(section.hasChanges ? "Changed" : "Unchanged")
600 Text(section.severity.title)
557601 .font(.system(.caption2, design: .monospaced))
558 .foregroundStyle(section.hasChanges ? .yellow : .secondary)
602 .foregroundStyle(sectionColor(section))
559603 }
560604 }
561605 }
@@ -577,16 +621,29 @@ struct DomainDiffView: View {
577621 }
578622 }
579623
580 private func changeColor(for changeType: DiffChangeType) -> Color {
581 switch changeType {
582 case .added:
583 return .green
584 case .removed:
624 private func changeColor(for item: DomainDiffItem) -> Color {
625 if item.changeType == .unchanged {
626 return .secondary
627 }
628
629 switch item.severity {
630 case .low:
631 return .blue
632 case .medium:
633 return .yellow
634 case .high:
585635 return .red
586 case .changed:
636 }
637 }
638
639 private func sectionColor(_ section: DomainDiffSection) -> Color {
640 switch section.severity {
641 case .low:
642 return .blue
643 case .medium:
587644 return .yellow
588 case .unchanged:
589 return .secondary
645 case .high:
646 return .red
590647 }
591648 }
592649
DomainDig/DomainDiffService.swift +276 −61
@@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable {
1313 let changeType: DiffChangeType
1414 let oldValue: String?
1515 let newValue: String?
16 let severity: ChangeSeverity
1617
1718 var hasChanges: Bool {
1819 changeType != .unchanged
1920 }
21
22 var isMeaningful: Bool {
23 hasChanges && severity >= .medium
24 }
2025}
2126
2227struct DomainDiffSection: Identifiable, Equatable {
@@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable {
2732 var hasChanges: Bool {
2833 items.contains(where: \.hasChanges)
2934 }
35
36 var severity: ChangeSeverity {
37 items.map(\.severity).max() ?? .low
38 }
3039}
3140
3241enum DomainDiffService {
3342 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
3443 [
35 section(title: "Availability", item: compare(
36 label: "Status",
37 oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
38 newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
39 )),
40 section(title: "Primary IP", item: compare(
41 label: "Address",
42 oldValue: primaryIP(from: oldSnapshot),
43 newValue: primaryIP(from: newSnapshot)
44 )),
44 availabilitySection(from: oldSnapshot, to: newSnapshot),
45 primaryIPSection(from: oldSnapshot, to: newSnapshot),
4546 dnsSection(from: oldSnapshot, to: newSnapshot),
46 section(title: "Redirect", item: compare(
47 label: "Final Target",
48 oldValue: finalRedirectURL(from: oldSnapshot),
49 newValue: finalRedirectURL(from: newSnapshot)
50 )),
47 redirectSection(from: oldSnapshot, to: newSnapshot),
5148 tlsSection(from: oldSnapshot, to: newSnapshot),
5249 httpSection(from: oldSnapshot, to: newSnapshot),
5350 emailSection(from: oldSnapshot, to: newSnapshot)
@@ -55,62 +52,212 @@ enum DomainDiffService {
5552 .filter { !$0.items.isEmpty }
5653 }
5754
58 static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
59 let changedSections = diff(from: oldSnapshot, to: newSnapshot)
60 .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
61 .map(\.title)
55 static func summary(
56 from oldSnapshot: LookupSnapshot,
57 to newSnapshot: LookupSnapshot,
58 generatedAt: Date = Date()
59 ) -> DomainChangeSummary {
60 let sections = diff(from: oldSnapshot, to: newSnapshot)
61 let meaningfulItems = sections
62 .flatMap(\.items)
63 .filter(\.isMeaningful)
64 let allChangedItems = sections
65 .flatMap(\.items)
66 .filter(\.hasChanges)
67
68 let highlights = summaryHighlights(from: meaningfulItems)
69 let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
70 let message = summaryMessage(from: meaningfulItems, highlights: highlights)
6271
6372 return DomainChangeSummary(
64 hasChanges: !changedSections.isEmpty,
65 changedSections: changedSections,
73 hasChanges: !meaningfulItems.isEmpty,
74 changedSections: highlights,
75 message: message,
76 severity: severity,
6677 generatedAt: generatedAt
6778 )
6879 }
6980
70 private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
71 DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
81 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
82 guard let days = snapshot.sslInfo?.daysUntilExpiry else {
83 return .none
84 }
85 if days < 14 {
86 return .critical
87 }
88 if days < 30 {
89 return .warning
90 }
91 return .none
92 }
93
94 private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
95 DomainDiffSection(
96 title: "Availability",
97 items: [
98 compare(
99 label: "Availability",
100 oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
101 newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
102 severity: .high
103 )
104 ].compactMap { $0 }
105 )
106 }
107
108 private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
109 DomainDiffSection(
110 title: "Primary IP",
111 items: [
112 compare(
113 label: "Primary IP",
114 oldValue: primaryIP(from: oldSnapshot),
115 newValue: primaryIP(from: newSnapshot),
116 severity: .high
117 )
118 ].compactMap { $0 }
119 )
72120 }
73121
74122 private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
75 let oldValue = normalizedDNSSummary(from: oldSnapshot)
76 let newValue = normalizedDNSSummary(from: newSnapshot)
77 return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
123 let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
124 let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
125 let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
126
127 var items: [DomainDiffItem] = []
128 for type in types {
129 let oldSection = oldSections[type]
130 let newSection = newSections[type]
131
132 if let recordChange = compare(
133 label: "\(type.rawValue) Records",
134 oldValue: normalizedRecordValues(for: oldSection),
135 newValue: normalizedRecordValues(for: newSection),
136 severity: .medium
137 ) {
138 items.append(recordChange)
139 }
140
141 if let ttlChange = compare(
142 label: "\(type.rawValue) TTL",
143 oldValue: normalizedTTLValues(for: oldSection),
144 newValue: normalizedTTLValues(for: newSection),
145 severity: .low
146 ), let oldSection, let newSection,
147 normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
148 items.append(ttlChange)
149 }
150 }
151
152 return DomainDiffSection(title: "DNS", items: items)
153 }
154
155 private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
156 DomainDiffSection(
157 title: "Redirect",
158 items: [
159 compare(
160 label: "Redirect Target",
161 oldValue: finalRedirectURL(from: oldSnapshot),
162 newValue: finalRedirectURL(from: newSnapshot),
163 severity: .high
164 )
165 ].compactMap { $0 }
166 )
78167 }
79168
80169 private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
81170 var items: [DomainDiffItem] = []
82 if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
83 items.append(item)
171
172 if let issuerChange = compare(
173 label: "TLS Issuer",
174 oldValue: normalized(oldSnapshot.sslInfo?.issuer),
175 newValue: normalized(newSnapshot.sslInfo?.issuer),
176 severity: .medium
177 ) {
178 items.append(issuerChange)
179 }
180
181 if let expiryChange = compare(
182 label: "TLS Expiration",
183 oldValue: expirationLabel(oldSnapshot.sslInfo),
184 newValue: expirationLabel(newSnapshot.sslInfo),
185 severity: .medium
186 ) {
187 items.append(expiryChange)
84188 }
85 if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
86 items.append(item)
189
190 let oldWarning = certificateWarningLevel(for: oldSnapshot)
191 let newWarning = certificateWarningLevel(for: newSnapshot)
192 if oldWarning != newWarning, newWarning != .none {
193 let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
194 items.append(
195 DomainDiffItem(
196 label: "Certificate Warning",
197 changeType: .changed,
198 oldValue: oldWarning.title,
199 newValue: "Certificate expires in \(days) days",
200 severity: newWarning == .critical ? .high : .medium
201 )
202 )
87203 }
88 return DomainDiffSection(title: "TLS Certificate", items: items)
204
205 return DomainDiffSection(title: "TLS", items: items)
89206 }
90207
91208 private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
92209 var items: [DomainDiffItem] = []
93 if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
94 items.append(item)
210
211 if let statusChange = compare(
212 label: "HTTP Status",
213 oldValue: httpStatusSummary(from: oldSnapshot),
214 newValue: httpStatusSummary(from: newSnapshot),
215 severity: .medium
216 ) {
217 items.append(statusChange)
95218 }
96 if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
97 items.append(item)
219
220 if let gradeChange = compare(
221 label: "Security Grade",
222 oldValue: normalized(oldSnapshot.httpSecurityGrade),
223 newValue: normalized(newSnapshot.httpSecurityGrade),
224 severity: .low
225 ) {
226 items.append(gradeChange)
227 }
228
229 if let headerChange = compare(
230 label: "Headers",
231 oldValue: normalizedHeaders(from: oldSnapshot),
232 newValue: normalizedHeaders(from: newSnapshot),
233 severity: .low
234 ) {
235 items.append(headerChange)
98236 }
237
99238 return DomainDiffSection(title: "HTTP", items: items)
100239 }
101240
102241 private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
103 section(
242 DomainDiffSection(
104243 title: "Email Security",
105 item: compare(
106 label: "Summary",
107 oldValue: normalized(emailSummary(from: oldSnapshot)),
108 newValue: normalized(emailSummary(from: newSnapshot))
109 )
244 items: [
245 compare(
246 label: "Email Security",
247 oldValue: normalized(emailSummary(from: oldSnapshot)),
248 newValue: normalized(emailSummary(from: newSnapshot)),
249 severity: .medium
250 )
251 ].compactMap { $0 }
110252 )
111253 }
112254
113 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
255 private static func compare(
256 label: String,
257 oldValue: String?,
258 newValue: String?,
259 severity: ChangeSeverity
260 ) -> DomainDiffItem? {
114261 let oldValue = normalized(oldValue)
115262 let newValue = normalized(newValue)
116263 let normalizedOldValue = comparisonValue(oldValue)
@@ -132,7 +279,68 @@ enum DomainDiffService {
132279 changeType = .changed
133280 }
134281
135 return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
282 return DomainDiffItem(
283 label: label,
284 changeType: changeType,
285 oldValue: oldValue,
286 newValue: newValue,
287 severity: severity
288 )
289 }
290
291 private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
292 var highlights: [String] = []
293
294 let labels = Set(items.map(\.label))
295 if labels.contains("Availability") {
296 highlights.append("Availability changed")
297 }
298 if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
299 highlights.append("IP changed")
300 highlights.append("DNS changed")
301 return highlights
302 }
303 if labels.contains("Primary IP") {
304 highlights.append("IP changed")
305 }
306 if labels.contains("Redirect Target") {
307 highlights.append("Redirect target changed")
308 }
309 if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
310 let message = certificateItem.newValue {
311 highlights.append(message)
312 } else if labels.contains("TLS Issuer") {
313 highlights.append("TLS issuer changed")
314 } else if labels.contains("TLS Expiration") {
315 highlights.append("Certificate expiration changed")
316 }
317 if labels.contains(where: { $0.hasSuffix("Records") }) {
318 highlights.append("DNS changed")
319 }
320 if labels.contains("HTTP Status") {
321 highlights.append("HTTP status changed")
322 }
323 if labels.contains("Email Security") {
324 highlights.append("Email security changed")
325 }
326
327 var deduplicated: [String] = []
328 for highlight in highlights where !deduplicated.contains(highlight) {
329 deduplicated.append(highlight)
330 }
331 return deduplicated
332 }
333
334 private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
335 guard !items.isEmpty, !highlights.isEmpty else {
336 return "No meaningful changes"
337 }
338
339 if highlights.count == 1 {
340 return highlights[0]
341 }
342
343 return "\(highlights[0]) and \(highlights[1].lowercased())"
136344 }
137345
138346 private static func normalized(_ value: String?) -> String? {
@@ -167,11 +375,9 @@ enum DomainDiffService {
167375 snapshot.redirectChain.last?.url
168376 }
169377
170 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
171 if let sslInfo = snapshot.sslInfo {
172 return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
173 }
174 return snapshot.sslError
378 private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
379 guard let sslInfo else { return nil }
380 return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
175381 }
176382
177383 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
@@ -194,18 +400,27 @@ enum DomainDiffService {
194400 return snapshot.emailSecurityError
195401 }
196402
197 private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
198 let parts = snapshot.dnsSections
199 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
200 .map { section in
201 let values = (section.records + section.wildcardRecords)
202 .map(\.value)
203 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
204 .sorted()
205 .joined(separator: ",")
206 return "\(section.recordType.rawValue):\(values)"
207 }
208 .filter { !$0.hasSuffix(":") }
209 return parts.isEmpty ? nil : parts.joined(separator: "|")
403 private static func normalizedRecordValues(for section: DNSSection?) -> String? {
404 guard let section else { return nil }
405 let values = (section.records + section.wildcardRecords)
406 .map(\.value)
407 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
408 .sorted()
409 return values.isEmpty ? nil : values.joined(separator: ",")
410 }
411
412 private static func normalizedTTLValues(for section: DNSSection?) -> String? {
413 guard let section else { return nil }
414 let values = (section.records + section.wildcardRecords)
415 .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
416 .sorted()
417 return values.isEmpty ? nil : values.joined(separator: ",")
418 }
419
420 private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
421 let headers = snapshot.httpHeaders
422 .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
423 .sorted()
424 return headers.isEmpty ? nil : headers.joined(separator: "|")
210425 }
211426}
DomainDig/DomainDigApp.swift +4
@@ -9,6 +9,10 @@ import SwiftUI
99
1010@main
1111struct DomainDigApp: App {
12 init() {
13 LocalNotificationService.shared.configureForegroundPresentation()
14 }
15
1216 var body: some Scene {
1317 WindowGroup {
1418 ContentView()
DomainDig/DomainViewModel.swift +538 −120
@@ -159,6 +159,10 @@ extension HistoryEntry {
159159 }
160160}
161161
162private struct BatchLookupPayload {
163 let snapshot: LookupSnapshot
164}
165
162166@MainActor
163167@Observable
164168final class DomainViewModel {
@@ -228,11 +232,16 @@ final class DomainViewModel {
228232 private(set) var batchCompletedCount = 0
229233 private(set) var batchTotalCount = 0
230234 private(set) var batchLookupRunning = false
235 var latestBatchSweepSummary: BatchSweepSummary?
236 private(set) var notificationsAuthorized = false
231237
232238 private var lookupTask: Task<Void, Never>?
233239 private var customPortScanTask: Task<Void, Never>?
240 private var batchTask: Task<Void, Never>?
234241 private var activeLookupID = UUID()
235242 private var lookupStartedAt: Date?
243 private var activeBatchDomains: [String] = []
244 private var lastBatchStartedAt: Date?
236245
237246 private static let recentSearchesKey = "recentSearches"
238247 private static let maxRecent = 20
@@ -355,8 +364,8 @@ final class DomainViewModel {
355364
356365 var batchProgressLabel: String {
357366 guard batchTotalCount > 0 else { return "No active batch" }
358 let domainLabel = batchCurrentDomain ?? "Preparing"
359 return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)"
367 let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
368 return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
360369 }
361370
362371 var currentBatchResultEntries: [HistoryEntry] {
@@ -550,6 +559,10 @@ final class DomainViewModel {
550559 func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
551560 refreshingTrackedDomainID = trackedDomain.id
552561 domain = trackedDomain.domain
562 Task { [weak self] in
563 guard let self else { return }
564 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
565 }
553566 run()
554567 }
555568
@@ -624,6 +637,7 @@ final class DomainViewModel {
624637 func reset() {
625638 lookupTask?.cancel()
626639 customPortScanTask?.cancel()
640 batchTask?.cancel()
627641 hasRun = false
628642 searchedDomain = ""
629643 lastLookupDurationMs = nil
@@ -649,59 +663,36 @@ final class DomainViewModel {
649663 func runBulkLookup() {
650664 let domains = parsedDomains(from: bulkInput)
651665 guard !domains.isEmpty else { return }
652
653 clearBatchState()
654 batchLookupSource = .manual
655 batchTotalCount = domains.count
656 batchLookupRunning = true
657 batchResults = domains.map {
658 BatchLookupResult(
659 domain: $0,
660 historyEntryID: nil,
661 availability: nil,
662 primaryIP: nil,
663 quickStatus: "Pending",
664 timestamp: Date(),
665 status: .pending
666 )
667 }
668
669 lookupTask?.cancel()
670 customPortScanTask?.cancel()
671
672 lookupTask = Task { [weak self] in
673 guard let self else { return }
674 await self.runBatchLookup(domains: domains, source: .manual)
675 }
666 startBatchLookup(domains: domains, source: .manual)
676667 }
677668
678669 func refreshAllTrackedDomains() {
679 let domains = sortedTrackedDomains.map(\.domain)
680 guard !domains.isEmpty else { return }
670 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
671 }
681672
682 clearBatchState()
683 batchLookupSource = .watchlistRefresh
684 batchTotalCount = domains.count
685 batchLookupRunning = true
686 batchResults = domains.map {
687 BatchLookupResult(
688 domain: $0,
689 historyEntryID: nil,
690 availability: nil,
691 primaryIP: nil,
692 quickStatus: "Pending",
673 func cancelBatchLookup() {
674 batchTask?.cancel()
675 batchLookupRunning = false
676 batchCurrentDomain = nil
677 activeBatchDomains = []
678 refreshingTrackedDomainID = nil
679
680 for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running {
681 batchResults[index] = BatchLookupResult(
682 id: batchResults[index].id,
683 domain: batchResults[index].domain,
684 historyEntryID: batchResults[index].historyEntryID,
685 availability: batchResults[index].availability,
686 primaryIP: batchResults[index].primaryIP,
687 quickStatus: "Cancelled",
688 summaryMessage: batchResults[index].summaryMessage,
689 changeSeverity: batchResults[index].changeSeverity,
690 certificateWarningLevel: batchResults[index].certificateWarningLevel,
693691 timestamp: Date(),
694 status: .pending
692 status: .failed,
693 errorMessage: "Lookup cancelled"
695694 )
696695 }
697
698 lookupTask?.cancel()
699 customPortScanTask?.cancel()
700
701 lookupTask = Task { [weak self] in
702 guard let self else { return }
703 await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
704 }
705696 }
706697
707698 func runCustomPortScan(ports: [UInt16]) async {
@@ -1057,7 +1048,215 @@ final class DomainViewModel {
10571048 customPortScanLoading = false
10581049 }
10591050
1060 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
1051 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
1052 guard !Task.isCancelled else { return nil }
1053
1054 let startedAt = Date()
1055 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
1056 let resolverURLString = DNSLookupService.currentResolverURLString()
1057
1058 async let dnsResult = DNSLookupService.lookupAll(domain: domain)
1059 async let availabilityResult = DomainAvailabilityService.check(domain: domain)
1060 async let sslResult = SSLCheckService.check(domain: domain)
1061 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
1062 async let httpResult = HTTPHeadersService.fetch(domain: domain)
1063 async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
1064 async let redirectResult = RedirectChainService.trace(domain: domain)
1065 async let portScanResult = PortScanService.scanAll(domain: domain)
1066
1067 let resolvedDNS = await dnsResult
1068 let availability = await availabilityResult
1069 let resolvedSSL = await sslResult
1070 let hsts = await hstsResult
1071 let http = await httpResult
1072 let reachability = await reachabilityResult
1073 let redirects = await redirectResult
1074 let ports = await portScanResult
1075
1076 guard !Task.isCancelled else { return nil }
1077
1078 let dnsSections: [DNSSection]
1079 let dnsError: String?
1080 switch resolvedDNS {
1081 case let .success(sections):
1082 dnsSections = sections
1083 dnsError = nil
1084 case let .empty(message), let .error(message):
1085 dnsSections = []
1086 dnsError = message
1087 }
1088
1089 let sslInfo: SSLCertificateInfo?
1090 let sslError: String?
1091 switch resolvedSSL {
1092 case let .success(info):
1093 sslInfo = info
1094 sslError = nil
1095 case let .empty(message), let .error(message):
1096 sslInfo = nil
1097 sslError = message
1098 }
1099
1100 let httpHeaders: [HTTPHeader]
1101 let httpSecurityGrade: String?
1102 let httpStatusCode: Int?
1103 let httpResponseTimeMs: Int?
1104 let httpProtocol: String?
1105 let http3Advertised: Bool
1106 let httpHeadersError: String?
1107 switch http {
1108 case let .success(result):
1109 httpHeaders = result.headers
1110 httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
1111 httpStatusCode = result.statusCode
1112 httpResponseTimeMs = result.responseTimeMs
1113 httpProtocol = result.httpProtocol
1114 http3Advertised = result.http3Advertised
1115 httpHeadersError = nil
1116 case let .empty(message), let .error(message):
1117 httpHeaders = []
1118 httpSecurityGrade = nil
1119 httpStatusCode = nil
1120 httpResponseTimeMs = nil
1121 httpProtocol = nil
1122 http3Advertised = false
1123 httpHeadersError = message
1124 }
1125
1126 let reachabilityResults: [PortReachability]
1127 let reachabilityError: String?
1128 switch reachability {
1129 case let .success(results):
1130 reachabilityResults = results
1131 reachabilityError = nil
1132 case let .empty(message), let .error(message):
1133 reachabilityResults = []
1134 reachabilityError = message
1135 }
1136
1137 let redirectChain: [RedirectHop]
1138 let redirectChainError: String?
1139 switch redirects {
1140 case let .success(hops):
1141 redirectChain = hops
1142 redirectChainError = nil
1143 case let .empty(message), let .error(message):
1144 redirectChain = []
1145 redirectChainError = message
1146 }
1147
1148 let portScanResults: [PortScanResult]
1149 let portScanError: String?
1150 switch ports {
1151 case let .success(results):
1152 portScanResults = await enrichOpenPortBanners(results, domain: domain)
1153 portScanError = nil
1154 case let .empty(message), let .error(message):
1155 portScanResults = []
1156 portScanError = message
1157 }
1158
1159 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
1160 let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1161
1162 async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
1163 async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
1164
1165 let resolvedEmail = await emailResult
1166 let resolvedSuggestions = await suggestions
1167 let resolvedPTR: ServiceResult<String>?
1168 let resolvedGeo: ServiceResult<IPGeolocation>?
1169 if let primaryIP {
1170 resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
1171 resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
1172 } else {
1173 resolvedPTR = nil
1174 resolvedGeo = nil
1175 }
1176
1177 guard !Task.isCancelled else { return nil }
1178
1179 let emailSecurity: EmailSecurityResult?
1180 let emailSecurityError: String?
1181 switch resolvedEmail {
1182 case let .success(result):
1183 emailSecurity = result
1184 emailSecurityError = nil
1185 case let .empty(message), let .error(message):
1186 emailSecurity = nil
1187 emailSecurityError = message
1188 }
1189
1190 let ptrRecord: String?
1191 let ptrError: String?
1192 switch resolvedPTR {
1193 case let .success(record):
1194 ptrRecord = record
1195 ptrError = nil
1196 case let .empty(message), let .error(message):
1197 ptrRecord = nil
1198 ptrError = message
1199 case .none:
1200 ptrRecord = nil
1201 ptrError = "No A record available"
1202 }
1203
1204 let ipGeolocation: IPGeolocation?
1205 let ipGeolocationError: String?
1206 switch resolvedGeo {
1207 case let .success(result):
1208 ipGeolocation = result
1209 ipGeolocationError = nil
1210 case let .empty(message), let .error(message):
1211 ipGeolocation = nil
1212 ipGeolocationError = message
1213 case .none:
1214 ipGeolocation = nil
1215 ipGeolocationError = "No A record available"
1216 }
1217
1218 let snapshot = LookupSnapshot(
1219 historyEntryID: nil,
1220 domain: domain,
1221 timestamp: Date(),
1222 trackedDomainID: nil,
1223 resolverDisplayName: resolverDisplayName,
1224 resolverURLString: resolverURLString,
1225 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
1226 dnsSections: dnsSections,
1227 dnsError: dnsError,
1228 availabilityResult: availability,
1229 suggestions: resolvedSuggestions,
1230 sslInfo: sslInfo,
1231 sslError: sslError,
1232 hstsPreloaded: hsts,
1233 httpHeaders: httpHeaders,
1234 httpSecurityGrade: httpSecurityGrade,
1235 httpStatusCode: httpStatusCode,
1236 httpResponseTimeMs: httpResponseTimeMs,
1237 httpProtocol: httpProtocol,
1238 http3Advertised: http3Advertised,
1239 httpHeadersError: httpHeadersError,
1240 reachabilityResults: reachabilityResults,
1241 reachabilityError: reachabilityError,
1242 ipGeolocation: ipGeolocation,
1243 ipGeolocationError: ipGeolocationError,
1244 emailSecurity: emailSecurity,
1245 emailSecurityError: emailSecurityError,
1246 ptrRecord: ptrRecord,
1247 ptrError: ptrError,
1248 redirectChain: redirectChain,
1249 redirectChainError: redirectChainError,
1250 portScanResults: portScanResults,
1251 portScanError: portScanError,
1252 changeSummary: nil,
1253 isLive: false
1254 )
1255
1256 return BatchLookupPayload(snapshot: snapshot)
1257 }
1258
1259 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
10611260 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
10621261 for result in results where result.open {
10631262 group.addTask {
@@ -1082,56 +1281,67 @@ final class DomainViewModel {
10821281 }
10831282 }
10841283
1284 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
1285 await Self.enrichOpenPortBanners(results, domain: domain)
1286 }
1287
10851288 @discardableResult
10861289 private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
10871290 guard !searchedDomain.isEmpty else { return nil }
1291 return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
1292 }
10881293
1089 let trackedDomainID = trackedDomain(for: searchedDomain)?.id
1090 let timestamp = Date()
1091 let snapshot = currentSnapshot
1092 let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1093 let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
1294 @discardableResult
1295 private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
1296 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
1297 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1298 let changeSummary = previousSnapshot.map {
1299 DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
1300 }
1301 let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
10941302
1095 currentChangeSummary = changeSummary
1096 currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
1303 if updateCurrentState {
1304 currentChangeSummary = changeSummary
1305 currentDiffSections = diffSections
1306 }
10971307
10981308 let entry = HistoryEntry(
1099 domain: searchedDomain,
1100 timestamp: timestamp,
1309 domain: snapshot.domain,
1310 timestamp: snapshot.timestamp,
11011311 trackedDomainID: trackedDomainID,
1102 dnsSections: dnsSections,
1103 sslInfo: sslInfo,
1104 httpHeaders: httpHeaders,
1105 reachabilityResults: reachabilityResults,
1106 ipGeolocation: ipGeolocation,
1107 emailSecurity: emailSecurity,
1108 mtaSts: emailSecurity?.mtaSts,
1109 ptrRecord: ptrRecord,
1110 redirectChain: redirectChain,
1111 portScanResults: allPortScanResults,
1112 hstsPreloaded: hstsPreloaded,
1113 availabilityResult: availabilityResult,
1114 suggestions: suggestions,
1115 resolverDisplayName: resolverDisplayName,
1116 resolverURLString: resolverURLString,
1117 totalLookupDurationMs: lastLookupDurationMs,
1312 dnsSections: snapshot.dnsSections,
1313 sslInfo: snapshot.sslInfo,
1314 httpHeaders: snapshot.httpHeaders,
1315 reachabilityResults: snapshot.reachabilityResults,
1316 ipGeolocation: snapshot.ipGeolocation,
1317 emailSecurity: snapshot.emailSecurity,
1318 mtaSts: snapshot.emailSecurity?.mtaSts,
1319 ptrRecord: snapshot.ptrRecord,
1320 redirectChain: snapshot.redirectChain,
1321 portScanResults: snapshot.portScanResults,
1322 hstsPreloaded: snapshot.hstsPreloaded,
1323 availabilityResult: snapshot.availabilityResult,
1324 suggestions: snapshot.suggestions,
1325 resolverDisplayName: snapshot.resolverDisplayName,
1326 resolverURLString: snapshot.resolverURLString,
1327 totalLookupDurationMs: snapshot.totalLookupDurationMs,
11181328 primaryIP: Self.primaryIPAddress(from: snapshot),
11191329 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
11201330 tlsStatusSummary: Self.httpsSummary(from: snapshot),
11211331 emailSecuritySummary: Self.emailSummary(from: snapshot),
11221332 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
11231333 changeSummary: changeSummary,
1124 sslError: sslError,
1125 httpHeadersError: httpHeadersError,
1126 reachabilityError: reachabilityError,
1127 ipGeolocationError: ipGeolocationError,
1128 emailSecurityError: emailSecurityError,
1129 ptrError: ptrError,
1130 redirectChainError: redirectChainError,
1131 portScanError: combinedPortScanError
1334 sslError: snapshot.sslError,
1335 httpHeadersError: snapshot.httpHeadersError,
1336 reachabilityError: snapshot.reachabilityError,
1337 ipGeolocationError: snapshot.ipGeolocationError,
1338 emailSecurityError: snapshot.emailSecurityError,
1339 ptrError: snapshot.ptrError,
1340 redirectChainError: snapshot.redirectChainError,
1341 portScanError: snapshot.portScanError
11321342 )
11331343
1134 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
1344 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
11351345 history[0] = entry
11361346 } else {
11371347 history.insert(entry, at: 0)
@@ -1141,13 +1351,17 @@ final class DomainViewModel {
11411351 }
11421352
11431353 updateTrackedDomainSnapshotMetadata(
1144 domain: searchedDomain,
1354 domain: snapshot.domain,
11451355 snapshotID: entry.id,
1146 availabilityStatus: availabilityResult?.status,
1147 updatedAt: timestamp,
1148 changeSummary: changeSummary
1356 availabilityStatus: snapshot.availabilityResult?.status,
1357 updatedAt: snapshot.timestamp,
1358 changeSummary: changeSummary,
1359 changeSeverity: changeSummary?.severity,
1360 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
1361 certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
11491362 )
11501363 persistHistory()
1364 notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
11511365 return entry
11521366 }
11531367
@@ -1176,7 +1390,10 @@ final class DomainViewModel {
11761390 snapshotID: UUID,
11771391 availabilityStatus: DomainAvailabilityStatus?,
11781392 updatedAt: Date,
1179 changeSummary: DomainChangeSummary?
1393 changeSummary: DomainChangeSummary?,
1394 changeSeverity: ChangeSeverity?,
1395 certificateWarningLevel: CertificateWarningLevel,
1396 certificateDaysRemaining: Int?
11801397 ) {
11811398 guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
11821399 return
@@ -1185,9 +1402,44 @@ final class DomainViewModel {
11851402 trackedDomains[index].lastKnownAvailability = availabilityStatus
11861403 trackedDomains[index].updatedAt = updatedAt
11871404 trackedDomains[index].lastChangeSummary = changeSummary
1405 trackedDomains[index].lastChangeSeverity = changeSeverity
1406 trackedDomains[index].certificateWarningLevel = certificateWarningLevel
1407 trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
11881408 persistTrackedDomains()
11891409 }
11901410
1411 private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
1412 guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
1413
1414 Task {
1415 if let summary = entry.changeSummary, summary.hasChanges {
1416 await LocalNotificationService.shared.notifyDomainEvent(
1417 domain: entry.domain,
1418 message: summary.message,
1419 severity: summary.severity
1420 )
1421 }
1422
1423 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
1424 if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
1425 await LocalNotificationService.shared.notifyCertificateWarning(
1426 domain: entry.domain,
1427 daysRemaining: daysRemaining
1428 )
1429 }
1430
1431 if let previousStatus = previousSnapshot?.availabilityResult?.status,
1432 let newStatus = snapshot.availabilityResult?.status,
1433 previousStatus != newStatus {
1434 await LocalNotificationService.shared.notifyDomainEvent(
1435 domain: entry.domain,
1436 message: "Availability changed",
1437 severity: .high
1438 )
1439 }
1440 }
1441 }
1442
11911443 private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
11921444 let matchingEntries = history.filter { entry in
11931445 if let trackedDomainID {
@@ -1235,7 +1487,10 @@ final class DomainViewModel {
12351487 let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
12361488 trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
12371489 trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
1490 trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
12381491 trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
1492 trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
1493 trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
12391494 trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
12401495 persistTrackedDomains()
12411496 }
@@ -1271,6 +1526,43 @@ final class DomainViewModel {
12711526 return lookupID
12721527 }
12731528
1529 private func startBatchLookup(domains: [String], source: BatchLookupSource) {
1530 guard !domains.isEmpty else { return }
1531 guard !batchLookupRunning else { return }
1532
1533 let now = Date()
1534 if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
1535 return
1536 }
1537
1538 lastBatchStartedAt = now
1539 clearBatchState()
1540 batchLookupSource = source
1541 batchTotalCount = domains.count
1542 batchLookupRunning = true
1543 batchResults = domains.map {
1544 BatchLookupResult(
1545 domain: $0,
1546 historyEntryID: nil,
1547 availability: nil,
1548 primaryIP: nil,
1549 quickStatus: "Pending",
1550 timestamp: Date(),
1551 status: .pending
1552 )
1553 }
1554
1555 lookupTask?.cancel()
1556 customPortScanTask?.cancel()
1557 batchTask?.cancel()
1558
1559 batchTask = Task { [weak self] in
1560 guard let self else { return }
1561 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
1562 await self.runBatchLookup(domains: domains, source: source)
1563 }
1564 }
1565
12741566 private func clearBatchState() {
12751567 batchResults = []
12761568 batchLookupSource = .manual
@@ -1278,6 +1570,9 @@ final class DomainViewModel {
12781570 batchCompletedCount = 0
12791571 batchTotalCount = 0
12801572 batchLookupRunning = false
1573 latestBatchSweepSummary = nil
1574 activeBatchDomains = []
1575 batchTask = nil
12811576 }
12821577
12831578 private func parsedDomains(from input: String) -> [String] {
@@ -1292,42 +1587,114 @@ final class DomainViewModel {
12921587 }
12931588
12941589 private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
1295 for (index, domain) in domains.enumerated() {
1296 guard !Task.isCancelled else { break }
1297
1298 batchCurrentDomain = domain
1299 if source == .watchlistRefresh {
1300 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
1590 let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
1591 var nextIndex = 0
1592
1593 await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
1594 for _ in 0..<concurrencyLimit {
1595 guard nextIndex < domains.count else { break }
1596 let domain = domains[nextIndex]
1597 nextIndex += 1
1598 enqueueBatchLookup(domain: domain, source: source, group: &group)
13011599 }
1302 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
1303
1304 let lookupID = beginLookup(for: domain, cancelExistingTask: false)
1305 let entry = await performLookup(domain: domain, lookupID: lookupID)
1306
1307 if let entry {
1308 updateBatchResult(
1309 domain: domain,
1310 status: .completed,
1311 quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged",
1312 entry: entry,
1313 errorMessage: nil
1314 )
1315 } else {
1316 updateBatchResult(
1317 domain: domain,
1318 status: .failed,
1319 quickStatus: "Failed",
1320 entry: nil,
1321 errorMessage: "Lookup cancelled"
1322 )
1600
1601 while let (domain, payload) = await group.next() {
1602 completeBatchLookup(domain: domain, payload: payload)
1603
1604 if nextIndex < domains.count, !Task.isCancelled {
1605 let nextDomain = domains[nextIndex]
1606 nextIndex += 1
1607 enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
1608 }
13231609 }
1610 }
1611
1612 finishBatchLookup(source: source)
1613 }
13241614
1325 batchCompletedCount = index + 1
1615 private func enqueueBatchLookup(
1616 domain: String,
1617 source: BatchLookupSource,
1618 group: inout TaskGroup<(String, BatchLookupPayload?)>
1619 ) {
1620 activeBatchDomains.append(domain)
1621 batchCurrentDomain = activeBatchDomains.first
1622 if source == .watchlistRefresh {
1623 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
1624 }
1625 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
1626
1627 group.addTask { [domain] in
1628 let payload = await Self.performBatchLookup(domain: domain)
1629 return (domain, payload)
13261630 }
1631 }
1632
1633 private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
1634 activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
1635 batchCurrentDomain = activeBatchDomains.first
13271636
1637 guard let payload else {
1638 updateBatchResult(
1639 domain: domain,
1640 status: .failed,
1641 quickStatus: "Failed",
1642 entry: nil,
1643 errorMessage: "Lookup cancelled"
1644 )
1645 batchCompletedCount += 1
1646 return
1647 }
1648
1649 let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
1650 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
1651 let quickStatus: String
1652 if entry?.changeSummary?.hasChanges == true {
1653 quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
1654 } else if certificateWarningLevel != .none {
1655 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
1656 } else {
1657 quickStatus = "Unchanged"
1658 }
1659
1660 updateBatchResult(
1661 domain: domain,
1662 status: .completed,
1663 quickStatus: quickStatus,
1664 entry: entry,
1665 errorMessage: nil
1666 )
1667 batchCompletedCount += 1
1668 }
1669
1670 private func finishBatchLookup(source: BatchLookupSource) {
13281671 batchLookupRunning = false
13291672 batchCurrentDomain = nil
1673 activeBatchDomains = []
13301674 refreshingTrackedDomainID = nil
1675 batchTask = nil
1676
1677 let summary = BatchSweepSummary(
1678 source: source,
1679 totalDomains: batchResults.count,
1680 changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
1681 unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
1682 warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
1683 results: batchResults.sorted { lhs, rhs in
1684 if lhs.status != rhs.status {
1685 return lhs.status.rawValue < rhs.status.rawValue
1686 }
1687 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1688 },
1689 generatedAt: Date()
1690 )
1691 latestBatchSweepSummary = summary
1692
1693 if notificationsAuthorized {
1694 Task {
1695 await LocalNotificationService.shared.notifySweepComplete(summary: summary)
1696 }
1697 }
13311698 }
13321699
13331700 private func updateBatchResult(
@@ -1348,6 +1715,9 @@ final class DomainViewModel {
13481715 availability: entry?.availabilityResult?.status,
13491716 primaryIP: entry?.primaryIP,
13501717 quickStatus: quickStatus,
1718 summaryMessage: entry?.changeSummary?.message,
1719 changeSeverity: entry?.changeSummary?.severity,
1720 certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
13511721 timestamp: entry?.timestamp ?? Date(),
13521722 status: status,
13531723 errorMessage: errorMessage
@@ -1594,8 +1964,8 @@ final class DomainViewModel {
15941964 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
15951965 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
15961966 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
1597 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
1598 SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
1967 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
1968 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary)
15991969 ]
16001970 }
16011971
@@ -1614,6 +1984,16 @@ final class DomainViewModel {
16141984 ),
16151985 at: 1
16161986 )
1987 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
1988 rows.insert(
1989 InfoRowViewData(
1990 label: "Certificate",
1991 value: certificateStatus,
1992 tone: certificateStatusTone(from: snapshot)
1993 ),
1994 at: 2
1995 )
1996 }
16171997 return rows
16181998 }
16191999
@@ -1663,7 +2043,7 @@ final class DomainViewModel {
16632043 InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
16642044 InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
16652045 InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
1666 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
2046 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
16672047 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
16682048 ]
16692049 if let tlsVersion = sslInfo.tlsVersion {
@@ -1855,7 +2235,8 @@ final class DomainViewModel {
18552235 lines.append(" \(item.label): \(item.value)")
18562236 }
18572237 if let changeSummary {
1858 lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
2238 lines.append(" Change Summary: \(changeSummary.message)")
2239 lines.append(" Severity: \(changeSummary.severity.title)")
18592240 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
18602241 lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
18612242 }
@@ -2080,6 +2461,43 @@ final class DomainViewModel {
20802461 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
20812462 }
20822463
2464 private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
2465 guard let sslInfo = snapshot.sslInfo else {
2466 return snapshot.sslError ?? "Unavailable"
2467 }
2468
2469 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
2470 case .critical:
2471 return "Critical (\(sslInfo.daysUntilExpiry)d)"
2472 case .warning:
2473 return "Warning (\(sslInfo.daysUntilExpiry)d)"
2474 case .none:
2475 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
2476 }
2477 }
2478
2479 private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
2480 guard snapshot.sslInfo != nil else { return nil }
2481 return certificateStatusLabel(from: snapshot)
2482 }
2483
2484 private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
2485 guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
2486 return snapshot.sslError == nil ? .secondary : .failure
2487 }
2488 return certificateTone(daysRemaining: daysRemaining)
2489 }
2490
2491 private static func certificateTone(daysRemaining: Int) -> ResultTone {
2492 if daysRemaining < 14 {
2493 return .failure
2494 }
2495 if daysRemaining < 30 {
2496 return .warning
2497 }
2498 return .success
2499 }
2500
20832501 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
20842502 switch status {
20852503 case .available:
DomainDig/LocalNotificationService.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2import UserNotifications
3
4@MainActor
5final class LocalNotificationService {
6 static let shared = LocalNotificationService()
7
8 private init() {}
9
10 func configureForegroundPresentation() {
11 UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared
12 }
13
14 func requestAuthorizationIfNeeded() async -> Bool {
15 let center = UNUserNotificationCenter.current()
16 let settings = await center.notificationSettings()
17
18 switch settings.authorizationStatus {
19 case .authorized, .provisional, .ephemeral:
20 return true
21 case .notDetermined:
22 return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
23 case .denied:
24 return false
25 @unknown default:
26 return false
27 }
28 }
29
30 func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
31 await schedule(
32 identifier: "domain-change-\(domain)",
33 title: domain,
34 body: message,
35 interruptionLevel: severity == .high ? .timeSensitive : .active
36 )
37 }
38
39 func notifyCertificateWarning(domain: String, daysRemaining: Int) async {
40 await schedule(
41 identifier: "cert-warning-\(domain)",
42 title: domain,
43 body: "Certificate expires in \(daysRemaining) days",
44 interruptionLevel: .timeSensitive
45 )
46 }
47
48 func notifySweepComplete(summary: BatchSweepSummary) async {
49 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
50 await schedule(
51 identifier: "sweep-complete",
52 title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete",
53 body: body,
54 interruptionLevel: .active
55 )
56 }
57
58 private func schedule(
59 identifier: String,
60 title: String,
61 body: String,
62 interruptionLevel: UNNotificationInterruptionLevel
63 ) async {
64 let content = UNMutableNotificationContent()
65 content.title = title
66 content.body = body
67 content.sound = .default
68 content.interruptionLevel = interruptionLevel
69
70 let request = UNNotificationRequest(
71 identifier: identifier,
72 content: content,
73 trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
74 )
75
76 try? await UNUserNotificationCenter.current().add(request)
77 }
78}
79
80private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
81 static let shared = NotificationCenterDelegate()
82
83 func userNotificationCenter(
84 _ center: UNUserNotificationCenter,
85 willPresent notification: UNNotification
86 ) async -> UNNotificationPresentationOptions {
87 [.banner, .list, .sound]
88 }
89}
DomainDig/Models.swift +110 −1
@@ -48,10 +48,74 @@ struct WatchedDomain: Codable, Identifiable {
4848 }
4949}
5050
51enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
52 case low
53 case medium
54 case high
55
56 static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
57 lhs.rawValue < rhs.rawValue
58 }
59
60 var title: String {
61 switch self {
62 case .low:
63 return "Low"
64 case .medium:
65 return "Medium"
66 case .high:
67 return "High"
68 }
69 }
70}
71
72enum CertificateWarningLevel: String, Codable {
73 case none
74 case warning
75 case critical
76
77 var title: String {
78 switch self {
79 case .none:
80 return "Healthy"
81 case .warning:
82 return "Warning"
83 case .critical:
84 return "Critical"
85 }
86 }
87}
88
5189struct DomainChangeSummary: Codable, Equatable {
5290 let hasChanges: Bool
5391 let changedSections: [String]
92 let message: String
93 let severity: ChangeSeverity
5494 let generatedAt: Date
95
96 init(
97 hasChanges: Bool,
98 changedSections: [String],
99 message: String,
100 severity: ChangeSeverity,
101 generatedAt: Date
102 ) {
103 self.hasChanges = hasChanges
104 self.changedSections = changedSections
105 self.message = message
106 self.severity = severity
107 self.generatedAt = generatedAt
108 }
109
110 init(from decoder: Decoder) throws {
111 let container = try decoder.container(keyedBy: CodingKeys.self)
112 hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
113 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
114 generatedAt = try container.decode(Date.self, forKey: .generatedAt)
115 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
116 message = try container.decodeIfPresent(String.self, forKey: .message)
117 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
118 }
55119}
56120
57121enum BatchLookupSource: String, Codable {
@@ -73,6 +137,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
73137 let availability: DomainAvailabilityStatus?
74138 let primaryIP: String?
75139 let quickStatus: String
140 let summaryMessage: String?
141 let changeSeverity: ChangeSeverity?
142 let certificateWarningLevel: CertificateWarningLevel
76143 let timestamp: Date
77144 let status: BatchLookupStatus
78145 let errorMessage: String?
@@ -84,6 +151,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
84151 availability: DomainAvailabilityStatus?,
85152 primaryIP: String?,
86153 quickStatus: String,
154 summaryMessage: String? = nil,
155 changeSeverity: ChangeSeverity? = nil,
156 certificateWarningLevel: CertificateWarningLevel = .none,
87157 timestamp: Date,
88158 status: BatchLookupStatus,
89159 errorMessage: String? = nil
@@ -94,12 +164,26 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
94164 self.availability = availability
95165 self.primaryIP = primaryIP
96166 self.quickStatus = quickStatus
167 self.summaryMessage = summaryMessage
168 self.changeSeverity = changeSeverity
169 self.certificateWarningLevel = certificateWarningLevel
97170 self.timestamp = timestamp
98171 self.status = status
99172 self.errorMessage = errorMessage
100173 }
101174}
102175
176struct BatchSweepSummary: Identifiable, Equatable {
177 let id = UUID()
178 let source: BatchLookupSource
179 let totalDomains: Int
180 let changedDomains: Int
181 let unchangedDomains: Int
182 let warningDomains: Int
183 let results: [BatchLookupResult]
184 let generatedAt: Date
185}
186
103187enum HistoryDateFilter: String, CaseIterable, Identifiable {
104188 case today
105189 case last7Days
@@ -213,6 +297,9 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
213297 var lastKnownAvailability: DomainAvailabilityStatus?
214298 var lastSnapshotID: UUID?
215299 var lastChangeSummary: DomainChangeSummary?
300 var lastChangeSeverity: ChangeSeverity?
301 var certificateWarningLevel: CertificateWarningLevel
302 var certificateDaysRemaining: Int?
216303
217304 init(
218305 id: UUID = UUID(),
@@ -223,7 +310,10 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
223310 isPinned: Bool = false,
224311 lastKnownAvailability: DomainAvailabilityStatus? = nil,
225312 lastSnapshotID: UUID? = nil,
226 lastChangeSummary: DomainChangeSummary? = nil
313 lastChangeSummary: DomainChangeSummary? = nil,
314 lastChangeSeverity: ChangeSeverity? = nil,
315 certificateWarningLevel: CertificateWarningLevel = .none,
316 certificateDaysRemaining: Int? = nil
227317 ) {
228318 self.id = id
229319 self.domain = domain
@@ -234,6 +324,25 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
234324 self.lastKnownAvailability = lastKnownAvailability
235325 self.lastSnapshotID = lastSnapshotID
236326 self.lastChangeSummary = lastChangeSummary
327 self.lastChangeSeverity = lastChangeSeverity
328 self.certificateWarningLevel = certificateWarningLevel
329 self.certificateDaysRemaining = certificateDaysRemaining
330 }
331
332 init(from decoder: Decoder) throws {
333 let container = try decoder.container(keyedBy: CodingKeys.self)
334 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
335 domain = try container.decode(String.self, forKey: .domain)
336 createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
337 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
338 note = try container.decodeIfPresent(String.self, forKey: .note)
339 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
340 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
341 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
342 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
343 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
344 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
345 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
237346 }
238347}
239348
DomainDig/SSLCheckService.swift +60 −13
@@ -65,24 +65,28 @@ struct SSLCheckService {
6565 let validFrom: Date
6666 let validUntil: Date
6767
68 if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
69 validFrom = notBefore
70 } else {
71 validFrom = Date.distantPast
72 }
68 let derData = SecCertificateCopyData(leaf) as Data
69 let parsed = DERCertificateParser.parse(derData)
70
71 if #available(iOS 18.0, *) {
72 if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
73 validFrom = notBefore
74 } else {
75 validFrom = parsed.notBefore ?? Date.distantPast
76 }
7377
74 if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
75 validUntil = notAfter
78 if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
79 validUntil = notAfter
80 } else {
81 validUntil = parsed.notAfter ?? Date.distantFuture
82 }
7683 } else {
77 validUntil = Date.distantFuture
84 validFrom = parsed.notBefore ?? Date.distantPast
85 validUntil = parsed.notAfter ?? Date.distantFuture
7886 }
7987
8088 let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
8189
82 // Parse the DER-encoded certificate to extract SANs and Issuer
83 let derData = SecCertificateCopyData(leaf) as Data
84 let parsed = DERCertificateParser.parse(derData)
85
8690 let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
8791
8892 // Issuer: prefer parsed issuer, fall back to chain's next cert summary
@@ -116,6 +120,7 @@ struct SSLCheckService {
116120 chain: chain
117121 )
118122 }
123
119124}
120125
121126fileprivate struct TLSMetadata {
@@ -133,6 +138,8 @@ private enum DERCertificateParser {
133138 struct Result {
134139 var issuerCommonName: String?
135140 var subjectAltNames: [String] = []
141 var notBefore: Date?
142 var notAfter: Date?
136143 }
137144
138145 static func parse(_ data: Data) -> Result {
@@ -171,8 +178,11 @@ private enum DERCertificateParser {
171178 offset = issuerSeq.contentStart + issuerSeq.length
172179 }
173180
174 // Skip validity
181 // Validity
175182 if let validity = readTagAndLength(bytes, offset: offset) {
183 let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length)
184 result.notBefore = notBefore
185 result.notAfter = notAfter
176186 offset = validity.contentStart + validity.length
177187 }
178188
@@ -287,6 +297,43 @@ private enum DERCertificateParser {
287297 return sans
288298 }
289299
300 private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) {
301 let end = sequenceStart + length
302 var position = sequenceStart
303 var dates: [Date] = []
304
305 while position < end, dates.count < 2 {
306 guard let timeTL = readTagAndLength(bytes, offset: position) else { break }
307 let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii)
308 if let raw {
309 dates.append(parseASN1Time(raw))
310 }
311 position = timeTL.contentStart + timeTL.length
312 }
313
314 let notBefore = dates.indices.contains(0) ? dates[0] : nil
315 let notAfter = dates.indices.contains(1) ? dates[1] : nil
316 return (notBefore, notAfter)
317 }
318
319 private static func parseASN1Time(_ string: String) -> Date {
320 let utcFormatter = DateFormatter()
321 utcFormatter.locale = Locale(identifier: "en_US_POSIX")
322 utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
323 utcFormatter.dateFormat = "yyMMddHHmmss'Z'"
324
325 if let date = utcFormatter.date(from: string) {
326 return date
327 }
328
329 let generalizedFormatter = DateFormatter()
330 generalizedFormatter.locale = Locale(identifier: "en_US_POSIX")
331 generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0)
332 generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'"
333
334 return generalizedFormatter.date(from: string) ?? Date.distantFuture
335 }
336
290337 private struct TLV {
291338 let contentStart: Int
292339 let length: Int
DomainDig/WatchlistView.swift +63 −5
@@ -9,12 +9,20 @@ struct WatchlistView: View {
99 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
1010 Section("Refresh Progress") {
1111 VStack(alignment: .leading, spacing: 8) {
12 if viewModel.batchLookupRunning {
13 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
14 .tint(.cyan)
12 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
13 .tint(.cyan)
14 HStack {
1515 Text(viewModel.batchProgressLabel)
1616 .font(.system(.caption, design: .monospaced))
1717 .foregroundStyle(.secondary)
18 Spacer()
19 if viewModel.batchLookupRunning {
20 Button("Cancel") {
21 viewModel.cancelBatchLookup()
22 }
23 .buttonStyle(.bordered)
24 .font(.system(.caption2, design: .monospaced))
25 }
1826 }
1927
2028 ForEach(viewModel.batchResults.prefix(5)) { result in
@@ -129,9 +137,10 @@ struct WatchlistView: View {
129137 }
130138 }
131139
132 Button("Refresh All") {
140 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
133141 viewModel.refreshAllTrackedDomains()
134142 }
143 .disabled(viewModel.batchLookupRunning)
135144
136145 Button("Export TXT") {
137146 shareTrackedDomains(asCSV: false)
@@ -151,9 +160,19 @@ struct WatchlistView: View {
151160 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
152161 dismiss()
153162 }
163 .sheet(item: batchSummaryBinding) { summary in
164 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
165 }
154166 .preferredColorScheme(.dark)
155167 }
156168
169 private var batchSummaryBinding: Binding<BatchSweepSummary?> {
170 Binding(
171 get: { viewModel.latestBatchSweepSummary },
172 set: { viewModel.latestBatchSweepSummary = $0 }
173 )
174 }
175
157176 private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
158177 let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
159178 domains.forEach(viewModel.deleteTrackedDomain)
@@ -197,13 +216,15 @@ struct WatchlistRowView: View {
197216 .font(.system(.caption2, design: .monospaced))
198217 .foregroundStyle(.secondary)
199218
219 indicatorRow
220
200221 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
201222 Text(note)
202223 .font(.system(.caption, design: .monospaced))
203224 .foregroundStyle(.secondary)
204225 .lineLimit(2)
205226 } else if let summary = trackedDomain.lastChangeSummary {
206 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
227 Text(summary.message)
207228 .font(.system(.caption, design: .monospaced))
208229 .foregroundStyle(.secondary)
209230 .lineLimit(2)
@@ -270,6 +291,43 @@ struct WatchlistRowView: View {
270291 return Color(.systemGray5).opacity(0.6)
271292 }
272293 }
294
295 @ViewBuilder
296 private var indicatorRow: some View {
297 HStack(spacing: 8) {
298 if let severity = trackedDomain.lastChangeSeverity, severity >= .medium {
299 Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill")
300 .font(.system(.caption2, design: .monospaced))
301 .foregroundStyle(severity == .high ? .red : .yellow)
302 } else {
303 Label("Stable", systemImage: "circle.fill")
304 .font(.system(.caption2, design: .monospaced))
305 .foregroundStyle(.secondary)
306 }
307
308 if trackedDomain.certificateWarningLevel != .none {
309 Text(certificateLabel)
310 .font(.system(.caption2, design: .monospaced))
311 .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow)
312 .padding(.horizontal, 8)
313 .padding(.vertical, 4)
314 .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16))
315 .clipShape(Capsule())
316 }
317 }
318 }
319
320 private var certificateLabel: String {
321 let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
322 switch trackedDomain.certificateWarningLevel {
323 case .critical:
324 return "Cert \(days)"
325 case .warning:
326 return "Warn \(days)"
327 case .none:
328 return ""
329 }
330 }
273331}
274332
275333struct TrackedDomainDetailView: View {