summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj12
-rw-r--r--DomainDig/BatchResultsView.swift11
-rw-r--r--DomainDig/BatchSweepSummaryView.swift68
-rw-r--r--DomainDig/ContentView.swift115
-rw-r--r--DomainDig/DomainDiffService.swift337
-rw-r--r--DomainDig/DomainDigApp.swift4
-rw-r--r--DomainDig/DomainViewModel.swift658
-rw-r--r--DomainDig/LocalNotificationService.swift89
-rw-r--r--DomainDig/Models.swift111
-rw-r--r--DomainDig/SSLCheckService.swift73
-rw-r--r--DomainDig/WatchlistView.swift68
11 files changed, 1310 insertions, 236 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 8958217..8e652f6 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -267,7 +267,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -279,12 +279,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.1.0;
+ MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -315,12 +315,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.1.0;
+ MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index e741830..03bc7c4 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -75,6 +75,12 @@ struct BatchResultRowView: View {
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
+ if let summaryMessage = result.summaryMessage {
+ Text(summaryMessage)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
if let errorMessage = result.errorMessage {
Text(errorMessage)
.font(.system(.caption2, design: .monospaced))
@@ -103,7 +109,10 @@ struct BatchResultRowView: View {
case .running:
return .cyan
case .completed:
- if result.quickStatus == "Changed" {
+ if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
+ return .red
+ }
+ if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
return .yellow
}
return .green
diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift
new file mode 100644
index 0000000..fda2bc5
--- /dev/null
+++ b/DomainDig/BatchSweepSummaryView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+struct BatchSweepSummaryView: View {
+ @Bindable var viewModel: DomainViewModel
+ let summary: BatchSweepSummary
+
+ @State private var showUnchangedDomains = false
+
+ private var visibleResults: [BatchLookupResult] {
+ if showUnchangedDomains {
+ return summary.results
+ }
+
+ return summary.results.filter {
+ ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Overview") {
+ statRow(label: "Checked", value: "\(summary.totalDomains)")
+ statRow(label: "Changed", value: "\(summary.changedDomains)")
+ statRow(label: "Warnings", value: "\(summary.warningDomains)")
+ statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
+ }
+
+ Section {
+ Toggle("Show unchanged domains", isOn: $showUnchangedDomains)
+ }
+
+ Section(visibleResults.isEmpty ? "Changed Domains" : "Results") {
+ if visibleResults.isEmpty {
+ Text("No domains with changes or warnings")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(visibleResults) { result in
+ if let entry = viewModel.historyEntry(for: result) {
+ NavigationLink {
+ HistoryDetailView(viewModel: viewModel, entry: entry)
+ } label: {
+ BatchResultRowView(result: result)
+ }
+ } else {
+ BatchResultRowView(result: result)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle(summary.source == .watchlistRefresh ? "Sweep Summary" : "Batch Summary")
+ }
+ }
+
+ private func statRow(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(value)
+ .font(.system(.callout, design: .monospaced))
+ .foregroundStyle(.primary)
+ }
+ }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index cda9f9e..2be81b3 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -308,6 +308,13 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Spacer()
+ if viewModel.batchLookupRunning {
+ Button("Cancel") {
+ viewModel.cancelBatchLookup()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ }
if !viewModel.currentBatchResultEntries.isEmpty {
Menu {
Button("Export Batch TXT") {
@@ -455,45 +462,82 @@ struct DomainChangeSummaryView: View {
var body: some View {
CardView(allowsHorizontalScroll: false) {
HStack {
- Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
+ Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle")
.font(.system(.caption, design: .monospaced))
- .foregroundStyle(summary.hasChanges ? .yellow : .green)
+ .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green)
Spacer()
+ Text(summary.severity.title.uppercased())
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
+ .clipShape(Capsule())
Text(summary.generatedAt, style: .time)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
- Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+ Text(summary.message)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.primary)
}
}
+
+ private func severityColor(_ severity: ChangeSeverity) -> Color {
+ switch severity {
+ case .low:
+ return .secondary
+ case .medium:
+ return .yellow
+ case .high:
+ return .red
+ }
+ }
}
struct DomainDiffView: View {
let title: String
let sections: [DomainDiffSection]
let showsUnchanged: Bool
+
@State private var collapsedSections = Set<UUID>()
+ @State private var showsLowSeverity = false
private var filteredSections: [DomainDiffSection] {
- guard showsUnchanged else {
- return sections
- .map { section in
- DomainDiffSection(
- title: section.title,
- items: section.items.filter(\.hasChanges)
- )
+ sections
+ .map { section in
+ let items = section.items.filter { item in
+ if !showsUnchanged, !item.hasChanges {
+ return false
+ }
+ if showsLowSeverity {
+ return true
+ }
+ return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged)
}
- .filter { !$0.items.isEmpty }
- }
- return sections
+ return DomainDiffSection(title: section.title, items: items)
+ }
+ .filter { !$0.items.isEmpty }
+ }
+
+ private var hasLowSeverityChanges: Bool {
+ sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
- SectionTitleView(title: title)
+ HStack {
+ SectionTitleView(title: title)
+ Spacer()
+ if hasLowSeverityChanges {
+ Button(showsLowSeverity ? "Hide Low" : "Show Low") {
+ showsLowSeverity.toggle()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption, design: .monospaced))
+ }
+ }
if filteredSections.isEmpty {
MessageCardView(text: "No comparison data available", isError: false)
} else {
@@ -509,12 +553,12 @@ struct DomainDiffView: View {
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
- Text(changeLabel(for: item.changeType))
+ Text("\(item.severity.title) • \(changeLabel(for: item.changeType))")
.font(.system(.caption2, design: .monospaced))
- .foregroundStyle(changeColor(for: item.changeType))
+ .foregroundStyle(changeColor(for: item))
.padding(.horizontal, 8)
.padding(.vertical, 4)
- .background(changeColor(for: item.changeType).opacity(0.16))
+ .background(changeColor(for: item).opacity(0.16))
.clipShape(Capsule())
}
@@ -543,7 +587,7 @@ struct DomainDiffView: View {
}
}
.padding(10)
- .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25))
+ .background(item.hasChanges ? changeColor(for: item).opacity(0.08) : Color(.systemGray6).opacity(0.25))
.cornerRadius(8)
}
} label: {
@@ -551,11 +595,11 @@ struct DomainDiffView: View {
Text(section.title)
.font(.system(.subheadline, design: .monospaced))
.fontWeight(.semibold)
- .foregroundStyle(.cyan)
+ .foregroundStyle(sectionColor(section))
Spacer()
- Text(section.hasChanges ? "Changed" : "Unchanged")
+ Text(section.severity.title)
.font(.system(.caption2, design: .monospaced))
- .foregroundStyle(section.hasChanges ? .yellow : .secondary)
+ .foregroundStyle(sectionColor(section))
}
}
}
@@ -577,16 +621,29 @@ struct DomainDiffView: View {
}
}
- private func changeColor(for changeType: DiffChangeType) -> Color {
- switch changeType {
- case .added:
- return .green
- case .removed:
+ private func changeColor(for item: DomainDiffItem) -> Color {
+ if item.changeType == .unchanged {
+ return .secondary
+ }
+
+ switch item.severity {
+ case .low:
+ return .blue
+ case .medium:
+ return .yellow
+ case .high:
return .red
- case .changed:
+ }
+ }
+
+ private func sectionColor(_ section: DomainDiffSection) -> Color {
+ switch section.severity {
+ case .low:
+ return .blue
+ case .medium:
return .yellow
- case .unchanged:
- return .secondary
+ case .high:
+ return .red
}
}
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index 65ea5a7..667802a 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable {
let changeType: DiffChangeType
let oldValue: String?
let newValue: String?
+ let severity: ChangeSeverity
var hasChanges: Bool {
changeType != .unchanged
}
+
+ var isMeaningful: Bool {
+ hasChanges && severity >= .medium
+ }
}
struct DomainDiffSection: Identifiable, Equatable {
@@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable {
var hasChanges: Bool {
items.contains(where: \.hasChanges)
}
+
+ var severity: ChangeSeverity {
+ items.map(\.severity).max() ?? .low
+ }
}
enum DomainDiffService {
static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
[
- section(title: "Availability", item: compare(
- label: "Status",
- oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
- newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
- )),
- section(title: "Primary IP", item: compare(
- label: "Address",
- oldValue: primaryIP(from: oldSnapshot),
- newValue: primaryIP(from: newSnapshot)
- )),
+ availabilitySection(from: oldSnapshot, to: newSnapshot),
+ primaryIPSection(from: oldSnapshot, to: newSnapshot),
dnsSection(from: oldSnapshot, to: newSnapshot),
- section(title: "Redirect", item: compare(
- label: "Final Target",
- oldValue: finalRedirectURL(from: oldSnapshot),
- newValue: finalRedirectURL(from: newSnapshot)
- )),
+ redirectSection(from: oldSnapshot, to: newSnapshot),
tlsSection(from: oldSnapshot, to: newSnapshot),
httpSection(from: oldSnapshot, to: newSnapshot),
emailSection(from: oldSnapshot, to: newSnapshot)
@@ -55,62 +52,212 @@ enum DomainDiffService {
.filter { !$0.items.isEmpty }
}
- static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
- let changedSections = diff(from: oldSnapshot, to: newSnapshot)
- .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
- .map(\.title)
+ static func summary(
+ from oldSnapshot: LookupSnapshot,
+ to newSnapshot: LookupSnapshot,
+ generatedAt: Date = Date()
+ ) -> DomainChangeSummary {
+ let sections = diff(from: oldSnapshot, to: newSnapshot)
+ let meaningfulItems = sections
+ .flatMap(\.items)
+ .filter(\.isMeaningful)
+ let allChangedItems = sections
+ .flatMap(\.items)
+ .filter(\.hasChanges)
+
+ let highlights = summaryHighlights(from: meaningfulItems)
+ let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
+ let message = summaryMessage(from: meaningfulItems, highlights: highlights)
return DomainChangeSummary(
- hasChanges: !changedSections.isEmpty,
- changedSections: changedSections,
+ hasChanges: !meaningfulItems.isEmpty,
+ changedSections: highlights,
+ message: message,
+ severity: severity,
generatedAt: generatedAt
)
}
- private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
- DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
+ static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+ guard let days = snapshot.sslInfo?.daysUntilExpiry else {
+ return .none
+ }
+ if days < 14 {
+ return .critical
+ }
+ if days < 30 {
+ return .warning
+ }
+ return .none
+ }
+
+ private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Availability",
+ items: [
+ compare(
+ label: "Availability",
+ oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
+ newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
+ severity: .high
+ )
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Primary IP",
+ items: [
+ compare(
+ label: "Primary IP",
+ oldValue: primaryIP(from: oldSnapshot),
+ newValue: primaryIP(from: newSnapshot),
+ severity: .high
+ )
+ ].compactMap { $0 }
+ )
}
private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- let oldValue = normalizedDNSSummary(from: oldSnapshot)
- let newValue = normalizedDNSSummary(from: newSnapshot)
- return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
+ let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
+ let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
+ let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
+
+ var items: [DomainDiffItem] = []
+ for type in types {
+ let oldSection = oldSections[type]
+ let newSection = newSections[type]
+
+ if let recordChange = compare(
+ label: "\(type.rawValue) Records",
+ oldValue: normalizedRecordValues(for: oldSection),
+ newValue: normalizedRecordValues(for: newSection),
+ severity: .medium
+ ) {
+ items.append(recordChange)
+ }
+
+ if let ttlChange = compare(
+ label: "\(type.rawValue) TTL",
+ oldValue: normalizedTTLValues(for: oldSection),
+ newValue: normalizedTTLValues(for: newSection),
+ severity: .low
+ ), let oldSection, let newSection,
+ normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
+ items.append(ttlChange)
+ }
+ }
+
+ return DomainDiffSection(title: "DNS", items: items)
+ }
+
+ private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Redirect",
+ items: [
+ compare(
+ label: "Redirect Target",
+ oldValue: finalRedirectURL(from: oldSnapshot),
+ newValue: finalRedirectURL(from: newSnapshot),
+ severity: .high
+ )
+ ].compactMap { $0 }
+ )
}
private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
var items: [DomainDiffItem] = []
- if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
- items.append(item)
+
+ if let issuerChange = compare(
+ label: "TLS Issuer",
+ oldValue: normalized(oldSnapshot.sslInfo?.issuer),
+ newValue: normalized(newSnapshot.sslInfo?.issuer),
+ severity: .medium
+ ) {
+ items.append(issuerChange)
+ }
+
+ if let expiryChange = compare(
+ label: "TLS Expiration",
+ oldValue: expirationLabel(oldSnapshot.sslInfo),
+ newValue: expirationLabel(newSnapshot.sslInfo),
+ severity: .medium
+ ) {
+ items.append(expiryChange)
}
- if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
- items.append(item)
+
+ let oldWarning = certificateWarningLevel(for: oldSnapshot)
+ let newWarning = certificateWarningLevel(for: newSnapshot)
+ if oldWarning != newWarning, newWarning != .none {
+ let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
+ items.append(
+ DomainDiffItem(
+ label: "Certificate Warning",
+ changeType: .changed,
+ oldValue: oldWarning.title,
+ newValue: "Certificate expires in \(days) days",
+ severity: newWarning == .critical ? .high : .medium
+ )
+ )
}
- return DomainDiffSection(title: "TLS Certificate", items: items)
+
+ return DomainDiffSection(title: "TLS", items: items)
}
private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
var items: [DomainDiffItem] = []
- if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
- items.append(item)
+
+ if let statusChange = compare(
+ label: "HTTP Status",
+ oldValue: httpStatusSummary(from: oldSnapshot),
+ newValue: httpStatusSummary(from: newSnapshot),
+ severity: .medium
+ ) {
+ items.append(statusChange)
}
- if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
- items.append(item)
+
+ if let gradeChange = compare(
+ label: "Security Grade",
+ oldValue: normalized(oldSnapshot.httpSecurityGrade),
+ newValue: normalized(newSnapshot.httpSecurityGrade),
+ severity: .low
+ ) {
+ items.append(gradeChange)
+ }
+
+ if let headerChange = compare(
+ label: "Headers",
+ oldValue: normalizedHeaders(from: oldSnapshot),
+ newValue: normalizedHeaders(from: newSnapshot),
+ severity: .low
+ ) {
+ items.append(headerChange)
}
+
return DomainDiffSection(title: "HTTP", items: items)
}
private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- section(
+ DomainDiffSection(
title: "Email Security",
- item: compare(
- label: "Summary",
- oldValue: normalized(emailSummary(from: oldSnapshot)),
- newValue: normalized(emailSummary(from: newSnapshot))
- )
+ items: [
+ compare(
+ label: "Email Security",
+ oldValue: normalized(emailSummary(from: oldSnapshot)),
+ newValue: normalized(emailSummary(from: newSnapshot)),
+ severity: .medium
+ )
+ ].compactMap { $0 }
)
}
- private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
+ private static func compare(
+ label: String,
+ oldValue: String?,
+ newValue: String?,
+ severity: ChangeSeverity
+ ) -> DomainDiffItem? {
let oldValue = normalized(oldValue)
let newValue = normalized(newValue)
let normalizedOldValue = comparisonValue(oldValue)
@@ -132,7 +279,68 @@ enum DomainDiffService {
changeType = .changed
}
- return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
+ return DomainDiffItem(
+ label: label,
+ changeType: changeType,
+ oldValue: oldValue,
+ newValue: newValue,
+ severity: severity
+ )
+ }
+
+ private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
+ var highlights: [String] = []
+
+ let labels = Set(items.map(\.label))
+ if labels.contains("Availability") {
+ highlights.append("Availability changed")
+ }
+ if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
+ highlights.append("IP changed")
+ highlights.append("DNS changed")
+ return highlights
+ }
+ if labels.contains("Primary IP") {
+ highlights.append("IP changed")
+ }
+ if labels.contains("Redirect Target") {
+ highlights.append("Redirect target changed")
+ }
+ if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
+ let message = certificateItem.newValue {
+ highlights.append(message)
+ } else if labels.contains("TLS Issuer") {
+ highlights.append("TLS issuer changed")
+ } else if labels.contains("TLS Expiration") {
+ highlights.append("Certificate expiration changed")
+ }
+ if labels.contains(where: { $0.hasSuffix("Records") }) {
+ highlights.append("DNS changed")
+ }
+ if labels.contains("HTTP Status") {
+ highlights.append("HTTP status changed")
+ }
+ if labels.contains("Email Security") {
+ highlights.append("Email security changed")
+ }
+
+ var deduplicated: [String] = []
+ for highlight in highlights where !deduplicated.contains(highlight) {
+ deduplicated.append(highlight)
+ }
+ return deduplicated
+ }
+
+ private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
+ guard !items.isEmpty, !highlights.isEmpty else {
+ return "No meaningful changes"
+ }
+
+ if highlights.count == 1 {
+ return highlights[0]
+ }
+
+ return "\(highlights[0]) and \(highlights[1].lowercased())"
}
private static func normalized(_ value: String?) -> String? {
@@ -167,11 +375,9 @@ enum DomainDiffService {
snapshot.redirectChain.last?.url
}
- private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
- if let sslInfo = snapshot.sslInfo {
- return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
- }
- return snapshot.sslError
+ private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
+ guard let sslInfo else { return nil }
+ return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
}
private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
@@ -194,18 +400,27 @@ enum DomainDiffService {
return snapshot.emailSecurityError
}
- private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
- let parts = snapshot.dnsSections
- .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
- .map { section in
- let values = (section.records + section.wildcardRecords)
- .map(\.value)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
- .sorted()
- .joined(separator: ",")
- return "\(section.recordType.rawValue):\(values)"
- }
- .filter { !$0.hasSuffix(":") }
- return parts.isEmpty ? nil : parts.joined(separator: "|")
+ private static func normalizedRecordValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map(\.value)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ",")
+ }
+
+ private static func normalizedTTLValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ",")
+ }
+
+ private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
+ let headers = snapshot.httpHeaders
+ .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
+ .sorted()
+ return headers.isEmpty ? nil : headers.joined(separator: "|")
}
}
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 9c881d8..a34eb12 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -9,6 +9,10 @@ import SwiftUI
@main
struct DomainDigApp: App {
+ init() {
+ LocalNotificationService.shared.configureForegroundPresentation()
+ }
+
var body: some Scene {
WindowGroup {
ContentView()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 82ec540..031c062 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -159,6 +159,10 @@ extension HistoryEntry {
}
}
+private struct BatchLookupPayload {
+ let snapshot: LookupSnapshot
+}
+
@MainActor
@Observable
final class DomainViewModel {
@@ -228,11 +232,16 @@ final class DomainViewModel {
private(set) var batchCompletedCount = 0
private(set) var batchTotalCount = 0
private(set) var batchLookupRunning = false
+ var latestBatchSweepSummary: BatchSweepSummary?
+ private(set) var notificationsAuthorized = false
private var lookupTask: Task<Void, Never>?
private var customPortScanTask: Task<Void, Never>?
+ private var batchTask: Task<Void, Never>?
private var activeLookupID = UUID()
private var lookupStartedAt: Date?
+ private var activeBatchDomains: [String] = []
+ private var lastBatchStartedAt: Date?
private static let recentSearchesKey = "recentSearches"
private static let maxRecent = 20
@@ -355,8 +364,8 @@ final class DomainViewModel {
var batchProgressLabel: String {
guard batchTotalCount > 0 else { return "No active batch" }
- let domainLabel = batchCurrentDomain ?? "Preparing"
- return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)"
+ let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
+ return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
}
var currentBatchResultEntries: [HistoryEntry] {
@@ -550,6 +559,10 @@ final class DomainViewModel {
func refreshTrackedDomain(_ trackedDomain: TrackedDomain) {
refreshingTrackedDomainID = trackedDomain.id
domain = trackedDomain.domain
+ Task { [weak self] in
+ guard let self else { return }
+ self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
+ }
run()
}
@@ -624,6 +637,7 @@ final class DomainViewModel {
func reset() {
lookupTask?.cancel()
customPortScanTask?.cancel()
+ batchTask?.cancel()
hasRun = false
searchedDomain = ""
lastLookupDurationMs = nil
@@ -649,59 +663,36 @@ final class DomainViewModel {
func runBulkLookup() {
let domains = parsedDomains(from: bulkInput)
guard !domains.isEmpty else { return }
-
- clearBatchState()
- batchLookupSource = .manual
- batchTotalCount = domains.count
- batchLookupRunning = true
- batchResults = domains.map {
- BatchLookupResult(
- domain: $0,
- historyEntryID: nil,
- availability: nil,
- primaryIP: nil,
- quickStatus: "Pending",
- timestamp: Date(),
- status: .pending
- )
- }
-
- lookupTask?.cancel()
- customPortScanTask?.cancel()
-
- lookupTask = Task { [weak self] in
- guard let self else { return }
- await self.runBatchLookup(domains: domains, source: .manual)
- }
+ startBatchLookup(domains: domains, source: .manual)
}
func refreshAllTrackedDomains() {
- let domains = sortedTrackedDomains.map(\.domain)
- guard !domains.isEmpty else { return }
+ startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
+ }
- clearBatchState()
- batchLookupSource = .watchlistRefresh
- batchTotalCount = domains.count
- batchLookupRunning = true
- batchResults = domains.map {
- BatchLookupResult(
- domain: $0,
- historyEntryID: nil,
- availability: nil,
- primaryIP: nil,
- quickStatus: "Pending",
+ func cancelBatchLookup() {
+ batchTask?.cancel()
+ batchLookupRunning = false
+ batchCurrentDomain = nil
+ activeBatchDomains = []
+ refreshingTrackedDomainID = nil
+
+ for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running {
+ batchResults[index] = BatchLookupResult(
+ id: batchResults[index].id,
+ domain: batchResults[index].domain,
+ historyEntryID: batchResults[index].historyEntryID,
+ availability: batchResults[index].availability,
+ primaryIP: batchResults[index].primaryIP,
+ quickStatus: "Cancelled",
+ summaryMessage: batchResults[index].summaryMessage,
+ changeSeverity: batchResults[index].changeSeverity,
+ certificateWarningLevel: batchResults[index].certificateWarningLevel,
timestamp: Date(),
- status: .pending
+ status: .failed,
+ errorMessage: "Lookup cancelled"
)
}
-
- lookupTask?.cancel()
- customPortScanTask?.cancel()
-
- lookupTask = Task { [weak self] in
- guard let self else { return }
- await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
- }
}
func runCustomPortScan(ports: [UInt16]) async {
@@ -1057,7 +1048,215 @@ final class DomainViewModel {
customPortScanLoading = false
}
- private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+ private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
+ guard !Task.isCancelled else { return nil }
+
+ let startedAt = Date()
+ let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
+ let resolverURLString = DNSLookupService.currentResolverURLString()
+
+ async let dnsResult = DNSLookupService.lookupAll(domain: domain)
+ async let availabilityResult = DomainAvailabilityService.check(domain: domain)
+ async let sslResult = SSLCheckService.check(domain: domain)
+ async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
+ async let httpResult = HTTPHeadersService.fetch(domain: domain)
+ async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
+ async let redirectResult = RedirectChainService.trace(domain: domain)
+ async let portScanResult = PortScanService.scanAll(domain: domain)
+
+ let resolvedDNS = await dnsResult
+ let availability = await availabilityResult
+ let resolvedSSL = await sslResult
+ let hsts = await hstsResult
+ let http = await httpResult
+ let reachability = await reachabilityResult
+ let redirects = await redirectResult
+ let ports = await portScanResult
+
+ guard !Task.isCancelled else { return nil }
+
+ let dnsSections: [DNSSection]
+ let dnsError: String?
+ switch resolvedDNS {
+ case let .success(sections):
+ dnsSections = sections
+ dnsError = nil
+ case let .empty(message), let .error(message):
+ dnsSections = []
+ dnsError = message
+ }
+
+ let sslInfo: SSLCertificateInfo?
+ let sslError: String?
+ switch resolvedSSL {
+ case let .success(info):
+ sslInfo = info
+ sslError = nil
+ case let .empty(message), let .error(message):
+ sslInfo = nil
+ sslError = message
+ }
+
+ let httpHeaders: [HTTPHeader]
+ let httpSecurityGrade: String?
+ let httpStatusCode: Int?
+ let httpResponseTimeMs: Int?
+ let httpProtocol: String?
+ let http3Advertised: Bool
+ let httpHeadersError: String?
+ switch http {
+ case let .success(result):
+ httpHeaders = result.headers
+ httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
+ httpStatusCode = result.statusCode
+ httpResponseTimeMs = result.responseTimeMs
+ httpProtocol = result.httpProtocol
+ http3Advertised = result.http3Advertised
+ httpHeadersError = nil
+ case let .empty(message), let .error(message):
+ httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
+ httpHeadersError = message
+ }
+
+ let reachabilityResults: [PortReachability]
+ let reachabilityError: String?
+ switch reachability {
+ case let .success(results):
+ reachabilityResults = results
+ reachabilityError = nil
+ case let .empty(message), let .error(message):
+ reachabilityResults = []
+ reachabilityError = message
+ }
+
+ let redirectChain: [RedirectHop]
+ let redirectChainError: String?
+ switch redirects {
+ case let .success(hops):
+ redirectChain = hops
+ redirectChainError = nil
+ case let .empty(message), let .error(message):
+ redirectChain = []
+ redirectChainError = message
+ }
+
+ let portScanResults: [PortScanResult]
+ let portScanError: String?
+ switch ports {
+ case let .success(results):
+ portScanResults = await enrichOpenPortBanners(results, domain: domain)
+ portScanError = nil
+ case let .empty(message), let .error(message):
+ portScanResults = []
+ portScanError = message
+ }
+
+ let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+ let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+
+ async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
+ async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
+
+ let resolvedEmail = await emailResult
+ let resolvedSuggestions = await suggestions
+ let resolvedPTR: ServiceResult<String>?
+ let resolvedGeo: ServiceResult<IPGeolocation>?
+ if let primaryIP {
+ resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
+ resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
+ } else {
+ resolvedPTR = nil
+ resolvedGeo = nil
+ }
+
+ guard !Task.isCancelled else { return nil }
+
+ let emailSecurity: EmailSecurityResult?
+ let emailSecurityError: String?
+ switch resolvedEmail {
+ case let .success(result):
+ emailSecurity = result
+ emailSecurityError = nil
+ case let .empty(message), let .error(message):
+ emailSecurity = nil
+ emailSecurityError = message
+ }
+
+ let ptrRecord: String?
+ let ptrError: String?
+ switch resolvedPTR {
+ case let .success(record):
+ ptrRecord = record
+ ptrError = nil
+ case let .empty(message), let .error(message):
+ ptrRecord = nil
+ ptrError = message
+ case .none:
+ ptrRecord = nil
+ ptrError = "No A record available"
+ }
+
+ let ipGeolocation: IPGeolocation?
+ let ipGeolocationError: String?
+ switch resolvedGeo {
+ case let .success(result):
+ ipGeolocation = result
+ ipGeolocationError = nil
+ case let .empty(message), let .error(message):
+ ipGeolocation = nil
+ ipGeolocationError = message
+ case .none:
+ ipGeolocation = nil
+ ipGeolocationError = "No A record available"
+ }
+
+ let snapshot = LookupSnapshot(
+ historyEntryID: nil,
+ domain: domain,
+ timestamp: Date(),
+ trackedDomainID: nil,
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
+ dnsSections: dnsSections,
+ dnsError: dnsError,
+ availabilityResult: availability,
+ suggestions: resolvedSuggestions,
+ sslInfo: sslInfo,
+ sslError: sslError,
+ hstsPreloaded: hsts,
+ httpHeaders: httpHeaders,
+ httpSecurityGrade: httpSecurityGrade,
+ httpStatusCode: httpStatusCode,
+ httpResponseTimeMs: httpResponseTimeMs,
+ httpProtocol: httpProtocol,
+ http3Advertised: http3Advertised,
+ httpHeadersError: httpHeadersError,
+ reachabilityResults: reachabilityResults,
+ reachabilityError: reachabilityError,
+ ipGeolocation: ipGeolocation,
+ ipGeolocationError: ipGeolocationError,
+ emailSecurity: emailSecurity,
+ emailSecurityError: emailSecurityError,
+ ptrRecord: ptrRecord,
+ ptrError: ptrError,
+ redirectChain: redirectChain,
+ redirectChainError: redirectChainError,
+ portScanResults: portScanResults,
+ portScanError: portScanError,
+ changeSummary: nil,
+ isLive: false
+ )
+
+ return BatchLookupPayload(snapshot: snapshot)
+ }
+
+ private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
for result in results where result.open {
group.addTask {
@@ -1082,56 +1281,67 @@ final class DomainViewModel {
}
}
+ private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+ await Self.enrichOpenPortBanners(results, domain: domain)
+ }
+
@discardableResult
private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
guard !searchedDomain.isEmpty else { return nil }
+ return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true)
+ }
- let trackedDomainID = trackedDomain(for: searchedDomain)?.id
- let timestamp = Date()
- let snapshot = currentSnapshot
- let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
- let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) }
+ @discardableResult
+ private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
+ let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
+ let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
+ let changeSummary = previousSnapshot.map {
+ DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
+ }
+ let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
- currentChangeSummary = changeSummary
- currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
+ if updateCurrentState {
+ currentChangeSummary = changeSummary
+ currentDiffSections = diffSections
+ }
let entry = HistoryEntry(
- domain: searchedDomain,
- timestamp: timestamp,
+ domain: snapshot.domain,
+ timestamp: snapshot.timestamp,
trackedDomainID: trackedDomainID,
- dnsSections: dnsSections,
- sslInfo: sslInfo,
- httpHeaders: httpHeaders,
- reachabilityResults: reachabilityResults,
- ipGeolocation: ipGeolocation,
- emailSecurity: emailSecurity,
- mtaSts: emailSecurity?.mtaSts,
- ptrRecord: ptrRecord,
- redirectChain: redirectChain,
- portScanResults: allPortScanResults,
- hstsPreloaded: hstsPreloaded,
- availabilityResult: availabilityResult,
- suggestions: suggestions,
- resolverDisplayName: resolverDisplayName,
- resolverURLString: resolverURLString,
- totalLookupDurationMs: lastLookupDurationMs,
+ dnsSections: snapshot.dnsSections,
+ sslInfo: snapshot.sslInfo,
+ httpHeaders: snapshot.httpHeaders,
+ reachabilityResults: snapshot.reachabilityResults,
+ ipGeolocation: snapshot.ipGeolocation,
+ emailSecurity: snapshot.emailSecurity,
+ mtaSts: snapshot.emailSecurity?.mtaSts,
+ ptrRecord: snapshot.ptrRecord,
+ redirectChain: snapshot.redirectChain,
+ portScanResults: snapshot.portScanResults,
+ hstsPreloaded: snapshot.hstsPreloaded,
+ availabilityResult: snapshot.availabilityResult,
+ suggestions: snapshot.suggestions,
+ resolverDisplayName: snapshot.resolverDisplayName,
+ resolverURLString: snapshot.resolverURLString,
+ totalLookupDurationMs: snapshot.totalLookupDurationMs,
primaryIP: Self.primaryIPAddress(from: snapshot),
finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
tlsStatusSummary: Self.httpsSummary(from: snapshot),
emailSecuritySummary: Self.emailSummary(from: snapshot),
httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
changeSummary: changeSummary,
- sslError: sslError,
- httpHeadersError: httpHeadersError,
- reachabilityError: reachabilityError,
- ipGeolocationError: ipGeolocationError,
- emailSecurityError: emailSecurityError,
- ptrError: ptrError,
- redirectChainError: redirectChainError,
- portScanError: combinedPortScanError
+ sslError: snapshot.sslError,
+ httpHeadersError: snapshot.httpHeadersError,
+ reachabilityError: snapshot.reachabilityError,
+ ipGeolocationError: snapshot.ipGeolocationError,
+ emailSecurityError: snapshot.emailSecurityError,
+ ptrError: snapshot.ptrError,
+ redirectChainError: snapshot.redirectChainError,
+ portScanError: snapshot.portScanError
)
- if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame {
+ if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
history[0] = entry
} else {
history.insert(entry, at: 0)
@@ -1141,13 +1351,17 @@ final class DomainViewModel {
}
updateTrackedDomainSnapshotMetadata(
- domain: searchedDomain,
+ domain: snapshot.domain,
snapshotID: entry.id,
- availabilityStatus: availabilityResult?.status,
- updatedAt: timestamp,
- changeSummary: changeSummary
+ availabilityStatus: snapshot.availabilityResult?.status,
+ updatedAt: snapshot.timestamp,
+ changeSummary: changeSummary,
+ changeSeverity: changeSummary?.severity,
+ certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+ certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry
)
persistHistory()
+ notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot)
return entry
}
@@ -1176,7 +1390,10 @@ final class DomainViewModel {
snapshotID: UUID,
availabilityStatus: DomainAvailabilityStatus?,
updatedAt: Date,
- changeSummary: DomainChangeSummary?
+ changeSummary: DomainChangeSummary?,
+ changeSeverity: ChangeSeverity?,
+ certificateWarningLevel: CertificateWarningLevel,
+ certificateDaysRemaining: Int?
) {
guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
return
@@ -1185,9 +1402,44 @@ final class DomainViewModel {
trackedDomains[index].lastKnownAvailability = availabilityStatus
trackedDomains[index].updatedAt = updatedAt
trackedDomains[index].lastChangeSummary = changeSummary
+ trackedDomains[index].lastChangeSeverity = changeSeverity
+ trackedDomains[index].certificateWarningLevel = certificateWarningLevel
+ trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining
persistTrackedDomains()
}
+ private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) {
+ guard notificationsAuthorized, entry.trackedDomainID != nil else { return }
+
+ Task {
+ if let summary = entry.changeSummary, summary.hasChanges {
+ await LocalNotificationService.shared.notifyDomainEvent(
+ domain: entry.domain,
+ message: summary.message,
+ severity: summary.severity
+ )
+ }
+
+ let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+ if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry {
+ await LocalNotificationService.shared.notifyCertificateWarning(
+ domain: entry.domain,
+ daysRemaining: daysRemaining
+ )
+ }
+
+ if let previousStatus = previousSnapshot?.availabilityResult?.status,
+ let newStatus = snapshot.availabilityResult?.status,
+ previousStatus != newStatus {
+ await LocalNotificationService.shared.notifyDomainEvent(
+ domain: entry.domain,
+ message: "Availability changed",
+ severity: .high
+ )
+ }
+ }
+ }
+
private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? {
let matchingEntries = history.filter { entry in
if let trackedDomainID {
@@ -1235,7 +1487,10 @@ final class DomainViewModel {
let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id
trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary
+ trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity
trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status
+ trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot)
+ trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry
trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp
persistTrackedDomains()
}
@@ -1271,6 +1526,43 @@ final class DomainViewModel {
return lookupID
}
+ private func startBatchLookup(domains: [String], source: BatchLookupSource) {
+ guard !domains.isEmpty else { return }
+ guard !batchLookupRunning else { return }
+
+ let now = Date()
+ if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 {
+ return
+ }
+
+ lastBatchStartedAt = now
+ clearBatchState()
+ batchLookupSource = source
+ batchTotalCount = domains.count
+ batchLookupRunning = true
+ batchResults = domains.map {
+ BatchLookupResult(
+ domain: $0,
+ historyEntryID: nil,
+ availability: nil,
+ primaryIP: nil,
+ quickStatus: "Pending",
+ timestamp: Date(),
+ status: .pending
+ )
+ }
+
+ lookupTask?.cancel()
+ customPortScanTask?.cancel()
+ batchTask?.cancel()
+
+ batchTask = Task { [weak self] in
+ guard let self else { return }
+ self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
+ await self.runBatchLookup(domains: domains, source: source)
+ }
+ }
+
private func clearBatchState() {
batchResults = []
batchLookupSource = .manual
@@ -1278,6 +1570,9 @@ final class DomainViewModel {
batchCompletedCount = 0
batchTotalCount = 0
batchLookupRunning = false
+ latestBatchSweepSummary = nil
+ activeBatchDomains = []
+ batchTask = nil
}
private func parsedDomains(from input: String) -> [String] {
@@ -1292,42 +1587,114 @@ final class DomainViewModel {
}
private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
- for (index, domain) in domains.enumerated() {
- guard !Task.isCancelled else { break }
-
- batchCurrentDomain = domain
- if source == .watchlistRefresh {
- refreshingTrackedDomainID = trackedDomain(for: domain)?.id
+ let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1))
+ var nextIndex = 0
+
+ await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in
+ for _ in 0..<concurrencyLimit {
+ guard nextIndex < domains.count else { break }
+ let domain = domains[nextIndex]
+ nextIndex += 1
+ enqueueBatchLookup(domain: domain, source: source, group: &group)
}
- updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
-
- let lookupID = beginLookup(for: domain, cancelExistingTask: false)
- let entry = await performLookup(domain: domain, lookupID: lookupID)
-
- if let entry {
- updateBatchResult(
- domain: domain,
- status: .completed,
- quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged",
- entry: entry,
- errorMessage: nil
- )
- } else {
- updateBatchResult(
- domain: domain,
- status: .failed,
- quickStatus: "Failed",
- entry: nil,
- errorMessage: "Lookup cancelled"
- )
+
+ while let (domain, payload) = await group.next() {
+ completeBatchLookup(domain: domain, payload: payload)
+
+ if nextIndex < domains.count, !Task.isCancelled {
+ let nextDomain = domains[nextIndex]
+ nextIndex += 1
+ enqueueBatchLookup(domain: nextDomain, source: source, group: &group)
+ }
}
+ }
+
+ finishBatchLookup(source: source)
+ }
- batchCompletedCount = index + 1
+ private func enqueueBatchLookup(
+ domain: String,
+ source: BatchLookupSource,
+ group: inout TaskGroup<(String, BatchLookupPayload?)>
+ ) {
+ activeBatchDomains.append(domain)
+ batchCurrentDomain = activeBatchDomains.first
+ if source == .watchlistRefresh {
+ refreshingTrackedDomainID = trackedDomain(for: domain)?.id
+ }
+ updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
+
+ group.addTask { [domain] in
+ let payload = await Self.performBatchLookup(domain: domain)
+ return (domain, payload)
}
+ }
+
+ private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) {
+ activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame }
+ batchCurrentDomain = activeBatchDomains.first
+ guard let payload else {
+ updateBatchResult(
+ domain: domain,
+ status: .failed,
+ quickStatus: "Failed",
+ entry: nil,
+ errorMessage: "Lookup cancelled"
+ )
+ batchCompletedCount += 1
+ return
+ }
+
+ let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
+ let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
+ let quickStatus: String
+ if entry?.changeSummary?.hasChanges == true {
+ quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
+ } else if certificateWarningLevel != .none {
+ quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
+ } else {
+ quickStatus = "Unchanged"
+ }
+
+ updateBatchResult(
+ domain: domain,
+ status: .completed,
+ quickStatus: quickStatus,
+ entry: entry,
+ errorMessage: nil
+ )
+ batchCompletedCount += 1
+ }
+
+ private func finishBatchLookup(source: BatchLookupSource) {
batchLookupRunning = false
batchCurrentDomain = nil
+ activeBatchDomains = []
refreshingTrackedDomainID = nil
+ batchTask = nil
+
+ let summary = BatchSweepSummary(
+ source: source,
+ totalDomains: batchResults.count,
+ changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
+ unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
+ warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
+ results: batchResults.sorted { lhs, rhs in
+ if lhs.status != rhs.status {
+ return lhs.status.rawValue < rhs.status.rawValue
+ }
+ return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
+ },
+ generatedAt: Date()
+ )
+ latestBatchSweepSummary = summary
+
+ if notificationsAuthorized {
+ Task {
+ await LocalNotificationService.shared.notifySweepComplete(summary: summary)
+ }
+ }
}
private func updateBatchResult(
@@ -1348,6 +1715,9 @@ final class DomainViewModel {
availability: entry?.availabilityResult?.status,
primaryIP: entry?.primaryIP,
quickStatus: quickStatus,
+ summaryMessage: entry?.changeSummary?.message,
+ changeSeverity: entry?.changeSummary?.severity,
+ certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
timestamp: entry?.timestamp ?? Date(),
status: status,
errorMessage: errorMessage
@@ -1594,8 +1964,8 @@ final class DomainViewModel {
SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
- SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
- SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary)
+ SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
+ SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary)
]
}
@@ -1614,6 +1984,16 @@ final class DomainViewModel {
),
at: 1
)
+ if let certificateStatus = certificateBadgeLabel(from: snapshot) {
+ rows.insert(
+ InfoRowViewData(
+ label: "Certificate",
+ value: certificateStatus,
+ tone: certificateStatusTone(from: snapshot)
+ ),
+ at: 2
+ )
+ }
return rows
}
@@ -1663,7 +2043,7 @@ final class DomainViewModel {
InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary),
InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary),
InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary),
- InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
+ InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)),
InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
]
if let tlsVersion = sslInfo.tlsVersion {
@@ -1855,7 +2235,8 @@ final class DomainViewModel {
lines.append(" \(item.label): \(item.value)")
}
if let changeSummary {
- lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
+ lines.append(" Change Summary: \(changeSummary.message)")
+ lines.append(" Severity: \(changeSummary.severity.title)")
lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
}
@@ -2080,6 +2461,43 @@ final class DomainViewModel {
return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
}
+ private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String {
+ guard let sslInfo = snapshot.sslInfo else {
+ return snapshot.sslError ?? "Unavailable"
+ }
+
+ switch DomainDiffService.certificateWarningLevel(for: snapshot) {
+ case .critical:
+ return "Critical (\(sslInfo.daysUntilExpiry)d)"
+ case .warning:
+ return "Warning (\(sslInfo.daysUntilExpiry)d)"
+ case .none:
+ return "Healthy (\(sslInfo.daysUntilExpiry)d)"
+ }
+ }
+
+ private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? {
+ guard snapshot.sslInfo != nil else { return nil }
+ return certificateStatusLabel(from: snapshot)
+ }
+
+ private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone {
+ guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else {
+ return snapshot.sslError == nil ? .secondary : .failure
+ }
+ return certificateTone(daysRemaining: daysRemaining)
+ }
+
+ private static func certificateTone(daysRemaining: Int) -> ResultTone {
+ if daysRemaining < 14 {
+ return .failure
+ }
+ if daysRemaining < 30 {
+ return .warning
+ }
+ return .success
+ }
+
private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
switch status {
case .available:
diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift
new file mode 100644
index 0000000..450d844
--- /dev/null
+++ b/DomainDig/LocalNotificationService.swift
@@ -0,0 +1,89 @@
+import Foundation
+import UserNotifications
+
+@MainActor
+final class LocalNotificationService {
+ static let shared = LocalNotificationService()
+
+ private init() {}
+
+ func configureForegroundPresentation() {
+ UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared
+ }
+
+ func requestAuthorizationIfNeeded() async -> Bool {
+ let center = UNUserNotificationCenter.current()
+ let settings = await center.notificationSettings()
+
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ return true
+ case .notDetermined:
+ return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
+ case .denied:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+
+ func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
+ await schedule(
+ identifier: "domain-change-\(domain)",
+ title: domain,
+ body: message,
+ interruptionLevel: severity == .high ? .timeSensitive : .active
+ )
+ }
+
+ func notifyCertificateWarning(domain: String, daysRemaining: Int) async {
+ await schedule(
+ identifier: "cert-warning-\(domain)",
+ title: domain,
+ body: "Certificate expires in \(daysRemaining) days",
+ interruptionLevel: .timeSensitive
+ )
+ }
+
+ func notifySweepComplete(summary: BatchSweepSummary) async {
+ let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
+ await schedule(
+ identifier: "sweep-complete",
+ title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete",
+ body: body,
+ interruptionLevel: .active
+ )
+ }
+
+ private func schedule(
+ identifier: String,
+ title: String,
+ body: String,
+ interruptionLevel: UNNotificationInterruptionLevel
+ ) async {
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+ content.interruptionLevel = interruptionLevel
+
+ let request = UNNotificationRequest(
+ identifier: identifier,
+ content: content,
+ trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
+ )
+
+ try? await UNUserNotificationCenter.current().add(request)
+ }
+}
+
+private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
+ static let shared = NotificationCenterDelegate()
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification
+ ) async -> UNNotificationPresentationOptions {
+ [.banner, .list, .sound]
+ }
+}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index d11e879..2e0a5c2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -48,10 +48,74 @@ struct WatchedDomain: Codable, Identifiable {
}
}
+enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
+ case low
+ case medium
+ case high
+
+ static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+
+ var title: String {
+ switch self {
+ case .low:
+ return "Low"
+ case .medium:
+ return "Medium"
+ case .high:
+ return "High"
+ }
+ }
+}
+
+enum CertificateWarningLevel: String, Codable {
+ case none
+ case warning
+ case critical
+
+ var title: String {
+ switch self {
+ case .none:
+ return "Healthy"
+ case .warning:
+ return "Warning"
+ case .critical:
+ return "Critical"
+ }
+ }
+}
+
struct DomainChangeSummary: Codable, Equatable {
let hasChanges: Bool
let changedSections: [String]
+ let message: String
+ let severity: ChangeSeverity
let generatedAt: Date
+
+ init(
+ hasChanges: Bool,
+ changedSections: [String],
+ message: String,
+ severity: ChangeSeverity,
+ generatedAt: Date
+ ) {
+ self.hasChanges = hasChanges
+ self.changedSections = changedSections
+ self.message = message
+ self.severity = severity
+ self.generatedAt = generatedAt
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
+ changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
+ generatedAt = try container.decode(Date.self, forKey: .generatedAt)
+ severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
+ message = try container.decodeIfPresent(String.self, forKey: .message)
+ ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
+ }
}
enum BatchLookupSource: String, Codable {
@@ -73,6 +137,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
let availability: DomainAvailabilityStatus?
let primaryIP: String?
let quickStatus: String
+ let summaryMessage: String?
+ let changeSeverity: ChangeSeverity?
+ let certificateWarningLevel: CertificateWarningLevel
let timestamp: Date
let status: BatchLookupStatus
let errorMessage: String?
@@ -84,6 +151,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
availability: DomainAvailabilityStatus?,
primaryIP: String?,
quickStatus: String,
+ summaryMessage: String? = nil,
+ changeSeverity: ChangeSeverity? = nil,
+ certificateWarningLevel: CertificateWarningLevel = .none,
timestamp: Date,
status: BatchLookupStatus,
errorMessage: String? = nil
@@ -94,12 +164,26 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
self.availability = availability
self.primaryIP = primaryIP
self.quickStatus = quickStatus
+ self.summaryMessage = summaryMessage
+ self.changeSeverity = changeSeverity
+ self.certificateWarningLevel = certificateWarningLevel
self.timestamp = timestamp
self.status = status
self.errorMessage = errorMessage
}
}
+struct BatchSweepSummary: Identifiable, Equatable {
+ let id = UUID()
+ let source: BatchLookupSource
+ let totalDomains: Int
+ let changedDomains: Int
+ let unchangedDomains: Int
+ let warningDomains: Int
+ let results: [BatchLookupResult]
+ let generatedAt: Date
+}
+
enum HistoryDateFilter: String, CaseIterable, Identifiable {
case today
case last7Days
@@ -213,6 +297,9 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
var lastKnownAvailability: DomainAvailabilityStatus?
var lastSnapshotID: UUID?
var lastChangeSummary: DomainChangeSummary?
+ var lastChangeSeverity: ChangeSeverity?
+ var certificateWarningLevel: CertificateWarningLevel
+ var certificateDaysRemaining: Int?
init(
id: UUID = UUID(),
@@ -223,7 +310,10 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
isPinned: Bool = false,
lastKnownAvailability: DomainAvailabilityStatus? = nil,
lastSnapshotID: UUID? = nil,
- lastChangeSummary: DomainChangeSummary? = nil
+ lastChangeSummary: DomainChangeSummary? = nil,
+ lastChangeSeverity: ChangeSeverity? = nil,
+ certificateWarningLevel: CertificateWarningLevel = .none,
+ certificateDaysRemaining: Int? = nil
) {
self.id = id
self.domain = domain
@@ -234,6 +324,25 @@ struct TrackedDomain: Codable, Identifiable, Equatable {
self.lastKnownAvailability = lastKnownAvailability
self.lastSnapshotID = lastSnapshotID
self.lastChangeSummary = lastChangeSummary
+ self.lastChangeSeverity = lastChangeSeverity
+ self.certificateWarningLevel = certificateWarningLevel
+ self.certificateDaysRemaining = certificateDaysRemaining
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+ domain = try container.decode(String.self, forKey: .domain)
+ createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
+ updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
+ note = try container.decodeIfPresent(String.self, forKey: .note)
+ isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
+ lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
+ lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
+ lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
+ lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
+ certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
+ certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
}
}
diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift
index e180d4e..73c00bd 100644
--- a/DomainDig/SSLCheckService.swift
+++ b/DomainDig/SSLCheckService.swift
@@ -65,24 +65,28 @@ struct SSLCheckService {
let validFrom: Date
let validUntil: Date
- if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
- validFrom = notBefore
- } else {
- validFrom = Date.distantPast
- }
+ let derData = SecCertificateCopyData(leaf) as Data
+ let parsed = DERCertificateParser.parse(derData)
+
+ if #available(iOS 18.0, *) {
+ if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
+ validFrom = notBefore
+ } else {
+ validFrom = parsed.notBefore ?? Date.distantPast
+ }
- if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
- validUntil = notAfter
+ if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
+ validUntil = notAfter
+ } else {
+ validUntil = parsed.notAfter ?? Date.distantFuture
+ }
} else {
- validUntil = Date.distantFuture
+ validFrom = parsed.notBefore ?? Date.distantPast
+ validUntil = parsed.notAfter ?? Date.distantFuture
}
let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
- // Parse the DER-encoded certificate to extract SANs and Issuer
- let derData = SecCertificateCopyData(leaf) as Data
- let parsed = DERCertificateParser.parse(derData)
-
let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
// Issuer: prefer parsed issuer, fall back to chain's next cert summary
@@ -116,6 +120,7 @@ struct SSLCheckService {
chain: chain
)
}
+
}
fileprivate struct TLSMetadata {
@@ -133,6 +138,8 @@ private enum DERCertificateParser {
struct Result {
var issuerCommonName: String?
var subjectAltNames: [String] = []
+ var notBefore: Date?
+ var notAfter: Date?
}
static func parse(_ data: Data) -> Result {
@@ -171,8 +178,11 @@ private enum DERCertificateParser {
offset = issuerSeq.contentStart + issuerSeq.length
}
- // Skip validity
+ // Validity
if let validity = readTagAndLength(bytes, offset: offset) {
+ let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length)
+ result.notBefore = notBefore
+ result.notAfter = notAfter
offset = validity.contentStart + validity.length
}
@@ -287,6 +297,43 @@ private enum DERCertificateParser {
return sans
}
+ private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) {
+ let end = sequenceStart + length
+ var position = sequenceStart
+ var dates: [Date] = []
+
+ while position < end, dates.count < 2 {
+ guard let timeTL = readTagAndLength(bytes, offset: position) else { break }
+ let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii)
+ if let raw {
+ dates.append(parseASN1Time(raw))
+ }
+ position = timeTL.contentStart + timeTL.length
+ }
+
+ let notBefore = dates.indices.contains(0) ? dates[0] : nil
+ let notAfter = dates.indices.contains(1) ? dates[1] : nil
+ return (notBefore, notAfter)
+ }
+
+ private static func parseASN1Time(_ string: String) -> Date {
+ let utcFormatter = DateFormatter()
+ utcFormatter.locale = Locale(identifier: "en_US_POSIX")
+ utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+ utcFormatter.dateFormat = "yyMMddHHmmss'Z'"
+
+ if let date = utcFormatter.date(from: string) {
+ return date
+ }
+
+ let generalizedFormatter = DateFormatter()
+ generalizedFormatter.locale = Locale(identifier: "en_US_POSIX")
+ generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+ generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'"
+
+ return generalizedFormatter.date(from: string) ?? Date.distantFuture
+ }
+
private struct TLV {
let contentStart: Int
let length: Int
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index b01b8f9..1f03a0d 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -9,12 +9,20 @@ struct WatchlistView: View {
if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
Section("Refresh Progress") {
VStack(alignment: .leading, spacing: 8) {
- if viewModel.batchLookupRunning {
- ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
- .tint(.cyan)
+ ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
+ .tint(.cyan)
+ HStack {
Text(viewModel.batchProgressLabel)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
+ Spacer()
+ if viewModel.batchLookupRunning {
+ Button("Cancel") {
+ viewModel.cancelBatchLookup()
+ }
+ .buttonStyle(.bordered)
+ .font(.system(.caption2, design: .monospaced))
+ }
}
ForEach(viewModel.batchResults.prefix(5)) { result in
@@ -129,9 +137,10 @@ struct WatchlistView: View {
}
}
- Button("Refresh All") {
+ Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
viewModel.refreshAllTrackedDomains()
}
+ .disabled(viewModel.batchLookupRunning)
Button("Export TXT") {
shareTrackedDomains(asCSV: false)
@@ -151,9 +160,19 @@ struct WatchlistView: View {
.onChange(of: viewModel.rerunNavigationToken) { _, _ in
dismiss()
}
+ .sheet(item: batchSummaryBinding) { summary in
+ BatchSweepSummaryView(viewModel: viewModel, summary: summary)
+ }
.preferredColorScheme(.dark)
}
+ private var batchSummaryBinding: Binding<BatchSweepSummary?> {
+ Binding(
+ get: { viewModel.latestBatchSweepSummary },
+ set: { viewModel.latestBatchSweepSummary = $0 }
+ )
+ }
+
private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
domains.forEach(viewModel.deleteTrackedDomain)
@@ -197,13 +216,15 @@ struct WatchlistRowView: View {
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
+ indicatorRow
+
if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
Text(note)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let summary = trackedDomain.lastChangeSummary {
- Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
+ Text(summary.message)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -270,6 +291,43 @@ struct WatchlistRowView: View {
return Color(.systemGray5).opacity(0.6)
}
}
+
+ @ViewBuilder
+ private var indicatorRow: some View {
+ HStack(spacing: 8) {
+ if let severity = trackedDomain.lastChangeSeverity, severity >= .medium {
+ Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(severity == .high ? .red : .yellow)
+ } else {
+ Label("Stable", systemImage: "circle.fill")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ if trackedDomain.certificateWarningLevel != .none {
+ Text(certificateLabel)
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16))
+ .clipShape(Capsule())
+ }
+ }
+ }
+
+ private var certificateLabel: String {
+ let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
+ switch trackedDomain.certificateWarningLevel {
+ case .critical:
+ return "Cert \(days)"
+ case .warning:
+ return "Warn \(days)"
+ case .none:
+ return ""
+ }
+ }
}
struct TrackedDomainDetailView: View {