Commit 1f58092dcb
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +6 −6
| @@ -267,7 +267,7 @@ | ||
| 267 | 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | 269 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 14; | |
| 270 | CURRENT_PROJECT_VERSION = 15; | |
| 271 | 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | 272 | ENABLE_PREVIEWS = YES; |
| 273 | 273 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -279,12 +279,12 @@ | ||
| 279 | 279 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 280 | 280 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 281 | 281 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 282 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; | |
| 282 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 283 | 283 | LD_RUNPATH_SEARCH_PATHS = ( |
| 284 | 284 | "$(inherited)", |
| 285 | 285 | "@executable_path/Frameworks", |
| 286 | 286 | ); |
| 287 | MARKETING_VERSION = 2.1.0; | |
| 287 | MARKETING_VERSION = 2.2.0; | |
| 288 | 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | 289 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +303,7 @@ | ||
| 303 | 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | 305 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 14; | |
| 306 | CURRENT_PROJECT_VERSION = 15; | |
| 307 | 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | 308 | ENABLE_PREVIEWS = YES; |
| 309 | 309 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -315,12 +315,12 @@ | ||
| 315 | 315 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 316 | 316 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 317 | 317 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 318 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; | |
| 318 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 319 | 319 | LD_RUNPATH_SEARCH_PATHS = ( |
| 320 | 320 | "$(inherited)", |
| 321 | 321 | "@executable_path/Frameworks", |
| 322 | 322 | ); |
| 323 | MARKETING_VERSION = 2.1.0; | |
| 323 | MARKETING_VERSION = 2.2.0; | |
| 324 | 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | 325 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/BatchResultsView.swift +10 −1
| @@ -75,6 +75,12 @@ struct BatchResultRowView: View { | ||
| 75 | 75 | .font(.system(.caption2, design: .monospaced)) |
| 76 | 76 | .foregroundStyle(.secondary) |
| 77 | 77 | |
| 78 | if let summaryMessage = result.summaryMessage { | |
| 79 | Text(summaryMessage) | |
| 80 | .font(.system(.caption2, design: .monospaced)) | |
| 81 | .foregroundStyle(.secondary) | |
| 82 | } | |
| 83 | ||
| 78 | 84 | if let errorMessage = result.errorMessage { |
| 79 | 85 | Text(errorMessage) |
| 80 | 86 | .font(.system(.caption2, design: .monospaced)) |
| @@ -103,7 +109,10 @@ struct BatchResultRowView: View { | ||
| 103 | 109 | case .running: |
| 104 | 110 | return .cyan |
| 105 | 111 | case .completed: |
| 106 | if result.quickStatus == "Changed" { | |
| 112 | if result.changeSeverity == .high || result.certificateWarningLevel == .critical { | |
| 113 | return .red | |
| 114 | } | |
| 115 | if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { | |
| 107 | 116 | return .yellow |
| 108 | 117 | } |
| 109 | 118 | return .green |
DomainDig/BatchSweepSummaryView.swift added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct BatchSweepSummaryView: View { | |
| 4 | @Bindable var viewModel: DomainViewModel | |
| 5 | let summary: BatchSweepSummary | |
| 6 | ||
| 7 | @State private var showUnchangedDomains = false | |
| 8 | ||
| 9 | private var visibleResults: [BatchLookupResult] { | |
| 10 | if showUnchangedDomains { | |
| 11 | return summary.results | |
| 12 | } | |
| 13 | ||
| 14 | return summary.results.filter { | |
| 15 | ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | var body: some View { | |
| 20 | NavigationStack { | |
| 21 | List { | |
| 22 | Section("Overview") { | |
| 23 | statRow(label: "Checked", value: "\(summary.totalDomains)") | |
| 24 | statRow(label: "Changed", value: "\(summary.changedDomains)") | |
| 25 | statRow(label: "Warnings", value: "\(summary.warningDomains)") | |
| 26 | statRow(label: "Unchanged", value: "\(summary.unchangedDomains)") | |
| 27 | } | |
| 28 | ||
| 29 | Section { | |
| 30 | Toggle("Show unchanged domains", isOn: $showUnchangedDomains) | |
| 31 | } | |
| 32 | ||
| 33 | Section(visibleResults.isEmpty ? "Changed Domains" : "Results") { | |
| 34 | if visibleResults.isEmpty { | |
| 35 | Text("No domains with changes or warnings") | |
| 36 | .font(.system(.caption, design: .monospaced)) | |
| 37 | .foregroundStyle(.secondary) | |
| 38 | } else { | |
| 39 | ForEach(visibleResults) { result in | |
| 40 | if let entry = viewModel.historyEntry(for: result) { | |
| 41 | NavigationLink { | |
| 42 | HistoryDetailView(viewModel: viewModel, entry: entry) | |
| 43 | } label: { | |
| 44 | BatchResultRowView(result: result) | |
| 45 | } | |
| 46 | } else { | |
| 47 | BatchResultRowView(result: result) | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | .navigationTitle(summary.source == .watchlistRefresh ? "Sweep Summary" : "Batch Summary") | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | private func statRow(label: String, value: String) -> some View { | |
| 58 | HStack { | |
| 59 | Text(label) | |
| 60 | .font(.system(.caption, design: .monospaced)) | |
| 61 | .foregroundStyle(.secondary) | |
| 62 | Spacer() | |
| 63 | Text(value) | |
| 64 | .font(.system(.callout, design: .monospaced)) | |
| 65 | .foregroundStyle(.primary) | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
DomainDig/ContentView.swift +86 −29
| @@ -308,6 +308,13 @@ struct ContentView: View { | ||
| 308 | 308 | VStack(alignment: .leading, spacing: 12) { |
| 309 | 309 | HStack { |
| 310 | 310 | Spacer() |
| 311 | if viewModel.batchLookupRunning { | |
| 312 | Button("Cancel") { | |
| 313 | viewModel.cancelBatchLookup() | |
| 314 | } | |
| 315 | .buttonStyle(.bordered) | |
| 316 | .font(.system(.caption, design: .monospaced)) | |
| 317 | } | |
| 311 | 318 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 312 | 319 | Menu { |
| 313 | 320 | Button("Export Batch TXT") { |
| @@ -455,45 +462,82 @@ struct DomainChangeSummaryView: View { | ||
| 455 | 462 | var body: some View { |
| 456 | 463 | CardView(allowsHorizontalScroll: false) { |
| 457 | 464 | HStack { |
| 458 | Label(summary.hasChanges ? "Changed" : "Unchanged", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") | |
| 465 | Label(summary.hasChanges ? "Changed" : "Stable", systemImage: summary.hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle") | |
| 459 | 466 | .font(.system(.caption, design: .monospaced)) |
| 460 | .foregroundStyle(summary.hasChanges ? .yellow : .green) | |
| 467 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) | |
| 461 | 468 | Spacer() |
| 469 | Text(summary.severity.title.uppercased()) | |
| 470 | .font(.system(.caption2, design: .monospaced)) | |
| 471 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .secondary) | |
| 472 | .padding(.horizontal, 8) | |
| 473 | .padding(.vertical, 4) | |
| 474 | .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) | |
| 475 | .clipShape(Capsule()) | |
| 462 | 476 | Text(summary.generatedAt, style: .time) |
| 463 | 477 | .font(.system(.caption2, design: .monospaced)) |
| 464 | 478 | .foregroundStyle(.secondary) |
| 465 | 479 | } |
| 466 | 480 | |
| 467 | Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) | |
| 481 | Text(summary.message) | |
| 468 | 482 | .font(.system(.caption, design: .monospaced)) |
| 469 | 483 | .foregroundStyle(.primary) |
| 470 | 484 | } |
| 471 | 485 | } |
| 486 | ||
| 487 | private func severityColor(_ severity: ChangeSeverity) -> Color { | |
| 488 | switch severity { | |
| 489 | case .low: | |
| 490 | return .secondary | |
| 491 | case .medium: | |
| 492 | return .yellow | |
| 493 | case .high: | |
| 494 | return .red | |
| 495 | } | |
| 496 | } | |
| 472 | 497 | } |
| 473 | 498 | |
| 474 | 499 | struct DomainDiffView: View { |
| 475 | 500 | let title: String |
| 476 | 501 | let sections: [DomainDiffSection] |
| 477 | 502 | let showsUnchanged: Bool |
| 503 | ||
| 478 | 504 | @State private var collapsedSections = Set<UUID>() |
| 505 | @State private var showsLowSeverity = false | |
| 479 | 506 | |
| 480 | 507 | private var filteredSections: [DomainDiffSection] { |
| 481 | guard showsUnchanged else { | |
| 482 | return sections | |
| 483 | .map { section in | |
| 484 | DomainDiffSection( | |
| 485 | title: section.title, | |
| 486 | items: section.items.filter(\.hasChanges) | |
| 487 | ) | |
| 508 | sections | |
| 509 | .map { section in | |
| 510 | let items = section.items.filter { item in | |
| 511 | if !showsUnchanged, !item.hasChanges { | |
| 512 | return false | |
| 513 | } | |
| 514 | if showsLowSeverity { | |
| 515 | return true | |
| 516 | } | |
| 517 | return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged) | |
| 488 | 518 | } |
| 489 | .filter { !$0.items.isEmpty } | |
| 490 | } | |
| 491 | return sections | |
| 519 | return DomainDiffSection(title: section.title, items: items) | |
| 520 | } | |
| 521 | .filter { !$0.items.isEmpty } | |
| 522 | } | |
| 523 | ||
| 524 | private var hasLowSeverityChanges: Bool { | |
| 525 | sections.flatMap(\.items).contains { $0.hasChanges && $0.severity == .low } | |
| 492 | 526 | } |
| 493 | 527 | |
| 494 | 528 | var body: some View { |
| 495 | 529 | VStack(alignment: .leading, spacing: 12) { |
| 496 | SectionTitleView(title: title) | |
| 530 | HStack { | |
| 531 | SectionTitleView(title: title) | |
| 532 | Spacer() | |
| 533 | if hasLowSeverityChanges { | |
| 534 | Button(showsLowSeverity ? "Hide Low" : "Show Low") { | |
| 535 | showsLowSeverity.toggle() | |
| 536 | } | |
| 537 | .buttonStyle(.bordered) | |
| 538 | .font(.system(.caption, design: .monospaced)) | |
| 539 | } | |
| 540 | } | |
| 497 | 541 | if filteredSections.isEmpty { |
| 498 | 542 | MessageCardView(text: "No comparison data available", isError: false) |
| 499 | 543 | } else { |
| @@ -509,12 +553,12 @@ struct DomainDiffView: View { | ||
| 509 | 553 | .font(.system(.caption, design: .monospaced)) |
| 510 | 554 | .foregroundStyle(.secondary) |
| 511 | 555 | Spacer() |
| 512 | Text(changeLabel(for: item.changeType)) | |
| 556 | Text("\(item.severity.title) • \(changeLabel(for: item.changeType))") | |
| 513 | 557 | .font(.system(.caption2, design: .monospaced)) |
| 514 | .foregroundStyle(changeColor(for: item.changeType)) | |
| 558 | .foregroundStyle(changeColor(for: item)) | |
| 515 | 559 | .padding(.horizontal, 8) |
| 516 | 560 | .padding(.vertical, 4) |
| 517 | .background(changeColor(for: item.changeType).opacity(0.16)) | |
| 561 | .background(changeColor(for: item).opacity(0.16)) | |
| 518 | 562 | .clipShape(Capsule()) |
| 519 | 563 | } |
| 520 | 564 | |
| @@ -543,7 +587,7 @@ struct DomainDiffView: View { | ||
| 543 | 587 | } |
| 544 | 588 | } |
| 545 | 589 | .padding(10) |
| 546 | .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25)) | |
| 590 | .background(item.hasChanges ? changeColor(for: item).opacity(0.08) : Color(.systemGray6).opacity(0.25)) | |
| 547 | 591 | .cornerRadius(8) |
| 548 | 592 | } |
| 549 | 593 | } label: { |
| @@ -551,11 +595,11 @@ struct DomainDiffView: View { | ||
| 551 | 595 | Text(section.title) |
| 552 | 596 | .font(.system(.subheadline, design: .monospaced)) |
| 553 | 597 | .fontWeight(.semibold) |
| 554 | .foregroundStyle(.cyan) | |
| 598 | .foregroundStyle(sectionColor(section)) | |
| 555 | 599 | Spacer() |
| 556 | Text(section.hasChanges ? "Changed" : "Unchanged") | |
| 600 | Text(section.severity.title) | |
| 557 | 601 | .font(.system(.caption2, design: .monospaced)) |
| 558 | .foregroundStyle(section.hasChanges ? .yellow : .secondary) | |
| 602 | .foregroundStyle(sectionColor(section)) | |
| 559 | 603 | } |
| 560 | 604 | } |
| 561 | 605 | } |
| @@ -577,16 +621,29 @@ struct DomainDiffView: View { | ||
| 577 | 621 | } |
| 578 | 622 | } |
| 579 | 623 | |
| 580 | private func changeColor(for changeType: DiffChangeType) -> Color { | |
| 581 | switch changeType { | |
| 582 | case .added: | |
| 583 | return .green | |
| 584 | case .removed: | |
| 624 | private func changeColor(for item: DomainDiffItem) -> Color { | |
| 625 | if item.changeType == .unchanged { | |
| 626 | return .secondary | |
| 627 | } | |
| 628 | ||
| 629 | switch item.severity { | |
| 630 | case .low: | |
| 631 | return .blue | |
| 632 | case .medium: | |
| 633 | return .yellow | |
| 634 | case .high: | |
| 585 | 635 | return .red |
| 586 | case .changed: | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | private func sectionColor(_ section: DomainDiffSection) -> Color { | |
| 640 | switch section.severity { | |
| 641 | case .low: | |
| 642 | return .blue | |
| 643 | case .medium: | |
| 587 | 644 | return .yellow |
| 588 | case .unchanged: | |
| 589 | return .secondary | |
| 645 | case .high: | |
| 646 | return .red | |
| 590 | 647 | } |
| 591 | 648 | } |
| 592 | 649 | |
DomainDig/DomainDiffService.swift +276 −61
| @@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable { | ||
| 13 | 13 | let changeType: DiffChangeType |
| 14 | 14 | let oldValue: String? |
| 15 | 15 | let newValue: String? |
| 16 | let severity: ChangeSeverity | |
| 16 | 17 | |
| 17 | 18 | var hasChanges: Bool { |
| 18 | 19 | changeType != .unchanged |
| 19 | 20 | } |
| 21 | ||
| 22 | var isMeaningful: Bool { | |
| 23 | hasChanges && severity >= .medium | |
| 24 | } | |
| 20 | 25 | } |
| 21 | 26 | |
| 22 | 27 | struct DomainDiffSection: Identifiable, Equatable { |
| @@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable { | ||
| 27 | 32 | var hasChanges: Bool { |
| 28 | 33 | items.contains(where: \.hasChanges) |
| 29 | 34 | } |
| 35 | ||
| 36 | var severity: ChangeSeverity { | |
| 37 | items.map(\.severity).max() ?? .low | |
| 38 | } | |
| 30 | 39 | } |
| 31 | 40 | |
| 32 | 41 | enum DomainDiffService { |
| 33 | 42 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { |
| 34 | 43 | [ |
| 35 | section(title: "Availability", item: compare( | |
| 36 | label: "Status", | |
| 37 | oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), | |
| 38 | newValue: availabilityLabel(newSnapshot.availabilityResult?.status) | |
| 39 | )), | |
| 40 | section(title: "Primary IP", item: compare( | |
| 41 | label: "Address", | |
| 42 | oldValue: primaryIP(from: oldSnapshot), | |
| 43 | newValue: primaryIP(from: newSnapshot) | |
| 44 | )), | |
| 44 | availabilitySection(from: oldSnapshot, to: newSnapshot), | |
| 45 | primaryIPSection(from: oldSnapshot, to: newSnapshot), | |
| 45 | 46 | dnsSection(from: oldSnapshot, to: newSnapshot), |
| 46 | section(title: "Redirect", item: compare( | |
| 47 | label: "Final Target", | |
| 48 | oldValue: finalRedirectURL(from: oldSnapshot), | |
| 49 | newValue: finalRedirectURL(from: newSnapshot) | |
| 50 | )), | |
| 47 | redirectSection(from: oldSnapshot, to: newSnapshot), | |
| 51 | 48 | tlsSection(from: oldSnapshot, to: newSnapshot), |
| 52 | 49 | httpSection(from: oldSnapshot, to: newSnapshot), |
| 53 | 50 | emailSection(from: oldSnapshot, to: newSnapshot) |
| @@ -55,62 +52,212 @@ enum DomainDiffService { | ||
| 55 | 52 | .filter { !$0.items.isEmpty } |
| 56 | 53 | } |
| 57 | 54 | |
| 58 | static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary { | |
| 59 | let changedSections = diff(from: oldSnapshot, to: newSnapshot) | |
| 60 | .filter { $0.items.contains(where: { $0.changeType != .unchanged }) } | |
| 61 | .map(\.title) | |
| 55 | static func summary( | |
| 56 | from oldSnapshot: LookupSnapshot, | |
| 57 | to newSnapshot: LookupSnapshot, | |
| 58 | generatedAt: Date = Date() | |
| 59 | ) -> DomainChangeSummary { | |
| 60 | let sections = diff(from: oldSnapshot, to: newSnapshot) | |
| 61 | let meaningfulItems = sections | |
| 62 | .flatMap(\.items) | |
| 63 | .filter(\.isMeaningful) | |
| 64 | let allChangedItems = sections | |
| 65 | .flatMap(\.items) | |
| 66 | .filter(\.hasChanges) | |
| 67 | ||
| 68 | let highlights = summaryHighlights(from: meaningfulItems) | |
| 69 | let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low) | |
| 70 | let message = summaryMessage(from: meaningfulItems, highlights: highlights) | |
| 62 | 71 | |
| 63 | 72 | return DomainChangeSummary( |
| 64 | hasChanges: !changedSections.isEmpty, | |
| 65 | changedSections: changedSections, | |
| 73 | hasChanges: !meaningfulItems.isEmpty, | |
| 74 | changedSections: highlights, | |
| 75 | message: message, | |
| 76 | severity: severity, | |
| 66 | 77 | generatedAt: generatedAt |
| 67 | 78 | ) |
| 68 | 79 | } |
| 69 | 80 | |
| 70 | private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection { | |
| 71 | DomainDiffSection(title: title, items: item.map { [$0] } ?? []) | |
| 81 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { | |
| 82 | guard let days = snapshot.sslInfo?.daysUntilExpiry else { | |
| 83 | return .none | |
| 84 | } | |
| 85 | if days < 14 { | |
| 86 | return .critical | |
| 87 | } | |
| 88 | if days < 30 { | |
| 89 | return .warning | |
| 90 | } | |
| 91 | return .none | |
| 92 | } | |
| 93 | ||
| 94 | private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 95 | DomainDiffSection( | |
| 96 | title: "Availability", | |
| 97 | items: [ | |
| 98 | compare( | |
| 99 | label: "Availability", | |
| 100 | oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), | |
| 101 | newValue: availabilityLabel(newSnapshot.availabilityResult?.status), | |
| 102 | severity: .high | |
| 103 | ) | |
| 104 | ].compactMap { $0 } | |
| 105 | ) | |
| 106 | } | |
| 107 | ||
| 108 | private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 109 | DomainDiffSection( | |
| 110 | title: "Primary IP", | |
| 111 | items: [ | |
| 112 | compare( | |
| 113 | label: "Primary IP", | |
| 114 | oldValue: primaryIP(from: oldSnapshot), | |
| 115 | newValue: primaryIP(from: newSnapshot), | |
| 116 | severity: .high | |
| 117 | ) | |
| 118 | ].compactMap { $0 } | |
| 119 | ) | |
| 72 | 120 | } |
| 73 | 121 | |
| 74 | 122 | private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 75 | let oldValue = normalizedDNSSummary(from: oldSnapshot) | |
| 76 | let newValue = normalizedDNSSummary(from: newSnapshot) | |
| 77 | return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue)) | |
| 123 | let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) }) | |
| 124 | let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) }) | |
| 125 | let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } | |
| 126 | ||
| 127 | var items: [DomainDiffItem] = [] | |
| 128 | for type in types { | |
| 129 | let oldSection = oldSections[type] | |
| 130 | let newSection = newSections[type] | |
| 131 | ||
| 132 | if let recordChange = compare( | |
| 133 | label: "\(type.rawValue) Records", | |
| 134 | oldValue: normalizedRecordValues(for: oldSection), | |
| 135 | newValue: normalizedRecordValues(for: newSection), | |
| 136 | severity: .medium | |
| 137 | ) { | |
| 138 | items.append(recordChange) | |
| 139 | } | |
| 140 | ||
| 141 | if let ttlChange = compare( | |
| 142 | label: "\(type.rawValue) TTL", | |
| 143 | oldValue: normalizedTTLValues(for: oldSection), | |
| 144 | newValue: normalizedTTLValues(for: newSection), | |
| 145 | severity: .low | |
| 146 | ), let oldSection, let newSection, | |
| 147 | normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) { | |
| 148 | items.append(ttlChange) | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | return DomainDiffSection(title: "DNS", items: items) | |
| 153 | } | |
| 154 | ||
| 155 | private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | |
| 156 | DomainDiffSection( | |
| 157 | title: "Redirect", | |
| 158 | items: [ | |
| 159 | compare( | |
| 160 | label: "Redirect Target", | |
| 161 | oldValue: finalRedirectURL(from: oldSnapshot), | |
| 162 | newValue: finalRedirectURL(from: newSnapshot), | |
| 163 | severity: .high | |
| 164 | ) | |
| 165 | ].compactMap { $0 } | |
| 166 | ) | |
| 78 | 167 | } |
| 79 | 168 | |
| 80 | 169 | private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 81 | 170 | var items: [DomainDiffItem] = [] |
| 82 | if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) { | |
| 83 | items.append(item) | |
| 171 | ||
| 172 | if let issuerChange = compare( | |
| 173 | label: "TLS Issuer", | |
| 174 | oldValue: normalized(oldSnapshot.sslInfo?.issuer), | |
| 175 | newValue: normalized(newSnapshot.sslInfo?.issuer), | |
| 176 | severity: .medium | |
| 177 | ) { | |
| 178 | items.append(issuerChange) | |
| 179 | } | |
| 180 | ||
| 181 | if let expiryChange = compare( | |
| 182 | label: "TLS Expiration", | |
| 183 | oldValue: expirationLabel(oldSnapshot.sslInfo), | |
| 184 | newValue: expirationLabel(newSnapshot.sslInfo), | |
| 185 | severity: .medium | |
| 186 | ) { | |
| 187 | items.append(expiryChange) | |
| 84 | 188 | } |
| 85 | if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) { | |
| 86 | items.append(item) | |
| 189 | ||
| 190 | let oldWarning = certificateWarningLevel(for: oldSnapshot) | |
| 191 | let newWarning = certificateWarningLevel(for: newSnapshot) | |
| 192 | if oldWarning != newWarning, newWarning != .none { | |
| 193 | let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0 | |
| 194 | items.append( | |
| 195 | DomainDiffItem( | |
| 196 | label: "Certificate Warning", | |
| 197 | changeType: .changed, | |
| 198 | oldValue: oldWarning.title, | |
| 199 | newValue: "Certificate expires in \(days) days", | |
| 200 | severity: newWarning == .critical ? .high : .medium | |
| 201 | ) | |
| 202 | ) | |
| 87 | 203 | } |
| 88 | return DomainDiffSection(title: "TLS Certificate", items: items) | |
| 204 | ||
| 205 | return DomainDiffSection(title: "TLS", items: items) | |
| 89 | 206 | } |
| 90 | 207 | |
| 91 | 208 | private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 92 | 209 | var items: [DomainDiffItem] = [] |
| 93 | if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) { | |
| 94 | items.append(item) | |
| 210 | ||
| 211 | if let statusChange = compare( | |
| 212 | label: "HTTP Status", | |
| 213 | oldValue: httpStatusSummary(from: oldSnapshot), | |
| 214 | newValue: httpStatusSummary(from: newSnapshot), | |
| 215 | severity: .medium | |
| 216 | ) { | |
| 217 | items.append(statusChange) | |
| 95 | 218 | } |
| 96 | if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) { | |
| 97 | items.append(item) | |
| 219 | ||
| 220 | if let gradeChange = compare( | |
| 221 | label: "Security Grade", | |
| 222 | oldValue: normalized(oldSnapshot.httpSecurityGrade), | |
| 223 | newValue: normalized(newSnapshot.httpSecurityGrade), | |
| 224 | severity: .low | |
| 225 | ) { | |
| 226 | items.append(gradeChange) | |
| 227 | } | |
| 228 | ||
| 229 | if let headerChange = compare( | |
| 230 | label: "Headers", | |
| 231 | oldValue: normalizedHeaders(from: oldSnapshot), | |
| 232 | newValue: normalizedHeaders(from: newSnapshot), | |
| 233 | severity: .low | |
| 234 | ) { | |
| 235 | items.append(headerChange) | |
| 98 | 236 | } |
| 237 | ||
| 99 | 238 | return DomainDiffSection(title: "HTTP", items: items) |
| 100 | 239 | } |
| 101 | 240 | |
| 102 | 241 | private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 103 | section( | |
| 242 | DomainDiffSection( | |
| 104 | 243 | title: "Email Security", |
| 105 | item: compare( | |
| 106 | label: "Summary", | |
| 107 | oldValue: normalized(emailSummary(from: oldSnapshot)), | |
| 108 | newValue: normalized(emailSummary(from: newSnapshot)) | |
| 109 | ) | |
| 244 | items: [ | |
| 245 | compare( | |
| 246 | label: "Email Security", | |
| 247 | oldValue: normalized(emailSummary(from: oldSnapshot)), | |
| 248 | newValue: normalized(emailSummary(from: newSnapshot)), | |
| 249 | severity: .medium | |
| 250 | ) | |
| 251 | ].compactMap { $0 } | |
| 110 | 252 | ) |
| 111 | 253 | } |
| 112 | 254 | |
| 113 | private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { | |
| 255 | private static func compare( | |
| 256 | label: String, | |
| 257 | oldValue: String?, | |
| 258 | newValue: String?, | |
| 259 | severity: ChangeSeverity | |
| 260 | ) -> DomainDiffItem? { | |
| 114 | 261 | let oldValue = normalized(oldValue) |
| 115 | 262 | let newValue = normalized(newValue) |
| 116 | 263 | let normalizedOldValue = comparisonValue(oldValue) |
| @@ -132,7 +279,68 @@ enum DomainDiffService { | ||
| 132 | 279 | changeType = .changed |
| 133 | 280 | } |
| 134 | 281 | |
| 135 | return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue) | |
| 282 | return DomainDiffItem( | |
| 283 | label: label, | |
| 284 | changeType: changeType, | |
| 285 | oldValue: oldValue, | |
| 286 | newValue: newValue, | |
| 287 | severity: severity | |
| 288 | ) | |
| 289 | } | |
| 290 | ||
| 291 | private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] { | |
| 292 | var highlights: [String] = [] | |
| 293 | ||
| 294 | let labels = Set(items.map(\.label)) | |
| 295 | if labels.contains("Availability") { | |
| 296 | highlights.append("Availability changed") | |
| 297 | } | |
| 298 | if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) { | |
| 299 | highlights.append("IP changed") | |
| 300 | highlights.append("DNS changed") | |
| 301 | return highlights | |
| 302 | } | |
| 303 | if labels.contains("Primary IP") { | |
| 304 | highlights.append("IP changed") | |
| 305 | } | |
| 306 | if labels.contains("Redirect Target") { | |
| 307 | highlights.append("Redirect target changed") | |
| 308 | } | |
| 309 | if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), | |
| 310 | let message = certificateItem.newValue { | |
| 311 | highlights.append(message) | |
| 312 | } else if labels.contains("TLS Issuer") { | |
| 313 | highlights.append("TLS issuer changed") | |
| 314 | } else if labels.contains("TLS Expiration") { | |
| 315 | highlights.append("Certificate expiration changed") | |
| 316 | } | |
| 317 | if labels.contains(where: { $0.hasSuffix("Records") }) { | |
| 318 | highlights.append("DNS changed") | |
| 319 | } | |
| 320 | if labels.contains("HTTP Status") { | |
| 321 | highlights.append("HTTP status changed") | |
| 322 | } | |
| 323 | if labels.contains("Email Security") { | |
| 324 | highlights.append("Email security changed") | |
| 325 | } | |
| 326 | ||
| 327 | var deduplicated: [String] = [] | |
| 328 | for highlight in highlights where !deduplicated.contains(highlight) { | |
| 329 | deduplicated.append(highlight) | |
| 330 | } | |
| 331 | return deduplicated | |
| 332 | } | |
| 333 | ||
| 334 | private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String { | |
| 335 | guard !items.isEmpty, !highlights.isEmpty else { | |
| 336 | return "No meaningful changes" | |
| 337 | } | |
| 338 | ||
| 339 | if highlights.count == 1 { | |
| 340 | return highlights[0] | |
| 341 | } | |
| 342 | ||
| 343 | return "\(highlights[0]) and \(highlights[1].lowercased())" | |
| 136 | 344 | } |
| 137 | 345 | |
| 138 | 346 | private static func normalized(_ value: String?) -> String? { |
| @@ -167,11 +375,9 @@ enum DomainDiffService { | ||
| 167 | 375 | snapshot.redirectChain.last?.url |
| 168 | 376 | } |
| 169 | 377 | |
| 170 | private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { | |
| 171 | if let sslInfo = snapshot.sslInfo { | |
| 172 | return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))" | |
| 173 | } | |
| 174 | return snapshot.sslError | |
| 378 | private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? { | |
| 379 | guard let sslInfo else { return nil } | |
| 380 | return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" | |
| 175 | 381 | } |
| 176 | 382 | |
| 177 | 383 | private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { |
| @@ -194,18 +400,27 @@ enum DomainDiffService { | ||
| 194 | 400 | return snapshot.emailSecurityError |
| 195 | 401 | } |
| 196 | 402 | |
| 197 | private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? { | |
| 198 | let parts = snapshot.dnsSections | |
| 199 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue } | |
| 200 | .map { section in | |
| 201 | let values = (section.records + section.wildcardRecords) | |
| 202 | .map(\.value) | |
| 203 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | |
| 204 | .sorted() | |
| 205 | .joined(separator: ",") | |
| 206 | return "\(section.recordType.rawValue):\(values)" | |
| 207 | } | |
| 208 | .filter { !$0.hasSuffix(":") } | |
| 209 | return parts.isEmpty ? nil : parts.joined(separator: "|") | |
| 403 | private static func normalizedRecordValues(for section: DNSSection?) -> String? { | |
| 404 | guard let section else { return nil } | |
| 405 | let values = (section.records + section.wildcardRecords) | |
| 406 | .map(\.value) | |
| 407 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | |
| 408 | .sorted() | |
| 409 | return values.isEmpty ? nil : values.joined(separator: ",") | |
| 410 | } | |
| 411 | ||
| 412 | private static func normalizedTTLValues(for section: DNSSection?) -> String? { | |
| 413 | guard let section else { return nil } | |
| 414 | let values = (section.records + section.wildcardRecords) | |
| 415 | .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" } | |
| 416 | .sorted() | |
| 417 | return values.isEmpty ? nil : values.joined(separator: ",") | |
| 418 | } | |
| 419 | ||
| 420 | private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? { | |
| 421 | let headers = snapshot.httpHeaders | |
| 422 | .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } | |
| 423 | .sorted() | |
| 424 | return headers.isEmpty ? nil : headers.joined(separator: "|") | |
| 210 | 425 | } |
| 211 | 426 | } |
DomainDig/DomainDigApp.swift +4
| @@ -9,6 +9,10 @@ import SwiftUI | ||
| 9 | 9 | |
| 10 | 10 | @main |
| 11 | 11 | struct DomainDigApp: App { |
| 12 | init() { | |
| 13 | LocalNotificationService.shared.configureForegroundPresentation() | |
| 14 | } | |
| 15 | ||
| 12 | 16 | var body: some Scene { |
| 13 | 17 | WindowGroup { |
| 14 | 18 | ContentView() |
DomainDig/DomainViewModel.swift +538 −120
| @@ -159,6 +159,10 @@ extension HistoryEntry { | ||
| 159 | 159 | } |
| 160 | 160 | } |
| 161 | 161 | |
| 162 | private struct BatchLookupPayload { | |
| 163 | let snapshot: LookupSnapshot | |
| 164 | } | |
| 165 | ||
| 162 | 166 | @MainActor |
| 163 | 167 | @Observable |
| 164 | 168 | final class DomainViewModel { |
| @@ -228,11 +232,16 @@ final class DomainViewModel { | ||
| 228 | 232 | private(set) var batchCompletedCount = 0 |
| 229 | 233 | private(set) var batchTotalCount = 0 |
| 230 | 234 | private(set) var batchLookupRunning = false |
| 235 | var latestBatchSweepSummary: BatchSweepSummary? | |
| 236 | private(set) var notificationsAuthorized = false | |
| 231 | 237 | |
| 232 | 238 | private var lookupTask: Task<Void, Never>? |
| 233 | 239 | private var customPortScanTask: Task<Void, Never>? |
| 240 | private var batchTask: Task<Void, Never>? | |
| 234 | 241 | private var activeLookupID = UUID() |
| 235 | 242 | private var lookupStartedAt: Date? |
| 243 | private var activeBatchDomains: [String] = [] | |
| 244 | private var lastBatchStartedAt: Date? | |
| 236 | 245 | |
| 237 | 246 | private static let recentSearchesKey = "recentSearches" |
| 238 | 247 | private static let maxRecent = 20 |
| @@ -355,8 +364,8 @@ final class DomainViewModel { | ||
| 355 | 364 | |
| 356 | 365 | var batchProgressLabel: String { |
| 357 | 366 | guard batchTotalCount > 0 else { return "No active batch" } |
| 358 | let domainLabel = batchCurrentDomain ?? "Preparing" | |
| 359 | return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)" | |
| 367 | let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing" | |
| 368 | return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)" | |
| 360 | 369 | } |
| 361 | 370 | |
| 362 | 371 | var currentBatchResultEntries: [HistoryEntry] { |
| @@ -550,6 +559,10 @@ final class DomainViewModel { | ||
| 550 | 559 | func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { |
| 551 | 560 | refreshingTrackedDomainID = trackedDomain.id |
| 552 | 561 | domain = trackedDomain.domain |
| 562 | Task { [weak self] in | |
| 563 | guard let self else { return } | |
| 564 | self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded() | |
| 565 | } | |
| 553 | 566 | run() |
| 554 | 567 | } |
| 555 | 568 | |
| @@ -624,6 +637,7 @@ final class DomainViewModel { | ||
| 624 | 637 | func reset() { |
| 625 | 638 | lookupTask?.cancel() |
| 626 | 639 | customPortScanTask?.cancel() |
| 640 | batchTask?.cancel() | |
| 627 | 641 | hasRun = false |
| 628 | 642 | searchedDomain = "" |
| 629 | 643 | lastLookupDurationMs = nil |
| @@ -649,59 +663,36 @@ final class DomainViewModel { | ||
| 649 | 663 | func runBulkLookup() { |
| 650 | 664 | let domains = parsedDomains(from: bulkInput) |
| 651 | 665 | guard !domains.isEmpty else { return } |
| 652 | ||
| 653 | clearBatchState() | |
| 654 | batchLookupSource = .manual | |
| 655 | batchTotalCount = domains.count | |
| 656 | batchLookupRunning = true | |
| 657 | batchResults = domains.map { | |
| 658 | BatchLookupResult( | |
| 659 | domain: $0, | |
| 660 | historyEntryID: nil, | |
| 661 | availability: nil, | |
| 662 | primaryIP: nil, | |
| 663 | quickStatus: "Pending", | |
| 664 | timestamp: Date(), | |
| 665 | status: .pending | |
| 666 | ) | |
| 667 | } | |
| 668 | ||
| 669 | lookupTask?.cancel() | |
| 670 | customPortScanTask?.cancel() | |
| 671 | ||
| 672 | lookupTask = Task { [weak self] in | |
| 673 | guard let self else { return } | |
| 674 | await self.runBatchLookup(domains: domains, source: .manual) | |
| 675 | } | |
| 666 | startBatchLookup(domains: domains, source: .manual) | |
| 676 | 667 | } |
| 677 | 668 | |
| 678 | 669 | func refreshAllTrackedDomains() { |
| 679 | let domains = sortedTrackedDomains.map(\.domain) | |
| 680 | guard !domains.isEmpty else { return } | |
| 670 | startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) | |
| 671 | } | |
| 681 | 672 | |
| 682 | clearBatchState() | |
| 683 | batchLookupSource = .watchlistRefresh | |
| 684 | batchTotalCount = domains.count | |
| 685 | batchLookupRunning = true | |
| 686 | batchResults = domains.map { | |
| 687 | BatchLookupResult( | |
| 688 | domain: $0, | |
| 689 | historyEntryID: nil, | |
| 690 | availability: nil, | |
| 691 | primaryIP: nil, | |
| 692 | quickStatus: "Pending", | |
| 673 | func cancelBatchLookup() { | |
| 674 | batchTask?.cancel() | |
| 675 | batchLookupRunning = false | |
| 676 | batchCurrentDomain = nil | |
| 677 | activeBatchDomains = [] | |
| 678 | refreshingTrackedDomainID = nil | |
| 679 | ||
| 680 | for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running { | |
| 681 | batchResults[index] = BatchLookupResult( | |
| 682 | id: batchResults[index].id, | |
| 683 | domain: batchResults[index].domain, | |
| 684 | historyEntryID: batchResults[index].historyEntryID, | |
| 685 | availability: batchResults[index].availability, | |
| 686 | primaryIP: batchResults[index].primaryIP, | |
| 687 | quickStatus: "Cancelled", | |
| 688 | summaryMessage: batchResults[index].summaryMessage, | |
| 689 | changeSeverity: batchResults[index].changeSeverity, | |
| 690 | certificateWarningLevel: batchResults[index].certificateWarningLevel, | |
| 693 | 691 | timestamp: Date(), |
| 694 | status: .pending | |
| 692 | status: .failed, | |
| 693 | errorMessage: "Lookup cancelled" | |
| 695 | 694 | ) |
| 696 | 695 | } |
| 697 | ||
| 698 | lookupTask?.cancel() | |
| 699 | customPortScanTask?.cancel() | |
| 700 | ||
| 701 | lookupTask = Task { [weak self] in | |
| 702 | guard let self else { return } | |
| 703 | await self.runBatchLookup(domains: domains, source: .watchlistRefresh) | |
| 704 | } | |
| 705 | 696 | } |
| 706 | 697 | |
| 707 | 698 | func runCustomPortScan(ports: [UInt16]) async { |
| @@ -1057,7 +1048,215 @@ final class DomainViewModel { | ||
| 1057 | 1048 | customPortScanLoading = false |
| 1058 | 1049 | } |
| 1059 | 1050 | |
| 1060 | private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] { | |
| 1051 | private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { | |
| 1052 | guard !Task.isCancelled else { return nil } | |
| 1053 | ||
| 1054 | let startedAt = Date() | |
| 1055 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() | |
| 1056 | let resolverURLString = DNSLookupService.currentResolverURLString() | |
| 1057 | ||
| 1058 | async let dnsResult = DNSLookupService.lookupAll(domain: domain) | |
| 1059 | async let availabilityResult = DomainAvailabilityService.check(domain: domain) | |
| 1060 | async let sslResult = SSLCheckService.check(domain: domain) | |
| 1061 | async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) | |
| 1062 | async let httpResult = HTTPHeadersService.fetch(domain: domain) | |
| 1063 | async let reachabilityResult = ReachabilityService.checkAll(domain: domain) | |
| 1064 | async let redirectResult = RedirectChainService.trace(domain: domain) | |
| 1065 | async let portScanResult = PortScanService.scanAll(domain: domain) | |
| 1066 | ||
| 1067 | let resolvedDNS = await dnsResult | |
| 1068 | let availability = await availabilityResult | |
| 1069 | let resolvedSSL = await sslResult | |
| 1070 | let hsts = await hstsResult | |
| 1071 | let http = await httpResult | |
| 1072 | let reachability = await reachabilityResult | |
| 1073 | let redirects = await redirectResult | |
| 1074 | let ports = await portScanResult | |
| 1075 | ||
| 1076 | guard !Task.isCancelled else { return nil } | |
| 1077 | ||
| 1078 | let dnsSections: [DNSSection] | |
| 1079 | let dnsError: String? | |
| 1080 | switch resolvedDNS { | |
| 1081 | case let .success(sections): | |
| 1082 | dnsSections = sections | |
| 1083 | dnsError = nil | |
| 1084 | case let .empty(message), let .error(message): | |
| 1085 | dnsSections = [] | |
| 1086 | dnsError = message | |
| 1087 | } | |
| 1088 | ||
| 1089 | let sslInfo: SSLCertificateInfo? | |
| 1090 | let sslError: String? | |
| 1091 | switch resolvedSSL { | |
| 1092 | case let .success(info): | |
| 1093 | sslInfo = info | |
| 1094 | sslError = nil | |
| 1095 | case let .empty(message), let .error(message): | |
| 1096 | sslInfo = nil | |
| 1097 | sslError = message | |
| 1098 | } | |
| 1099 | ||
| 1100 | let httpHeaders: [HTTPHeader] | |
| 1101 | let httpSecurityGrade: String? | |
| 1102 | let httpStatusCode: Int? | |
| 1103 | let httpResponseTimeMs: Int? | |
| 1104 | let httpProtocol: String? | |
| 1105 | let http3Advertised: Bool | |
| 1106 | let httpHeadersError: String? | |
| 1107 | switch http { | |
| 1108 | case let .success(result): | |
| 1109 | httpHeaders = result.headers | |
| 1110 | httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue | |
| 1111 | httpStatusCode = result.statusCode | |
| 1112 | httpResponseTimeMs = result.responseTimeMs | |
| 1113 | httpProtocol = result.httpProtocol | |
| 1114 | http3Advertised = result.http3Advertised | |
| 1115 | httpHeadersError = nil | |
| 1116 | case let .empty(message), let .error(message): | |
| 1117 | httpHeaders = [] | |
| 1118 | httpSecurityGrade = nil | |
| 1119 | httpStatusCode = nil | |
| 1120 | httpResponseTimeMs = nil | |
| 1121 | httpProtocol = nil | |
| 1122 | http3Advertised = false | |
| 1123 | httpHeadersError = message | |
| 1124 | } | |
| 1125 | ||
| 1126 | let reachabilityResults: [PortReachability] | |
| 1127 | let reachabilityError: String? | |
| 1128 | switch reachability { | |
| 1129 | case let .success(results): | |
| 1130 | reachabilityResults = results | |
| 1131 | reachabilityError = nil | |
| 1132 | case let .empty(message), let .error(message): | |
| 1133 | reachabilityResults = [] | |
| 1134 | reachabilityError = message | |
| 1135 | } | |
| 1136 | ||
| 1137 | let redirectChain: [RedirectHop] | |
| 1138 | let redirectChainError: String? | |
| 1139 | switch redirects { | |
| 1140 | case let .success(hops): | |
| 1141 | redirectChain = hops | |
| 1142 | redirectChainError = nil | |
| 1143 | case let .empty(message), let .error(message): | |
| 1144 | redirectChain = [] | |
| 1145 | redirectChainError = message | |
| 1146 | } | |
| 1147 | ||
| 1148 | let portScanResults: [PortScanResult] | |
| 1149 | let portScanError: String? | |
| 1150 | switch ports { | |
| 1151 | case let .success(results): | |
| 1152 | portScanResults = await enrichOpenPortBanners(results, domain: domain) | |
| 1153 | portScanError = nil | |
| 1154 | case let .empty(message), let .error(message): | |
| 1155 | portScanResults = [] | |
| 1156 | portScanError = message | |
| 1157 | } | |
| 1158 | ||
| 1159 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 1160 | let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 1161 | ||
| 1162 | async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) | |
| 1163 | async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : [] | |
| 1164 | ||
| 1165 | let resolvedEmail = await emailResult | |
| 1166 | let resolvedSuggestions = await suggestions | |
| 1167 | let resolvedPTR: ServiceResult<String>? | |
| 1168 | let resolvedGeo: ServiceResult<IPGeolocation>? | |
| 1169 | if let primaryIP { | |
| 1170 | resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) | |
| 1171 | resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP) | |
| 1172 | } else { | |
| 1173 | resolvedPTR = nil | |
| 1174 | resolvedGeo = nil | |
| 1175 | } | |
| 1176 | ||
| 1177 | guard !Task.isCancelled else { return nil } | |
| 1178 | ||
| 1179 | let emailSecurity: EmailSecurityResult? | |
| 1180 | let emailSecurityError: String? | |
| 1181 | switch resolvedEmail { | |
| 1182 | case let .success(result): | |
| 1183 | emailSecurity = result | |
| 1184 | emailSecurityError = nil | |
| 1185 | case let .empty(message), let .error(message): | |
| 1186 | emailSecurity = nil | |
| 1187 | emailSecurityError = message | |
| 1188 | } | |
| 1189 | ||
| 1190 | let ptrRecord: String? | |
| 1191 | let ptrError: String? | |
| 1192 | switch resolvedPTR { | |
| 1193 | case let .success(record): | |
| 1194 | ptrRecord = record | |
| 1195 | ptrError = nil | |
| 1196 | case let .empty(message), let .error(message): | |
| 1197 | ptrRecord = nil | |
| 1198 | ptrError = message | |
| 1199 | case .none: | |
| 1200 | ptrRecord = nil | |
| 1201 | ptrError = "No A record available" | |
| 1202 | } | |
| 1203 | ||
| 1204 | let ipGeolocation: IPGeolocation? | |
| 1205 | let ipGeolocationError: String? | |
| 1206 | switch resolvedGeo { | |
| 1207 | case let .success(result): | |
| 1208 | ipGeolocation = result | |
| 1209 | ipGeolocationError = nil | |
| 1210 | case let .empty(message), let .error(message): | |
| 1211 | ipGeolocation = nil | |
| 1212 | ipGeolocationError = message | |
| 1213 | case .none: | |
| 1214 | ipGeolocation = nil | |
| 1215 | ipGeolocationError = "No A record available" | |
| 1216 | } | |
| 1217 | ||
| 1218 | let snapshot = LookupSnapshot( | |
| 1219 | historyEntryID: nil, | |
| 1220 | domain: domain, | |
| 1221 | timestamp: Date(), | |
| 1222 | trackedDomainID: nil, | |
| 1223 | resolverDisplayName: resolverDisplayName, | |
| 1224 | resolverURLString: resolverURLString, | |
| 1225 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | |
| 1226 | dnsSections: dnsSections, | |
| 1227 | dnsError: dnsError, | |
| 1228 | availabilityResult: availability, | |
| 1229 | suggestions: resolvedSuggestions, | |
| 1230 | sslInfo: sslInfo, | |
| 1231 | sslError: sslError, | |
| 1232 | hstsPreloaded: hsts, | |
| 1233 | httpHeaders: httpHeaders, | |
| 1234 | httpSecurityGrade: httpSecurityGrade, | |
| 1235 | httpStatusCode: httpStatusCode, | |
| 1236 | httpResponseTimeMs: httpResponseTimeMs, | |
| 1237 | httpProtocol: httpProtocol, | |
| 1238 | http3Advertised: http3Advertised, | |
| 1239 | httpHeadersError: httpHeadersError, | |
| 1240 | reachabilityResults: reachabilityResults, | |
| 1241 | reachabilityError: reachabilityError, | |
| 1242 | ipGeolocation: ipGeolocation, | |
| 1243 | ipGeolocationError: ipGeolocationError, | |
| 1244 | emailSecurity: emailSecurity, | |
| 1245 | emailSecurityError: emailSecurityError, | |
| 1246 | ptrRecord: ptrRecord, | |
| 1247 | ptrError: ptrError, | |
| 1248 | redirectChain: redirectChain, | |
| 1249 | redirectChainError: redirectChainError, | |
| 1250 | portScanResults: portScanResults, | |
| 1251 | portScanError: portScanError, | |
| 1252 | changeSummary: nil, | |
| 1253 | isLive: false | |
| 1254 | ) | |
| 1255 | ||
| 1256 | return BatchLookupPayload(snapshot: snapshot) | |
| 1257 | } | |
| 1258 | ||
| 1259 | private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] { | |
| 1061 | 1260 | let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in |
| 1062 | 1261 | for result in results where result.open { |
| 1063 | 1262 | group.addTask { |
| @@ -1082,56 +1281,67 @@ final class DomainViewModel { | ||
| 1082 | 1281 | } |
| 1083 | 1282 | } |
| 1084 | 1283 | |
| 1284 | private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] { | |
| 1285 | await Self.enrichOpenPortBanners(results, domain: domain) | |
| 1286 | } | |
| 1287 | ||
| 1085 | 1288 | @discardableResult |
| 1086 | 1289 | private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { |
| 1087 | 1290 | guard !searchedDomain.isEmpty else { return nil } |
| 1291 | return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true) | |
| 1292 | } | |
| 1088 | 1293 | |
| 1089 | let trackedDomainID = trackedDomain(for: searchedDomain)?.id | |
| 1090 | let timestamp = Date() | |
| 1091 | let snapshot = currentSnapshot | |
| 1092 | let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | |
| 1093 | let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) } | |
| 1294 | @discardableResult | |
| 1295 | private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { | |
| 1296 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id | |
| 1297 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | |
| 1298 | let changeSummary = previousSnapshot.map { | |
| 1299 | DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) | |
| 1300 | } | |
| 1301 | let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] | |
| 1094 | 1302 | |
| 1095 | currentChangeSummary = changeSummary | |
| 1096 | currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] | |
| 1303 | if updateCurrentState { | |
| 1304 | currentChangeSummary = changeSummary | |
| 1305 | currentDiffSections = diffSections | |
| 1306 | } | |
| 1097 | 1307 | |
| 1098 | 1308 | let entry = HistoryEntry( |
| 1099 | domain: searchedDomain, | |
| 1100 | timestamp: timestamp, | |
| 1309 | domain: snapshot.domain, | |
| 1310 | timestamp: snapshot.timestamp, | |
| 1101 | 1311 | trackedDomainID: trackedDomainID, |
| 1102 | dnsSections: dnsSections, | |
| 1103 | sslInfo: sslInfo, | |
| 1104 | httpHeaders: httpHeaders, | |
| 1105 | reachabilityResults: reachabilityResults, | |
| 1106 | ipGeolocation: ipGeolocation, | |
| 1107 | emailSecurity: emailSecurity, | |
| 1108 | mtaSts: emailSecurity?.mtaSts, | |
| 1109 | ptrRecord: ptrRecord, | |
| 1110 | redirectChain: redirectChain, | |
| 1111 | portScanResults: allPortScanResults, | |
| 1112 | hstsPreloaded: hstsPreloaded, | |
| 1113 | availabilityResult: availabilityResult, | |
| 1114 | suggestions: suggestions, | |
| 1115 | resolverDisplayName: resolverDisplayName, | |
| 1116 | resolverURLString: resolverURLString, | |
| 1117 | totalLookupDurationMs: lastLookupDurationMs, | |
| 1312 | dnsSections: snapshot.dnsSections, | |
| 1313 | sslInfo: snapshot.sslInfo, | |
| 1314 | httpHeaders: snapshot.httpHeaders, | |
| 1315 | reachabilityResults: snapshot.reachabilityResults, | |
| 1316 | ipGeolocation: snapshot.ipGeolocation, | |
| 1317 | emailSecurity: snapshot.emailSecurity, | |
| 1318 | mtaSts: snapshot.emailSecurity?.mtaSts, | |
| 1319 | ptrRecord: snapshot.ptrRecord, | |
| 1320 | redirectChain: snapshot.redirectChain, | |
| 1321 | portScanResults: snapshot.portScanResults, | |
| 1322 | hstsPreloaded: snapshot.hstsPreloaded, | |
| 1323 | availabilityResult: snapshot.availabilityResult, | |
| 1324 | suggestions: snapshot.suggestions, | |
| 1325 | resolverDisplayName: snapshot.resolverDisplayName, | |
| 1326 | resolverURLString: snapshot.resolverURLString, | |
| 1327 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | |
| 1118 | 1328 | primaryIP: Self.primaryIPAddress(from: snapshot), |
| 1119 | 1329 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), |
| 1120 | 1330 | tlsStatusSummary: Self.httpsSummary(from: snapshot), |
| 1121 | 1331 | emailSecuritySummary: Self.emailSummary(from: snapshot), |
| 1122 | 1332 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, |
| 1123 | 1333 | changeSummary: changeSummary, |
| 1124 | sslError: sslError, | |
| 1125 | httpHeadersError: httpHeadersError, | |
| 1126 | reachabilityError: reachabilityError, | |
| 1127 | ipGeolocationError: ipGeolocationError, | |
| 1128 | emailSecurityError: emailSecurityError, | |
| 1129 | ptrError: ptrError, | |
| 1130 | redirectChainError: redirectChainError, | |
| 1131 | portScanError: combinedPortScanError | |
| 1334 | sslError: snapshot.sslError, | |
| 1335 | httpHeadersError: snapshot.httpHeadersError, | |
| 1336 | reachabilityError: snapshot.reachabilityError, | |
| 1337 | ipGeolocationError: snapshot.ipGeolocationError, | |
| 1338 | emailSecurityError: snapshot.emailSecurityError, | |
| 1339 | ptrError: snapshot.ptrError, | |
| 1340 | redirectChainError: snapshot.redirectChainError, | |
| 1341 | portScanError: snapshot.portScanError | |
| 1132 | 1342 | ) |
| 1133 | 1343 | |
| 1134 | if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame { | |
| 1344 | if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame { | |
| 1135 | 1345 | history[0] = entry |
| 1136 | 1346 | } else { |
| 1137 | 1347 | history.insert(entry, at: 0) |
| @@ -1141,13 +1351,17 @@ final class DomainViewModel { | ||
| 1141 | 1351 | } |
| 1142 | 1352 | |
| 1143 | 1353 | updateTrackedDomainSnapshotMetadata( |
| 1144 | domain: searchedDomain, | |
| 1354 | domain: snapshot.domain, | |
| 1145 | 1355 | snapshotID: entry.id, |
| 1146 | availabilityStatus: availabilityResult?.status, | |
| 1147 | updatedAt: timestamp, | |
| 1148 | changeSummary: changeSummary | |
| 1356 | availabilityStatus: snapshot.availabilityResult?.status, | |
| 1357 | updatedAt: snapshot.timestamp, | |
| 1358 | changeSummary: changeSummary, | |
| 1359 | changeSeverity: changeSummary?.severity, | |
| 1360 | certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), | |
| 1361 | certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry | |
| 1149 | 1362 | ) |
| 1150 | 1363 | persistHistory() |
| 1364 | notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot) | |
| 1151 | 1365 | return entry |
| 1152 | 1366 | } |
| 1153 | 1367 | |
| @@ -1176,7 +1390,10 @@ final class DomainViewModel { | ||
| 1176 | 1390 | snapshotID: UUID, |
| 1177 | 1391 | availabilityStatus: DomainAvailabilityStatus?, |
| 1178 | 1392 | updatedAt: Date, |
| 1179 | changeSummary: DomainChangeSummary? | |
| 1393 | changeSummary: DomainChangeSummary?, | |
| 1394 | changeSeverity: ChangeSeverity?, | |
| 1395 | certificateWarningLevel: CertificateWarningLevel, | |
| 1396 | certificateDaysRemaining: Int? | |
| 1180 | 1397 | ) { |
| 1181 | 1398 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { |
| 1182 | 1399 | return |
| @@ -1185,9 +1402,44 @@ final class DomainViewModel { | ||
| 1185 | 1402 | trackedDomains[index].lastKnownAvailability = availabilityStatus |
| 1186 | 1403 | trackedDomains[index].updatedAt = updatedAt |
| 1187 | 1404 | trackedDomains[index].lastChangeSummary = changeSummary |
| 1405 | trackedDomains[index].lastChangeSeverity = changeSeverity | |
| 1406 | trackedDomains[index].certificateWarningLevel = certificateWarningLevel | |
| 1407 | trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining | |
| 1188 | 1408 | persistTrackedDomains() |
| 1189 | 1409 | } |
| 1190 | 1410 | |
| 1411 | private func notifyIfNeeded(for entry: HistoryEntry, snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?) { | |
| 1412 | guard notificationsAuthorized, entry.trackedDomainID != nil else { return } | |
| 1413 | ||
| 1414 | Task { | |
| 1415 | if let summary = entry.changeSummary, summary.hasChanges { | |
| 1416 | await LocalNotificationService.shared.notifyDomainEvent( | |
| 1417 | domain: entry.domain, | |
| 1418 | message: summary.message, | |
| 1419 | severity: summary.severity | |
| 1420 | ) | |
| 1421 | } | |
| 1422 | ||
| 1423 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot) | |
| 1424 | if certificateWarningLevel == .critical, let daysRemaining = snapshot.sslInfo?.daysUntilExpiry { | |
| 1425 | await LocalNotificationService.shared.notifyCertificateWarning( | |
| 1426 | domain: entry.domain, | |
| 1427 | daysRemaining: daysRemaining | |
| 1428 | ) | |
| 1429 | } | |
| 1430 | ||
| 1431 | if let previousStatus = previousSnapshot?.availabilityResult?.status, | |
| 1432 | let newStatus = snapshot.availabilityResult?.status, | |
| 1433 | previousStatus != newStatus { | |
| 1434 | await LocalNotificationService.shared.notifyDomainEvent( | |
| 1435 | domain: entry.domain, | |
| 1436 | message: "Availability changed", | |
| 1437 | severity: .high | |
| 1438 | ) | |
| 1439 | } | |
| 1440 | } | |
| 1441 | } | |
| 1442 | ||
| 1191 | 1443 | private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? { |
| 1192 | 1444 | let matchingEntries = history.filter { entry in |
| 1193 | 1445 | if let trackedDomainID { |
| @@ -1235,7 +1487,10 @@ final class DomainViewModel { | ||
| 1235 | 1487 | let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { |
| 1236 | 1488 | trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id |
| 1237 | 1489 | trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary |
| 1490 | trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity | |
| 1238 | 1491 | trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status |
| 1492 | trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot) | |
| 1493 | trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry | |
| 1239 | 1494 | trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp |
| 1240 | 1495 | persistTrackedDomains() |
| 1241 | 1496 | } |
| @@ -1271,6 +1526,43 @@ final class DomainViewModel { | ||
| 1271 | 1526 | return lookupID |
| 1272 | 1527 | } |
| 1273 | 1528 | |
| 1529 | private func startBatchLookup(domains: [String], source: BatchLookupSource) { | |
| 1530 | guard !domains.isEmpty else { return } | |
| 1531 | guard !batchLookupRunning else { return } | |
| 1532 | ||
| 1533 | let now = Date() | |
| 1534 | if let lastBatchStartedAt, now.timeIntervalSince(lastBatchStartedAt) < 1 { | |
| 1535 | return | |
| 1536 | } | |
| 1537 | ||
| 1538 | lastBatchStartedAt = now | |
| 1539 | clearBatchState() | |
| 1540 | batchLookupSource = source | |
| 1541 | batchTotalCount = domains.count | |
| 1542 | batchLookupRunning = true | |
| 1543 | batchResults = domains.map { | |
| 1544 | BatchLookupResult( | |
| 1545 | domain: $0, | |
| 1546 | historyEntryID: nil, | |
| 1547 | availability: nil, | |
| 1548 | primaryIP: nil, | |
| 1549 | quickStatus: "Pending", | |
| 1550 | timestamp: Date(), | |
| 1551 | status: .pending | |
| 1552 | ) | |
| 1553 | } | |
| 1554 | ||
| 1555 | lookupTask?.cancel() | |
| 1556 | customPortScanTask?.cancel() | |
| 1557 | batchTask?.cancel() | |
| 1558 | ||
| 1559 | batchTask = Task { [weak self] in | |
| 1560 | guard let self else { return } | |
| 1561 | self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded() | |
| 1562 | await self.runBatchLookup(domains: domains, source: source) | |
| 1563 | } | |
| 1564 | } | |
| 1565 | ||
| 1274 | 1566 | private func clearBatchState() { |
| 1275 | 1567 | batchResults = [] |
| 1276 | 1568 | batchLookupSource = .manual |
| @@ -1278,6 +1570,9 @@ final class DomainViewModel { | ||
| 1278 | 1570 | batchCompletedCount = 0 |
| 1279 | 1571 | batchTotalCount = 0 |
| 1280 | 1572 | batchLookupRunning = false |
| 1573 | latestBatchSweepSummary = nil | |
| 1574 | activeBatchDomains = [] | |
| 1575 | batchTask = nil | |
| 1281 | 1576 | } |
| 1282 | 1577 | |
| 1283 | 1578 | private func parsedDomains(from input: String) -> [String] { |
| @@ -1292,42 +1587,114 @@ final class DomainViewModel { | ||
| 1292 | 1587 | } |
| 1293 | 1588 | |
| 1294 | 1589 | private func runBatchLookup(domains: [String], source: BatchLookupSource) async { |
| 1295 | for (index, domain) in domains.enumerated() { | |
| 1296 | guard !Task.isCancelled else { break } | |
| 1297 | ||
| 1298 | batchCurrentDomain = domain | |
| 1299 | if source == .watchlistRefresh { | |
| 1300 | refreshingTrackedDomainID = trackedDomain(for: domain)?.id | |
| 1590 | let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1)) | |
| 1591 | var nextIndex = 0 | |
| 1592 | ||
| 1593 | await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in | |
| 1594 | for _ in 0..<concurrencyLimit { | |
| 1595 | guard nextIndex < domains.count else { break } | |
| 1596 | let domain = domains[nextIndex] | |
| 1597 | nextIndex += 1 | |
| 1598 | enqueueBatchLookup(domain: domain, source: source, group: &group) | |
| 1301 | 1599 | } |
| 1302 | updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) | |
| 1303 | ||
| 1304 | let lookupID = beginLookup(for: domain, cancelExistingTask: false) | |
| 1305 | let entry = await performLookup(domain: domain, lookupID: lookupID) | |
| 1306 | ||
| 1307 | if let entry { | |
| 1308 | updateBatchResult( | |
| 1309 | domain: domain, | |
| 1310 | status: .completed, | |
| 1311 | quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged", | |
| 1312 | entry: entry, | |
| 1313 | errorMessage: nil | |
| 1314 | ) | |
| 1315 | } else { | |
| 1316 | updateBatchResult( | |
| 1317 | domain: domain, | |
| 1318 | status: .failed, | |
| 1319 | quickStatus: "Failed", | |
| 1320 | entry: nil, | |
| 1321 | errorMessage: "Lookup cancelled" | |
| 1322 | ) | |
| 1600 | ||
| 1601 | while let (domain, payload) = await group.next() { | |
| 1602 | completeBatchLookup(domain: domain, payload: payload) | |
| 1603 | ||
| 1604 | if nextIndex < domains.count, !Task.isCancelled { | |
| 1605 | let nextDomain = domains[nextIndex] | |
| 1606 | nextIndex += 1 | |
| 1607 | enqueueBatchLookup(domain: nextDomain, source: source, group: &group) | |
| 1608 | } | |
| 1323 | 1609 | } |
| 1610 | } | |
| 1611 | ||
| 1612 | finishBatchLookup(source: source) | |
| 1613 | } | |
| 1324 | 1614 | |
| 1325 | batchCompletedCount = index + 1 | |
| 1615 | private func enqueueBatchLookup( | |
| 1616 | domain: String, | |
| 1617 | source: BatchLookupSource, | |
| 1618 | group: inout TaskGroup<(String, BatchLookupPayload?)> | |
| 1619 | ) { | |
| 1620 | activeBatchDomains.append(domain) | |
| 1621 | batchCurrentDomain = activeBatchDomains.first | |
| 1622 | if source == .watchlistRefresh { | |
| 1623 | refreshingTrackedDomainID = trackedDomain(for: domain)?.id | |
| 1624 | } | |
| 1625 | updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) | |
| 1626 | ||
| 1627 | group.addTask { [domain] in | |
| 1628 | let payload = await Self.performBatchLookup(domain: domain) | |
| 1629 | return (domain, payload) | |
| 1326 | 1630 | } |
| 1631 | } | |
| 1632 | ||
| 1633 | private func completeBatchLookup(domain: String, payload: BatchLookupPayload?) { | |
| 1634 | activeBatchDomains.removeAll { $0.caseInsensitiveCompare(domain) == .orderedSame } | |
| 1635 | batchCurrentDomain = activeBatchDomains.first | |
| 1327 | 1636 | |
| 1637 | guard let payload else { | |
| 1638 | updateBatchResult( | |
| 1639 | domain: domain, | |
| 1640 | status: .failed, | |
| 1641 | quickStatus: "Failed", | |
| 1642 | entry: nil, | |
| 1643 | errorMessage: "Lookup cancelled" | |
| 1644 | ) | |
| 1645 | batchCompletedCount += 1 | |
| 1646 | return | |
| 1647 | } | |
| 1648 | ||
| 1649 | let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) | |
| 1650 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) | |
| 1651 | let quickStatus: String | |
| 1652 | if entry?.changeSummary?.hasChanges == true { | |
| 1653 | quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed" | |
| 1654 | } else if certificateWarningLevel != .none { | |
| 1655 | quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning" | |
| 1656 | } else { | |
| 1657 | quickStatus = "Unchanged" | |
| 1658 | } | |
| 1659 | ||
| 1660 | updateBatchResult( | |
| 1661 | domain: domain, | |
| 1662 | status: .completed, | |
| 1663 | quickStatus: quickStatus, | |
| 1664 | entry: entry, | |
| 1665 | errorMessage: nil | |
| 1666 | ) | |
| 1667 | batchCompletedCount += 1 | |
| 1668 | } | |
| 1669 | ||
| 1670 | private func finishBatchLookup(source: BatchLookupSource) { | |
| 1328 | 1671 | batchLookupRunning = false |
| 1329 | 1672 | batchCurrentDomain = nil |
| 1673 | activeBatchDomains = [] | |
| 1330 | 1674 | refreshingTrackedDomainID = nil |
| 1675 | batchTask = nil | |
| 1676 | ||
| 1677 | let summary = BatchSweepSummary( | |
| 1678 | source: source, | |
| 1679 | totalDomains: batchResults.count, | |
| 1680 | changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count, | |
| 1681 | unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count, | |
| 1682 | warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count, | |
| 1683 | results: batchResults.sorted { lhs, rhs in | |
| 1684 | if lhs.status != rhs.status { | |
| 1685 | return lhs.status.rawValue < rhs.status.rawValue | |
| 1686 | } | |
| 1687 | return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending | |
| 1688 | }, | |
| 1689 | generatedAt: Date() | |
| 1690 | ) | |
| 1691 | latestBatchSweepSummary = summary | |
| 1692 | ||
| 1693 | if notificationsAuthorized { | |
| 1694 | Task { | |
| 1695 | await LocalNotificationService.shared.notifySweepComplete(summary: summary) | |
| 1696 | } | |
| 1697 | } | |
| 1331 | 1698 | } |
| 1332 | 1699 | |
| 1333 | 1700 | private func updateBatchResult( |
| @@ -1348,6 +1715,9 @@ final class DomainViewModel { | ||
| 1348 | 1715 | availability: entry?.availabilityResult?.status, |
| 1349 | 1716 | primaryIP: entry?.primaryIP, |
| 1350 | 1717 | quickStatus: quickStatus, |
| 1718 | summaryMessage: entry?.changeSummary?.message, | |
| 1719 | changeSeverity: entry?.changeSummary?.severity, | |
| 1720 | certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel, | |
| 1351 | 1721 | timestamp: entry?.timestamp ?? Date(), |
| 1352 | 1722 | status: status, |
| 1353 | 1723 | errorMessage: errorMessage |
| @@ -1594,8 +1964,8 @@ final class DomainViewModel { | ||
| 1594 | 1964 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), |
| 1595 | 1965 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), |
| 1596 | 1966 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), |
| 1597 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), | |
| 1598 | SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary) | |
| 1967 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), | |
| 1968 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary) | |
| 1599 | 1969 | ] |
| 1600 | 1970 | } |
| 1601 | 1971 | |
| @@ -1614,6 +1984,16 @@ final class DomainViewModel { | ||
| 1614 | 1984 | ), |
| 1615 | 1985 | at: 1 |
| 1616 | 1986 | ) |
| 1987 | if let certificateStatus = certificateBadgeLabel(from: snapshot) { | |
| 1988 | rows.insert( | |
| 1989 | InfoRowViewData( | |
| 1990 | label: "Certificate", | |
| 1991 | value: certificateStatus, | |
| 1992 | tone: certificateStatusTone(from: snapshot) | |
| 1993 | ), | |
| 1994 | at: 2 | |
| 1995 | ) | |
| 1996 | } | |
| 1617 | 1997 | return rows |
| 1618 | 1998 | } |
| 1619 | 1999 | |
| @@ -1663,7 +2043,7 @@ final class DomainViewModel { | ||
| 1663 | 2043 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), |
| 1664 | 2044 | InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), |
| 1665 | 2045 | InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary), |
| 1666 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), | |
| 2046 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: certificateTone(daysRemaining: sslInfo.daysUntilExpiry)), | |
| 1667 | 2047 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) |
| 1668 | 2048 | ] |
| 1669 | 2049 | if let tlsVersion = sslInfo.tlsVersion { |
| @@ -1855,7 +2235,8 @@ final class DomainViewModel { | ||
| 1855 | 2235 | lines.append(" \(item.label): \(item.value)") |
| 1856 | 2236 | } |
| 1857 | 2237 | if let changeSummary { |
| 1858 | lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") | |
| 2238 | lines.append(" Change Summary: \(changeSummary.message)") | |
| 2239 | lines.append(" Severity: \(changeSummary.severity.title)") | |
| 1859 | 2240 | lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") |
| 1860 | 2241 | lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))") |
| 1861 | 2242 | } |
| @@ -2080,6 +2461,43 @@ final class DomainViewModel { | ||
| 2080 | 2461 | return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" |
| 2081 | 2462 | } |
| 2082 | 2463 | |
| 2464 | private static func certificateStatusLabel(from snapshot: LookupSnapshot) -> String { | |
| 2465 | guard let sslInfo = snapshot.sslInfo else { | |
| 2466 | return snapshot.sslError ?? "Unavailable" | |
| 2467 | } | |
| 2468 | ||
| 2469 | switch DomainDiffService.certificateWarningLevel(for: snapshot) { | |
| 2470 | case .critical: | |
| 2471 | return "Critical (\(sslInfo.daysUntilExpiry)d)" | |
| 2472 | case .warning: | |
| 2473 | return "Warning (\(sslInfo.daysUntilExpiry)d)" | |
| 2474 | case .none: | |
| 2475 | return "Healthy (\(sslInfo.daysUntilExpiry)d)" | |
| 2476 | } | |
| 2477 | } | |
| 2478 | ||
| 2479 | private static func certificateBadgeLabel(from snapshot: LookupSnapshot) -> String? { | |
| 2480 | guard snapshot.sslInfo != nil else { return nil } | |
| 2481 | return certificateStatusLabel(from: snapshot) | |
| 2482 | } | |
| 2483 | ||
| 2484 | private static func certificateStatusTone(from snapshot: LookupSnapshot) -> ResultTone { | |
| 2485 | guard let daysRemaining = snapshot.sslInfo?.daysUntilExpiry else { | |
| 2486 | return snapshot.sslError == nil ? .secondary : .failure | |
| 2487 | } | |
| 2488 | return certificateTone(daysRemaining: daysRemaining) | |
| 2489 | } | |
| 2490 | ||
| 2491 | private static func certificateTone(daysRemaining: Int) -> ResultTone { | |
| 2492 | if daysRemaining < 14 { | |
| 2493 | return .failure | |
| 2494 | } | |
| 2495 | if daysRemaining < 30 { | |
| 2496 | return .warning | |
| 2497 | } | |
| 2498 | return .success | |
| 2499 | } | |
| 2500 | ||
| 2083 | 2501 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { |
| 2084 | 2502 | switch status { |
| 2085 | 2503 | case .available: |
DomainDig/LocalNotificationService.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Foundation | |
| 2 | import UserNotifications | |
| 3 | ||
| 4 | @MainActor | |
| 5 | final class LocalNotificationService { | |
| 6 | static let shared = LocalNotificationService() | |
| 7 | ||
| 8 | private init() {} | |
| 9 | ||
| 10 | func configureForegroundPresentation() { | |
| 11 | UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared | |
| 12 | } | |
| 13 | ||
| 14 | func requestAuthorizationIfNeeded() async -> Bool { | |
| 15 | let center = UNUserNotificationCenter.current() | |
| 16 | let settings = await center.notificationSettings() | |
| 17 | ||
| 18 | switch settings.authorizationStatus { | |
| 19 | case .authorized, .provisional, .ephemeral: | |
| 20 | return true | |
| 21 | case .notDetermined: | |
| 22 | return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false | |
| 23 | case .denied: | |
| 24 | return false | |
| 25 | @unknown default: | |
| 26 | return false | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async { | |
| 31 | await schedule( | |
| 32 | identifier: "domain-change-\(domain)", | |
| 33 | title: domain, | |
| 34 | body: message, | |
| 35 | interruptionLevel: severity == .high ? .timeSensitive : .active | |
| 36 | ) | |
| 37 | } | |
| 38 | ||
| 39 | func notifyCertificateWarning(domain: String, daysRemaining: Int) async { | |
| 40 | await schedule( | |
| 41 | identifier: "cert-warning-\(domain)", | |
| 42 | title: domain, | |
| 43 | body: "Certificate expires in \(daysRemaining) days", | |
| 44 | interruptionLevel: .timeSensitive | |
| 45 | ) | |
| 46 | } | |
| 47 | ||
| 48 | func notifySweepComplete(summary: BatchSweepSummary) async { | |
| 49 | let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged" | |
| 50 | await schedule( | |
| 51 | identifier: "sweep-complete", | |
| 52 | title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete", | |
| 53 | body: body, | |
| 54 | interruptionLevel: .active | |
| 55 | ) | |
| 56 | } | |
| 57 | ||
| 58 | private func schedule( | |
| 59 | identifier: String, | |
| 60 | title: String, | |
| 61 | body: String, | |
| 62 | interruptionLevel: UNNotificationInterruptionLevel | |
| 63 | ) async { | |
| 64 | let content = UNMutableNotificationContent() | |
| 65 | content.title = title | |
| 66 | content.body = body | |
| 67 | content.sound = .default | |
| 68 | content.interruptionLevel = interruptionLevel | |
| 69 | ||
| 70 | let request = UNNotificationRequest( | |
| 71 | identifier: identifier, | |
| 72 | content: content, | |
| 73 | trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) | |
| 74 | ) | |
| 75 | ||
| 76 | try? await UNUserNotificationCenter.current().add(request) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { | |
| 81 | static let shared = NotificationCenterDelegate() | |
| 82 | ||
| 83 | func userNotificationCenter( | |
| 84 | _ center: UNUserNotificationCenter, | |
| 85 | willPresent notification: UNNotification | |
| 86 | ) async -> UNNotificationPresentationOptions { | |
| 87 | [.banner, .list, .sound] | |
| 88 | } | |
| 89 | } | |
DomainDig/Models.swift +110 −1
| @@ -48,10 +48,74 @@ struct WatchedDomain: Codable, Identifiable { | ||
| 48 | 48 | } |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | enum ChangeSeverity: Int, Codable, CaseIterable, Comparable { | |
| 52 | case low | |
| 53 | case medium | |
| 54 | case high | |
| 55 | ||
| 56 | static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool { | |
| 57 | lhs.rawValue < rhs.rawValue | |
| 58 | } | |
| 59 | ||
| 60 | var title: String { | |
| 61 | switch self { | |
| 62 | case .low: | |
| 63 | return "Low" | |
| 64 | case .medium: | |
| 65 | return "Medium" | |
| 66 | case .high: | |
| 67 | return "High" | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | enum CertificateWarningLevel: String, Codable { | |
| 73 | case none | |
| 74 | case warning | |
| 75 | case critical | |
| 76 | ||
| 77 | var title: String { | |
| 78 | switch self { | |
| 79 | case .none: | |
| 80 | return "Healthy" | |
| 81 | case .warning: | |
| 82 | return "Warning" | |
| 83 | case .critical: | |
| 84 | return "Critical" | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 51 | 89 | struct DomainChangeSummary: Codable, Equatable { |
| 52 | 90 | let hasChanges: Bool |
| 53 | 91 | let changedSections: [String] |
| 92 | let message: String | |
| 93 | let severity: ChangeSeverity | |
| 54 | 94 | let generatedAt: Date |
| 95 | ||
| 96 | init( | |
| 97 | hasChanges: Bool, | |
| 98 | changedSections: [String], | |
| 99 | message: String, | |
| 100 | severity: ChangeSeverity, | |
| 101 | generatedAt: Date | |
| 102 | ) { | |
| 103 | self.hasChanges = hasChanges | |
| 104 | self.changedSections = changedSections | |
| 105 | self.message = message | |
| 106 | self.severity = severity | |
| 107 | self.generatedAt = generatedAt | |
| 108 | } | |
| 109 | ||
| 110 | init(from decoder: Decoder) throws { | |
| 111 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 112 | hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false | |
| 113 | changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? [] | |
| 114 | generatedAt = try container.decode(Date.self, forKey: .generatedAt) | |
| 115 | severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) | |
| 116 | message = try container.decodeIfPresent(String.self, forKey: .message) | |
| 117 | ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) | |
| 118 | } | |
| 55 | 119 | } |
| 56 | 120 | |
| 57 | 121 | enum BatchLookupSource: String, Codable { |
| @@ -73,6 +137,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | ||
| 73 | 137 | let availability: DomainAvailabilityStatus? |
| 74 | 138 | let primaryIP: String? |
| 75 | 139 | let quickStatus: String |
| 140 | let summaryMessage: String? | |
| 141 | let changeSeverity: ChangeSeverity? | |
| 142 | let certificateWarningLevel: CertificateWarningLevel | |
| 76 | 143 | let timestamp: Date |
| 77 | 144 | let status: BatchLookupStatus |
| 78 | 145 | let errorMessage: String? |
| @@ -84,6 +151,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | ||
| 84 | 151 | availability: DomainAvailabilityStatus?, |
| 85 | 152 | primaryIP: String?, |
| 86 | 153 | quickStatus: String, |
| 154 | summaryMessage: String? = nil, | |
| 155 | changeSeverity: ChangeSeverity? = nil, | |
| 156 | certificateWarningLevel: CertificateWarningLevel = .none, | |
| 87 | 157 | timestamp: Date, |
| 88 | 158 | status: BatchLookupStatus, |
| 89 | 159 | errorMessage: String? = nil |
| @@ -94,12 +164,26 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | ||
| 94 | 164 | self.availability = availability |
| 95 | 165 | self.primaryIP = primaryIP |
| 96 | 166 | self.quickStatus = quickStatus |
| 167 | self.summaryMessage = summaryMessage | |
| 168 | self.changeSeverity = changeSeverity | |
| 169 | self.certificateWarningLevel = certificateWarningLevel | |
| 97 | 170 | self.timestamp = timestamp |
| 98 | 171 | self.status = status |
| 99 | 172 | self.errorMessage = errorMessage |
| 100 | 173 | } |
| 101 | 174 | } |
| 102 | 175 | |
| 176 | struct BatchSweepSummary: Identifiable, Equatable { | |
| 177 | let id = UUID() | |
| 178 | let source: BatchLookupSource | |
| 179 | let totalDomains: Int | |
| 180 | let changedDomains: Int | |
| 181 | let unchangedDomains: Int | |
| 182 | let warningDomains: Int | |
| 183 | let results: [BatchLookupResult] | |
| 184 | let generatedAt: Date | |
| 185 | } | |
| 186 | ||
| 103 | 187 | enum HistoryDateFilter: String, CaseIterable, Identifiable { |
| 104 | 188 | case today |
| 105 | 189 | case last7Days |
| @@ -213,6 +297,9 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 213 | 297 | var lastKnownAvailability: DomainAvailabilityStatus? |
| 214 | 298 | var lastSnapshotID: UUID? |
| 215 | 299 | var lastChangeSummary: DomainChangeSummary? |
| 300 | var lastChangeSeverity: ChangeSeverity? | |
| 301 | var certificateWarningLevel: CertificateWarningLevel | |
| 302 | var certificateDaysRemaining: Int? | |
| 216 | 303 | |
| 217 | 304 | init( |
| 218 | 305 | id: UUID = UUID(), |
| @@ -223,7 +310,10 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 223 | 310 | isPinned: Bool = false, |
| 224 | 311 | lastKnownAvailability: DomainAvailabilityStatus? = nil, |
| 225 | 312 | lastSnapshotID: UUID? = nil, |
| 226 | lastChangeSummary: DomainChangeSummary? = nil | |
| 313 | lastChangeSummary: DomainChangeSummary? = nil, | |
| 314 | lastChangeSeverity: ChangeSeverity? = nil, | |
| 315 | certificateWarningLevel: CertificateWarningLevel = .none, | |
| 316 | certificateDaysRemaining: Int? = nil | |
| 227 | 317 | ) { |
| 228 | 318 | self.id = id |
| 229 | 319 | self.domain = domain |
| @@ -234,6 +324,25 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | ||
| 234 | 324 | self.lastKnownAvailability = lastKnownAvailability |
| 235 | 325 | self.lastSnapshotID = lastSnapshotID |
| 236 | 326 | self.lastChangeSummary = lastChangeSummary |
| 327 | self.lastChangeSeverity = lastChangeSeverity | |
| 328 | self.certificateWarningLevel = certificateWarningLevel | |
| 329 | self.certificateDaysRemaining = certificateDaysRemaining | |
| 330 | } | |
| 331 | ||
| 332 | init(from decoder: Decoder) throws { | |
| 333 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 334 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 335 | domain = try container.decode(String.self, forKey: .domain) | |
| 336 | createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() | |
| 337 | updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt | |
| 338 | note = try container.decodeIfPresent(String.self, forKey: .note) | |
| 339 | isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false | |
| 340 | lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability) | |
| 341 | lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID) | |
| 342 | lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary) | |
| 343 | lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity | |
| 344 | certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none | |
| 345 | certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining) | |
| 237 | 346 | } |
| 238 | 347 | } |
| 239 | 348 | |
DomainDig/SSLCheckService.swift +60 −13
| @@ -65,24 +65,28 @@ struct SSLCheckService { | ||
| 65 | 65 | let validFrom: Date |
| 66 | 66 | let validUntil: Date |
| 67 | 67 | |
| 68 | if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? { | |
| 69 | validFrom = notBefore | |
| 70 | } else { | |
| 71 | validFrom = Date.distantPast | |
| 72 | } | |
| 68 | let derData = SecCertificateCopyData(leaf) as Data | |
| 69 | let parsed = DERCertificateParser.parse(derData) | |
| 70 | ||
| 71 | if #available(iOS 18.0, *) { | |
| 72 | if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? { | |
| 73 | validFrom = notBefore | |
| 74 | } else { | |
| 75 | validFrom = parsed.notBefore ?? Date.distantPast | |
| 76 | } | |
| 73 | 77 | |
| 74 | if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? { | |
| 75 | validUntil = notAfter | |
| 78 | if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? { | |
| 79 | validUntil = notAfter | |
| 80 | } else { | |
| 81 | validUntil = parsed.notAfter ?? Date.distantFuture | |
| 82 | } | |
| 76 | 83 | } else { |
| 77 | validUntil = Date.distantFuture | |
| 84 | validFrom = parsed.notBefore ?? Date.distantPast | |
| 85 | validUntil = parsed.notAfter ?? Date.distantFuture | |
| 78 | 86 | } |
| 79 | 87 | |
| 80 | 88 | let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0 |
| 81 | 89 | |
| 82 | // Parse the DER-encoded certificate to extract SANs and Issuer | |
| 83 | let derData = SecCertificateCopyData(leaf) as Data | |
| 84 | let parsed = DERCertificateParser.parse(derData) | |
| 85 | ||
| 86 | 90 | let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames |
| 87 | 91 | |
| 88 | 92 | // Issuer: prefer parsed issuer, fall back to chain's next cert summary |
| @@ -116,6 +120,7 @@ struct SSLCheckService { | ||
| 116 | 120 | chain: chain |
| 117 | 121 | ) |
| 118 | 122 | } |
| 123 | ||
| 119 | 124 | } |
| 120 | 125 | |
| 121 | 126 | fileprivate struct TLSMetadata { |
| @@ -133,6 +138,8 @@ private enum DERCertificateParser { | ||
| 133 | 138 | struct Result { |
| 134 | 139 | var issuerCommonName: String? |
| 135 | 140 | var subjectAltNames: [String] = [] |
| 141 | var notBefore: Date? | |
| 142 | var notAfter: Date? | |
| 136 | 143 | } |
| 137 | 144 | |
| 138 | 145 | static func parse(_ data: Data) -> Result { |
| @@ -171,8 +178,11 @@ private enum DERCertificateParser { | ||
| 171 | 178 | offset = issuerSeq.contentStart + issuerSeq.length |
| 172 | 179 | } |
| 173 | 180 | |
| 174 | // Skip validity | |
| 181 | // Validity | |
| 175 | 182 | if let validity = readTagAndLength(bytes, offset: offset) { |
| 183 | let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length) | |
| 184 | result.notBefore = notBefore | |
| 185 | result.notAfter = notAfter | |
| 176 | 186 | offset = validity.contentStart + validity.length |
| 177 | 187 | } |
| 178 | 188 | |
| @@ -287,6 +297,43 @@ private enum DERCertificateParser { | ||
| 287 | 297 | return sans |
| 288 | 298 | } |
| 289 | 299 | |
| 300 | private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) { | |
| 301 | let end = sequenceStart + length | |
| 302 | var position = sequenceStart | |
| 303 | var dates: [Date] = [] | |
| 304 | ||
| 305 | while position < end, dates.count < 2 { | |
| 306 | guard let timeTL = readTagAndLength(bytes, offset: position) else { break } | |
| 307 | let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii) | |
| 308 | if let raw { | |
| 309 | dates.append(parseASN1Time(raw)) | |
| 310 | } | |
| 311 | position = timeTL.contentStart + timeTL.length | |
| 312 | } | |
| 313 | ||
| 314 | let notBefore = dates.indices.contains(0) ? dates[0] : nil | |
| 315 | let notAfter = dates.indices.contains(1) ? dates[1] : nil | |
| 316 | return (notBefore, notAfter) | |
| 317 | } | |
| 318 | ||
| 319 | private static func parseASN1Time(_ string: String) -> Date { | |
| 320 | let utcFormatter = DateFormatter() | |
| 321 | utcFormatter.locale = Locale(identifier: "en_US_POSIX") | |
| 322 | utcFormatter.timeZone = TimeZone(secondsFromGMT: 0) | |
| 323 | utcFormatter.dateFormat = "yyMMddHHmmss'Z'" | |
| 324 | ||
| 325 | if let date = utcFormatter.date(from: string) { | |
| 326 | return date | |
| 327 | } | |
| 328 | ||
| 329 | let generalizedFormatter = DateFormatter() | |
| 330 | generalizedFormatter.locale = Locale(identifier: "en_US_POSIX") | |
| 331 | generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0) | |
| 332 | generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'" | |
| 333 | ||
| 334 | return generalizedFormatter.date(from: string) ?? Date.distantFuture | |
| 335 | } | |
| 336 | ||
| 290 | 337 | private struct TLV { |
| 291 | 338 | let contentStart: Int |
| 292 | 339 | let length: Int |
DomainDig/WatchlistView.swift +63 −5
| @@ -9,12 +9,20 @@ struct WatchlistView: View { | ||
| 9 | 9 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { |
| 10 | 10 | Section("Refresh Progress") { |
| 11 | 11 | VStack(alignment: .leading, spacing: 8) { |
| 12 | if viewModel.batchLookupRunning { | |
| 13 | ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) | |
| 14 | .tint(.cyan) | |
| 12 | ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) | |
| 13 | .tint(.cyan) | |
| 14 | HStack { | |
| 15 | 15 | Text(viewModel.batchProgressLabel) |
| 16 | 16 | .font(.system(.caption, design: .monospaced)) |
| 17 | 17 | .foregroundStyle(.secondary) |
| 18 | Spacer() | |
| 19 | if viewModel.batchLookupRunning { | |
| 20 | Button("Cancel") { | |
| 21 | viewModel.cancelBatchLookup() | |
| 22 | } | |
| 23 | .buttonStyle(.bordered) | |
| 24 | .font(.system(.caption2, design: .monospaced)) | |
| 25 | } | |
| 18 | 26 | } |
| 19 | 27 | |
| 20 | 28 | ForEach(viewModel.batchResults.prefix(5)) { result in |
| @@ -129,9 +137,10 @@ struct WatchlistView: View { | ||
| 129 | 137 | } |
| 130 | 138 | } |
| 131 | 139 | |
| 132 | Button("Refresh All") { | |
| 140 | Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") { | |
| 133 | 141 | viewModel.refreshAllTrackedDomains() |
| 134 | 142 | } |
| 143 | .disabled(viewModel.batchLookupRunning) | |
| 135 | 144 | |
| 136 | 145 | Button("Export TXT") { |
| 137 | 146 | shareTrackedDomains(asCSV: false) |
| @@ -151,9 +160,19 @@ struct WatchlistView: View { | ||
| 151 | 160 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 152 | 161 | dismiss() |
| 153 | 162 | } |
| 163 | .sheet(item: batchSummaryBinding) { summary in | |
| 164 | BatchSweepSummaryView(viewModel: viewModel, summary: summary) | |
| 165 | } | |
| 154 | 166 | .preferredColorScheme(.dark) |
| 155 | 167 | } |
| 156 | 168 | |
| 169 | private var batchSummaryBinding: Binding<BatchSweepSummary?> { | |
| 170 | Binding( | |
| 171 | get: { viewModel.latestBatchSweepSummary }, | |
| 172 | set: { viewModel.latestBatchSweepSummary = $0 } | |
| 173 | ) | |
| 174 | } | |
| 175 | ||
| 157 | 176 | private func deleteFilteredTrackedDomains(at offsets: IndexSet) { |
| 158 | 177 | let domains = offsets.map { viewModel.filteredTrackedDomains[$0] } |
| 159 | 178 | domains.forEach(viewModel.deleteTrackedDomain) |
| @@ -197,13 +216,15 @@ struct WatchlistRowView: View { | ||
| 197 | 216 | .font(.system(.caption2, design: .monospaced)) |
| 198 | 217 | .foregroundStyle(.secondary) |
| 199 | 218 | |
| 219 | indicatorRow | |
| 220 | ||
| 200 | 221 | if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { |
| 201 | 222 | Text(note) |
| 202 | 223 | .font(.system(.caption, design: .monospaced)) |
| 203 | 224 | .foregroundStyle(.secondary) |
| 204 | 225 | .lineLimit(2) |
| 205 | 226 | } else if let summary = trackedDomain.lastChangeSummary { |
| 206 | Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • ")) | |
| 227 | Text(summary.message) | |
| 207 | 228 | .font(.system(.caption, design: .monospaced)) |
| 208 | 229 | .foregroundStyle(.secondary) |
| 209 | 230 | .lineLimit(2) |
| @@ -270,6 +291,43 @@ struct WatchlistRowView: View { | ||
| 270 | 291 | return Color(.systemGray5).opacity(0.6) |
| 271 | 292 | } |
| 272 | 293 | } |
| 294 | ||
| 295 | @ViewBuilder | |
| 296 | private var indicatorRow: some View { | |
| 297 | HStack(spacing: 8) { | |
| 298 | if let severity = trackedDomain.lastChangeSeverity, severity >= .medium { | |
| 299 | Label(severity.title, systemImage: severity == .high ? "exclamationmark.circle.fill" : "circle.fill") | |
| 300 | .font(.system(.caption2, design: .monospaced)) | |
| 301 | .foregroundStyle(severity == .high ? .red : .yellow) | |
| 302 | } else { | |
| 303 | Label("Stable", systemImage: "circle.fill") | |
| 304 | .font(.system(.caption2, design: .monospaced)) | |
| 305 | .foregroundStyle(.secondary) | |
| 306 | } | |
| 307 | ||
| 308 | if trackedDomain.certificateWarningLevel != .none { | |
| 309 | Text(certificateLabel) | |
| 310 | .font(.system(.caption2, design: .monospaced)) | |
| 311 | .foregroundStyle(trackedDomain.certificateWarningLevel == .critical ? .red : .yellow) | |
| 312 | .padding(.horizontal, 8) | |
| 313 | .padding(.vertical, 4) | |
| 314 | .background((trackedDomain.certificateWarningLevel == .critical ? Color.red : Color.yellow).opacity(0.16)) | |
| 315 | .clipShape(Capsule()) | |
| 316 | } | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | private var certificateLabel: String { | |
| 321 | let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon" | |
| 322 | switch trackedDomain.certificateWarningLevel { | |
| 323 | case .critical: | |
| 324 | return "Cert \(days)" | |
| 325 | case .warning: | |
| 326 | return "Warn \(days)" | |
| 327 | case .none: | |
| 328 | return "" | |
| 329 | } | |
| 330 | } | |
| 273 | 331 | } |
| 274 | 332 | |
| 275 | 333 | struct TrackedDomainDetailView: View { |