diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 11 | ||||
| -rw-r--r-- | DomainDig/BatchSweepSummaryView.swift | 68 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 115 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 337 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 4 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 658 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 89 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 111 | ||||
| -rw-r--r-- | DomainDig/SSLCheckService.swift | 73 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 68 |
10 files changed, 1304 insertions, 230 deletions
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 { |
