Commit 1f58092dcb
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +6 −6
| @@ -267,7 +267,7 @@ | |||
| 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | CODE_SIGN_STYLE = Automatic; | 269 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 14; | 270 | CURRENT_PROJECT_VERSION = 15; |
| 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | ENABLE_PREVIEWS = YES; | 272 | ENABLE_PREVIEWS = YES; |
| 273 | GENERATE_INFOPLIST_FILE = YES; | 273 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -279,12 +279,12 @@ | |||
| 279 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | 279 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 280 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 280 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 281 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 281 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 282 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; | 282 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 283 | LD_RUNPATH_SEARCH_PATHS = ( | 283 | LD_RUNPATH_SEARCH_PATHS = ( |
| 284 | "$(inherited)", | 284 | "$(inherited)", |
| 285 | "@executable_path/Frameworks", | 285 | "@executable_path/Frameworks", |
| 286 | ); | 286 | ); |
| 287 | MARKETING_VERSION = 2.1.0; | 287 | MARKETING_VERSION = 2.2.0; |
| 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | PRODUCT_NAME = "$(TARGET_NAME)"; | 289 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +303,7 @@ | |||
| 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | CODE_SIGN_STYLE = Automatic; | 305 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 14; | 306 | CURRENT_PROJECT_VERSION = 15; |
| 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | ENABLE_PREVIEWS = YES; | 308 | ENABLE_PREVIEWS = YES; |
| 309 | GENERATE_INFOPLIST_FILE = YES; | 309 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -315,12 +315,12 @@ | |||
| 315 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | 315 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; |
| 316 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 316 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 317 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | 317 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; |
| 318 | IPHONEOS_DEPLOYMENT_TARGET = 26.0; | 318 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; |
| 319 | LD_RUNPATH_SEARCH_PATHS = ( | 319 | LD_RUNPATH_SEARCH_PATHS = ( |
| 320 | "$(inherited)", | 320 | "$(inherited)", |
| 321 | "@executable_path/Frameworks", | 321 | "@executable_path/Frameworks", |
| 322 | ); | 322 | ); |
| 323 | MARKETING_VERSION = 2.1.0; | 323 | MARKETING_VERSION = 2.2.0; |
| 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | PRODUCT_NAME = "$(TARGET_NAME)"; | 325 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/BatchResultsView.swift +10 −1
| @@ -75,6 +75,12 @@ struct BatchResultRowView: View { | |||
| 75 | .font(.system(.caption2, design: .monospaced)) | 75 | .font(.system(.caption2, design: .monospaced)) |
| 76 | .foregroundStyle(.secondary) | 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 | if let errorMessage = result.errorMessage { | 84 | if let errorMessage = result.errorMessage { |
| 79 | Text(errorMessage) | 85 | Text(errorMessage) |
| 80 | .font(.system(.caption2, design: .monospaced)) | 86 | .font(.system(.caption2, design: .monospaced)) |
| @@ -103,7 +109,10 @@ struct BatchResultRowView: View { | |||
| 103 | case .running: | 109 | case .running: |
| 104 | return .cyan | 110 | return .cyan |
| 105 | case .completed: | 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 | return .yellow | 116 | return .yellow |
| 108 | } | 117 | } |
| 109 | return .green | 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 | VStack(alignment: .leading, spacing: 12) { | 308 | VStack(alignment: .leading, spacing: 12) { |
| 309 | HStack { | 309 | HStack { |
| 310 | Spacer() | 310 | Spacer() |
| 311 | if viewModel.batchLookupRunning { | ||
| 312 | Button("Cancel") { | ||
| 313 | viewModel.cancelBatchLookup() | ||
| 314 | } | ||
| 315 | .buttonStyle(.bordered) | ||
| 316 | .font(.system(.caption, design: .monospaced)) | ||
| 317 | } | ||
| 311 | if !viewModel.currentBatchResultEntries.isEmpty { | 318 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 312 | Menu { | 319 | Menu { |
| 313 | Button("Export Batch TXT") { | 320 | Button("Export Batch TXT") { |
| @@ -455,45 +462,82 @@ struct DomainChangeSummaryView: View { | |||
| 455 | var body: some View { | 462 | var body: some View { |
| 456 | CardView(allowsHorizontalScroll: false) { | 463 | CardView(allowsHorizontalScroll: false) { |
| 457 | HStack { | 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 | .font(.system(.caption, design: .monospaced)) | 466 | .font(.system(.caption, design: .monospaced)) |
| 460 | .foregroundStyle(summary.hasChanges ? .yellow : .green) | 467 | .foregroundStyle(summary.hasChanges ? severityColor(summary.severity) : .green) |
| 461 | Spacer() | 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 | Text(summary.generatedAt, style: .time) | 476 | Text(summary.generatedAt, style: .time) |
| 463 | .font(.system(.caption2, design: .monospaced)) | 477 | .font(.system(.caption2, design: .monospaced)) |
| 464 | .foregroundStyle(.secondary) | 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 | .font(.system(.caption, design: .monospaced)) | 482 | .font(.system(.caption, design: .monospaced)) |
| 469 | .foregroundStyle(.primary) | 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 | struct DomainDiffView: View { | 499 | struct DomainDiffView: View { |
| 475 | let title: String | 500 | let title: String |
| 476 | let sections: [DomainDiffSection] | 501 | let sections: [DomainDiffSection] |
| 477 | let showsUnchanged: Bool | 502 | let showsUnchanged: Bool |
| 503 | |||
| 478 | @State private var collapsedSections = Set<UUID>() | 504 | @State private var collapsedSections = Set<UUID>() |
| 505 | @State private var showsLowSeverity = false | ||
| 479 | 506 | ||
| 480 | private var filteredSections: [DomainDiffSection] { | 507 | private var filteredSections: [DomainDiffSection] { |
| 481 | guard showsUnchanged else { | 508 | sections |
| 482 | return sections | 509 | .map { section in |
| 483 | .map { section in | 510 | let items = section.items.filter { item in |
| 484 | DomainDiffSection( | 511 | if !showsUnchanged, !item.hasChanges { |
| 485 | title: section.title, | 512 | return false |
| 486 | items: section.items.filter(\.hasChanges) | 513 | } |
| 487 | ) | 514 | if showsLowSeverity { |
| 515 | return true | ||
| 516 | } | ||
| 517 | return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged) | ||
| 488 | } | 518 | } |
| 489 | .filter { !$0.items.isEmpty } | 519 | return DomainDiffSection(title: section.title, items: items) |
| 490 | } | 520 | } |
| 491 | return sections | 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 | var body: some View { | 528 | var body: some View { |
| 495 | VStack(alignment: .leading, spacing: 12) { | 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 | if filteredSections.isEmpty { | 541 | if filteredSections.isEmpty { |
| 498 | MessageCardView(text: "No comparison data available", isError: false) | 542 | MessageCardView(text: "No comparison data available", isError: false) |
| 499 | } else { | 543 | } else { |
| @@ -509,12 +553,12 @@ struct DomainDiffView: View { | |||
| 509 | .font(.system(.caption, design: .monospaced)) | 553 | .font(.system(.caption, design: .monospaced)) |
| 510 | .foregroundStyle(.secondary) | 554 | .foregroundStyle(.secondary) |
| 511 | Spacer() | 555 | Spacer() |
| 512 | Text(changeLabel(for: item.changeType)) | 556 | Text("\(item.severity.title) • \(changeLabel(for: item.changeType))") |
| 513 | .font(.system(.caption2, design: .monospaced)) | 557 | .font(.system(.caption2, design: .monospaced)) |
| 514 | .foregroundStyle(changeColor(for: item.changeType)) | 558 | .foregroundStyle(changeColor(for: item)) |
| 515 | .padding(.horizontal, 8) | 559 | .padding(.horizontal, 8) |
| 516 | .padding(.vertical, 4) | 560 | .padding(.vertical, 4) |
| 517 | .background(changeColor(for: item.changeType).opacity(0.16)) | 561 | .background(changeColor(for: item).opacity(0.16)) |
| 518 | .clipShape(Capsule()) | 562 | .clipShape(Capsule()) |
| 519 | } | 563 | } |
| 520 | 564 | ||
| @@ -543,7 +587,7 @@ struct DomainDiffView: View { | |||
| 543 | } | 587 | } |
| 544 | } | 588 | } |
| 545 | .padding(10) | 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 | .cornerRadius(8) | 591 | .cornerRadius(8) |
| 548 | } | 592 | } |
| 549 | } label: { | 593 | } label: { |
| @@ -551,11 +595,11 @@ struct DomainDiffView: View { | |||
| 551 | Text(section.title) | 595 | Text(section.title) |
| 552 | .font(.system(.subheadline, design: .monospaced)) | 596 | .font(.system(.subheadline, design: .monospaced)) |
| 553 | .fontWeight(.semibold) | 597 | .fontWeight(.semibold) |
| 554 | .foregroundStyle(.cyan) | 598 | .foregroundStyle(sectionColor(section)) |
| 555 | Spacer() | 599 | Spacer() |
| 556 | Text(section.hasChanges ? "Changed" : "Unchanged") | 600 | Text(section.severity.title) |
| 557 | .font(.system(.caption2, design: .monospaced)) | 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 { | 624 | private func changeColor(for item: DomainDiffItem) -> Color { |
| 581 | switch changeType { | 625 | if item.changeType == .unchanged { |
| 582 | case .added: | 626 | return .secondary |
| 583 | return .green | 627 | } |
| 584 | case .removed: | 628 | |
| 629 | switch item.severity { | ||
| 630 | case .low: | ||
| 631 | return .blue | ||
| 632 | case .medium: | ||
| 633 | return .yellow | ||
| 634 | case .high: | ||
| 585 | return .red | 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 | return .yellow | 644 | return .yellow |
| 588 | case .unchanged: | 645 | case .high: |
| 589 | return .secondary | 646 | return .red |
| 590 | } | 647 | } |
| 591 | } | 648 | } |
| 592 | 649 | ||
DomainDig/DomainDiffService.swift +276 −61
| @@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable { | |||
| 13 | let changeType: DiffChangeType | 13 | let changeType: DiffChangeType |
| 14 | let oldValue: String? | 14 | let oldValue: String? |
| 15 | let newValue: String? | 15 | let newValue: String? |
| 16 | let severity: ChangeSeverity | ||
| 16 | 17 | ||
| 17 | var hasChanges: Bool { | 18 | var hasChanges: Bool { |
| 18 | changeType != .unchanged | 19 | changeType != .unchanged |
| 19 | } | 20 | } |
| 21 | |||
| 22 | var isMeaningful: Bool { | ||
| 23 | hasChanges && severity >= .medium | ||
| 24 | } | ||
| 20 | } | 25 | } |
| 21 | 26 | ||
| 22 | struct DomainDiffSection: Identifiable, Equatable { | 27 | struct DomainDiffSection: Identifiable, Equatable { |
| @@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable { | |||
| 27 | var hasChanges: Bool { | 32 | var hasChanges: Bool { |
| 28 | items.contains(where: \.hasChanges) | 33 | items.contains(where: \.hasChanges) |
| 29 | } | 34 | } |
| 35 | |||
| 36 | var severity: ChangeSeverity { | ||
| 37 | items.map(\.severity).max() ?? .low | ||
| 38 | } | ||
| 30 | } | 39 | } |
| 31 | 40 | ||
| 32 | enum DomainDiffService { | 41 | enum DomainDiffService { |
| 33 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { | 42 | static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { |
| 34 | [ | 43 | [ |
| 35 | section(title: "Availability", item: compare( | 44 | availabilitySection(from: oldSnapshot, to: newSnapshot), |
| 36 | label: "Status", | 45 | primaryIPSection(from: oldSnapshot, to: newSnapshot), |
| 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 | )), | ||
| 45 | dnsSection(from: oldSnapshot, to: newSnapshot), | 46 | dnsSection(from: oldSnapshot, to: newSnapshot), |
| 46 | section(title: "Redirect", item: compare( | 47 | redirectSection(from: oldSnapshot, to: newSnapshot), |
| 47 | label: "Final Target", | ||
| 48 | oldValue: finalRedirectURL(from: oldSnapshot), | ||
| 49 | newValue: finalRedirectURL(from: newSnapshot) | ||
| 50 | )), | ||
| 51 | tlsSection(from: oldSnapshot, to: newSnapshot), | 48 | tlsSection(from: oldSnapshot, to: newSnapshot), |
| 52 | httpSection(from: oldSnapshot, to: newSnapshot), | 49 | httpSection(from: oldSnapshot, to: newSnapshot), |
| 53 | emailSection(from: oldSnapshot, to: newSnapshot) | 50 | emailSection(from: oldSnapshot, to: newSnapshot) |
| @@ -55,62 +52,212 @@ enum DomainDiffService { | |||
| 55 | .filter { !$0.items.isEmpty } | 52 | .filter { !$0.items.isEmpty } |
| 56 | } | 53 | } |
| 57 | 54 | ||
| 58 | static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary { | 55 | static func summary( |
| 59 | let changedSections = diff(from: oldSnapshot, to: newSnapshot) | 56 | from oldSnapshot: LookupSnapshot, |
| 60 | .filter { $0.items.contains(where: { $0.changeType != .unchanged }) } | 57 | to newSnapshot: LookupSnapshot, |
| 61 | .map(\.title) | 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 | return DomainChangeSummary( | 72 | return DomainChangeSummary( |
| 64 | hasChanges: !changedSections.isEmpty, | 73 | hasChanges: !meaningfulItems.isEmpty, |
| 65 | changedSections: changedSections, | 74 | changedSections: highlights, |
| 75 | message: message, | ||
| 76 | severity: severity, | ||
| 66 | generatedAt: generatedAt | 77 | generatedAt: generatedAt |
| 67 | ) | 78 | ) |
| 68 | } | 79 | } |
| 69 | 80 | ||
| 70 | private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection { | 81 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { |
| 71 | DomainDiffSection(title: title, items: item.map { [$0] } ?? []) | 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 | private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | 122 | private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 75 | let oldValue = normalizedDNSSummary(from: oldSnapshot) | 123 | let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) }) |
| 76 | let newValue = normalizedDNSSummary(from: newSnapshot) | 124 | let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) }) |
| 77 | return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue)) | 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 | private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | 169 | private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 81 | var items: [DomainDiffItem] = [] | 170 | var items: [DomainDiffItem] = [] |
| 82 | if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) { | 171 | |
| 83 | items.append(item) | 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)) { | 189 | |
| 86 | items.append(item) | 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 | private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | 208 | private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 92 | var items: [DomainDiffItem] = [] | 209 | var items: [DomainDiffItem] = [] |
| 93 | if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) { | 210 | |
| 94 | items.append(item) | 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)) { | 219 | |
| 97 | items.append(item) | 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 | return DomainDiffSection(title: "HTTP", items: items) | 238 | return DomainDiffSection(title: "HTTP", items: items) |
| 100 | } | 239 | } |
| 101 | 240 | ||
| 102 | private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { | 241 | private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { |
| 103 | section( | 242 | DomainDiffSection( |
| 104 | title: "Email Security", | 243 | title: "Email Security", |
| 105 | item: compare( | 244 | items: [ |
| 106 | label: "Summary", | 245 | compare( |
| 107 | oldValue: normalized(emailSummary(from: oldSnapshot)), | 246 | label: "Email Security", |
| 108 | newValue: normalized(emailSummary(from: newSnapshot)) | 247 | oldValue: normalized(emailSummary(from: oldSnapshot)), |
| 109 | ) | 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 | let oldValue = normalized(oldValue) | 261 | let oldValue = normalized(oldValue) |
| 115 | let newValue = normalized(newValue) | 262 | let newValue = normalized(newValue) |
| 116 | let normalizedOldValue = comparisonValue(oldValue) | 263 | let normalizedOldValue = comparisonValue(oldValue) |
| @@ -132,7 +279,68 @@ enum DomainDiffService { | |||
| 132 | changeType = .changed | 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 | private static func normalized(_ value: String?) -> String? { | 346 | private static func normalized(_ value: String?) -> String? { |
| @@ -167,11 +375,9 @@ enum DomainDiffService { | |||
| 167 | snapshot.redirectChain.last?.url | 375 | snapshot.redirectChain.last?.url |
| 168 | } | 376 | } |
| 169 | 377 | ||
| 170 | private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { | 378 | private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? { |
| 171 | if let sslInfo = snapshot.sslInfo { | 379 | guard let sslInfo else { return nil } |
| 172 | return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))" | 380 | return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" |
| 173 | } | ||
| 174 | return snapshot.sslError | ||
| 175 | } | 381 | } |
| 176 | 382 | ||
| 177 | private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { | 383 | private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { |
| @@ -194,18 +400,27 @@ enum DomainDiffService { | |||
| 194 | return snapshot.emailSecurityError | 400 | return snapshot.emailSecurityError |
| 195 | } | 401 | } |
| 196 | 402 | ||
| 197 | private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? { | 403 | private static func normalizedRecordValues(for section: DNSSection?) -> String? { |
| 198 | let parts = snapshot.dnsSections | 404 | guard let section else { return nil } |
| 199 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue } | 405 | let values = (section.records + section.wildcardRecords) |
| 200 | .map { section in | 406 | .map(\.value) |
| 201 | let values = (section.records + section.wildcardRecords) | 407 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } |
| 202 | .map(\.value) | 408 | .sorted() |
| 203 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } | 409 | return values.isEmpty ? nil : values.joined(separator: ",") |
| 204 | .sorted() | 410 | } |
| 205 | .joined(separator: ",") | 411 | |
| 206 | return "\(section.recordType.rawValue):\(values)" | 412 | private static func normalizedTTLValues(for section: DNSSection?) -> String? { |
| 207 | } | 413 | guard let section else { return nil } |
| 208 | .filter { !$0.hasSuffix(":") } | 414 | let values = (section.records + section.wildcardRecords) |
| 209 | return parts.isEmpty ? nil : parts.joined(separator: "|") | 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 | @main | 10 | @main |
| 11 | struct DomainDigApp: App { | 11 | struct DomainDigApp: App { |
| 12 | init() { | ||
| 13 | LocalNotificationService.shared.configureForegroundPresentation() | ||
| 14 | } | ||
| 15 | |||
| 12 | var body: some Scene { | 16 | var body: some Scene { |
| 13 | WindowGroup { | 17 | WindowGroup { |
| 14 | ContentView() | 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 | @MainActor | 166 | @MainActor |
| 163 | @Observable | 167 | @Observable |
| 164 | final class DomainViewModel { | 168 | final class DomainViewModel { |
| @@ -228,11 +232,16 @@ final class DomainViewModel { | |||
| 228 | private(set) var batchCompletedCount = 0 | 232 | private(set) var batchCompletedCount = 0 |
| 229 | private(set) var batchTotalCount = 0 | 233 | private(set) var batchTotalCount = 0 |
| 230 | private(set) var batchLookupRunning = false | 234 | private(set) var batchLookupRunning = false |
| 235 | var latestBatchSweepSummary: BatchSweepSummary? | ||
| 236 | private(set) var notificationsAuthorized = false | ||
| 231 | 237 | ||
| 232 | private var lookupTask: Task<Void, Never>? | 238 | private var lookupTask: Task<Void, Never>? |
| 233 | private var customPortScanTask: Task<Void, Never>? | 239 | private var customPortScanTask: Task<Void, Never>? |
| 240 | private var batchTask: Task<Void, Never>? | ||
| 234 | private var activeLookupID = UUID() | 241 | private var activeLookupID = UUID() |
| 235 | private var lookupStartedAt: Date? | 242 | private var lookupStartedAt: Date? |
| 243 | private var activeBatchDomains: [String] = [] | ||
| 244 | private var lastBatchStartedAt: Date? | ||
| 236 | 245 | ||
| 237 | private static let recentSearchesKey = "recentSearches" | 246 | private static let recentSearchesKey = "recentSearches" |
| 238 | private static let maxRecent = 20 | 247 | private static let maxRecent = 20 |
| @@ -355,8 +364,8 @@ final class DomainViewModel { | |||
| 355 | 364 | ||
| 356 | var batchProgressLabel: String { | 365 | var batchProgressLabel: String { |
| 357 | guard batchTotalCount > 0 else { return "No active batch" } | 366 | guard batchTotalCount > 0 else { return "No active batch" } |
| 358 | let domainLabel = batchCurrentDomain ?? "Preparing" | 367 | let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing" |
| 359 | return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)" | 368 | return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)" |
| 360 | } | 369 | } |
| 361 | 370 | ||
| 362 | var currentBatchResultEntries: [HistoryEntry] { | 371 | var currentBatchResultEntries: [HistoryEntry] { |
| @@ -550,6 +559,10 @@ final class DomainViewModel { | |||
| 550 | func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { | 559 | func refreshTrackedDomain(_ trackedDomain: TrackedDomain) { |
| 551 | refreshingTrackedDomainID = trackedDomain.id | 560 | refreshingTrackedDomainID = trackedDomain.id |
| 552 | domain = trackedDomain.domain | 561 | domain = trackedDomain.domain |
| 562 | Task { [weak self] in | ||
| 563 | guard let self else { return } | ||
| 564 | self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded() | ||
| 565 | } | ||
| 553 | run() | 566 | run() |
| 554 | } | 567 | } |
| 555 | 568 | ||
| @@ -624,6 +637,7 @@ final class DomainViewModel { | |||
| 624 | func reset() { | 637 | func reset() { |
| 625 | lookupTask?.cancel() | 638 | lookupTask?.cancel() |
| 626 | customPortScanTask?.cancel() | 639 | customPortScanTask?.cancel() |
| 640 | batchTask?.cancel() | ||
| 627 | hasRun = false | 641 | hasRun = false |
| 628 | searchedDomain = "" | 642 | searchedDomain = "" |
| 629 | lastLookupDurationMs = nil | 643 | lastLookupDurationMs = nil |
| @@ -649,59 +663,36 @@ final class DomainViewModel { | |||
| 649 | func runBulkLookup() { | 663 | func runBulkLookup() { |
| 650 | let domains = parsedDomains(from: bulkInput) | 664 | let domains = parsedDomains(from: bulkInput) |
| 651 | guard !domains.isEmpty else { return } | 665 | guard !domains.isEmpty else { return } |
| 652 | 666 | startBatchLookup(domains: domains, source: .manual) | |
| 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 | } | ||
| 676 | } | 667 | } |
| 677 | 668 | ||
| 678 | func refreshAllTrackedDomains() { | 669 | func refreshAllTrackedDomains() { |
| 679 | let domains = sortedTrackedDomains.map(\.domain) | 670 | startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) |
| 680 | guard !domains.isEmpty else { return } | 671 | } |
| 681 | 672 | ||
| 682 | clearBatchState() | 673 | func cancelBatchLookup() { |
| 683 | batchLookupSource = .watchlistRefresh | 674 | batchTask?.cancel() |
| 684 | batchTotalCount = domains.count | 675 | batchLookupRunning = false |
| 685 | batchLookupRunning = true | 676 | batchCurrentDomain = nil |
| 686 | batchResults = domains.map { | 677 | activeBatchDomains = [] |
| 687 | BatchLookupResult( | 678 | refreshingTrackedDomainID = nil |
| 688 | domain: $0, | 679 | |
| 689 | historyEntryID: nil, | 680 | for index in batchResults.indices where batchResults[index].status == .pending || batchResults[index].status == .running { |
| 690 | availability: nil, | 681 | batchResults[index] = BatchLookupResult( |
| 691 | primaryIP: nil, | 682 | id: batchResults[index].id, |
| 692 | quickStatus: "Pending", | 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 | timestamp: Date(), | 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 | func runCustomPortScan(ports: [UInt16]) async { | 698 | func runCustomPortScan(ports: [UInt16]) async { |
| @@ -1057,7 +1048,215 @@ final class DomainViewModel { | |||
| 1057 | customPortScanLoading = false | 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 | let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in | 1260 | let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in |
| 1062 | for result in results where result.open { | 1261 | for result in results where result.open { |
| 1063 | group.addTask { | 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 | @discardableResult | 1288 | @discardableResult |
| 1086 | private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { | 1289 | private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { |
| 1087 | guard !searchedDomain.isEmpty else { return nil } | 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 | 1294 | @discardableResult |
| 1090 | let timestamp = Date() | 1295 | private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { |
| 1091 | let snapshot = currentSnapshot | 1296 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id |
| 1092 | let previousSnapshot = previousSnapshot(for: searchedDomain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | 1297 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) |
| 1093 | let changeSummary = previousSnapshot.map { DomainDiffService.summary(from: $0, to: snapshot, generatedAt: timestamp) } | 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 | 1303 | if updateCurrentState { |
| 1096 | currentDiffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] | 1304 | currentChangeSummary = changeSummary |
| 1305 | currentDiffSections = diffSections | ||
| 1306 | } | ||
| 1097 | 1307 | ||
| 1098 | let entry = HistoryEntry( | 1308 | let entry = HistoryEntry( |
| 1099 | domain: searchedDomain, | 1309 | domain: snapshot.domain, |
| 1100 | timestamp: timestamp, | 1310 | timestamp: snapshot.timestamp, |
| 1101 | trackedDomainID: trackedDomainID, | 1311 | trackedDomainID: trackedDomainID, |
| 1102 | dnsSections: dnsSections, | 1312 | dnsSections: snapshot.dnsSections, |
| 1103 | sslInfo: sslInfo, | 1313 | sslInfo: snapshot.sslInfo, |
| 1104 | httpHeaders: httpHeaders, | 1314 | httpHeaders: snapshot.httpHeaders, |
| 1105 | reachabilityResults: reachabilityResults, | 1315 | reachabilityResults: snapshot.reachabilityResults, |
| 1106 | ipGeolocation: ipGeolocation, | 1316 | ipGeolocation: snapshot.ipGeolocation, |
| 1107 | emailSecurity: emailSecurity, | 1317 | emailSecurity: snapshot.emailSecurity, |
| 1108 | mtaSts: emailSecurity?.mtaSts, | 1318 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 1109 | ptrRecord: ptrRecord, | 1319 | ptrRecord: snapshot.ptrRecord, |
| 1110 | redirectChain: redirectChain, | 1320 | redirectChain: snapshot.redirectChain, |
| 1111 | portScanResults: allPortScanResults, | 1321 | portScanResults: snapshot.portScanResults, |
| 1112 | hstsPreloaded: hstsPreloaded, | 1322 | hstsPreloaded: snapshot.hstsPreloaded, |
| 1113 | availabilityResult: availabilityResult, | 1323 | availabilityResult: snapshot.availabilityResult, |
| 1114 | suggestions: suggestions, | 1324 | suggestions: snapshot.suggestions, |
| 1115 | resolverDisplayName: resolverDisplayName, | 1325 | resolverDisplayName: snapshot.resolverDisplayName, |
| 1116 | resolverURLString: resolverURLString, | 1326 | resolverURLString: snapshot.resolverURLString, |
| 1117 | totalLookupDurationMs: lastLookupDurationMs, | 1327 | totalLookupDurationMs: snapshot.totalLookupDurationMs, |
| 1118 | primaryIP: Self.primaryIPAddress(from: snapshot), | 1328 | primaryIP: Self.primaryIPAddress(from: snapshot), |
| 1119 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), | 1329 | finalRedirectURL: Self.finalRedirectTarget(from: snapshot), |
| 1120 | tlsStatusSummary: Self.httpsSummary(from: snapshot), | 1330 | tlsStatusSummary: Self.httpsSummary(from: snapshot), |
| 1121 | emailSecuritySummary: Self.emailSummary(from: snapshot), | 1331 | emailSecuritySummary: Self.emailSummary(from: snapshot), |
| 1122 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, | 1332 | httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, |
| 1123 | changeSummary: changeSummary, | 1333 | changeSummary: changeSummary, |
| 1124 | sslError: sslError, | 1334 | sslError: snapshot.sslError, |
| 1125 | httpHeadersError: httpHeadersError, | 1335 | httpHeadersError: snapshot.httpHeadersError, |
| 1126 | reachabilityError: reachabilityError, | 1336 | reachabilityError: snapshot.reachabilityError, |
| 1127 | ipGeolocationError: ipGeolocationError, | 1337 | ipGeolocationError: snapshot.ipGeolocationError, |
| 1128 | emailSecurityError: emailSecurityError, | 1338 | emailSecurityError: snapshot.emailSecurityError, |
| 1129 | ptrError: ptrError, | 1339 | ptrError: snapshot.ptrError, |
| 1130 | redirectChainError: redirectChainError, | 1340 | redirectChainError: snapshot.redirectChainError, |
| 1131 | portScanError: combinedPortScanError | 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 | history[0] = entry | 1345 | history[0] = entry |
| 1136 | } else { | 1346 | } else { |
| 1137 | history.insert(entry, at: 0) | 1347 | history.insert(entry, at: 0) |
| @@ -1141,13 +1351,17 @@ final class DomainViewModel { | |||
| 1141 | } | 1351 | } |
| 1142 | 1352 | ||
| 1143 | updateTrackedDomainSnapshotMetadata( | 1353 | updateTrackedDomainSnapshotMetadata( |
| 1144 | domain: searchedDomain, | 1354 | domain: snapshot.domain, |
| 1145 | snapshotID: entry.id, | 1355 | snapshotID: entry.id, |
| 1146 | availabilityStatus: availabilityResult?.status, | 1356 | availabilityStatus: snapshot.availabilityResult?.status, |
| 1147 | updatedAt: timestamp, | 1357 | updatedAt: snapshot.timestamp, |
| 1148 | changeSummary: changeSummary | 1358 | changeSummary: changeSummary, |
| 1359 | changeSeverity: changeSummary?.severity, | ||
| 1360 | certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), | ||
| 1361 | certificateDaysRemaining: snapshot.sslInfo?.daysUntilExpiry | ||
| 1149 | ) | 1362 | ) |
| 1150 | persistHistory() | 1363 | persistHistory() |
| 1364 | notifyIfNeeded(for: entry, snapshot: snapshot, previousSnapshot: previousSnapshot) | ||
| 1151 | return entry | 1365 | return entry |
| 1152 | } | 1366 | } |
| 1153 | 1367 | ||
| @@ -1176,7 +1390,10 @@ final class DomainViewModel { | |||
| 1176 | snapshotID: UUID, | 1390 | snapshotID: UUID, |
| 1177 | availabilityStatus: DomainAvailabilityStatus?, | 1391 | availabilityStatus: DomainAvailabilityStatus?, |
| 1178 | updatedAt: Date, | 1392 | updatedAt: Date, |
| 1179 | changeSummary: DomainChangeSummary? | 1393 | changeSummary: DomainChangeSummary?, |
| 1394 | changeSeverity: ChangeSeverity?, | ||
| 1395 | certificateWarningLevel: CertificateWarningLevel, | ||
| 1396 | certificateDaysRemaining: Int? | ||
| 1180 | ) { | 1397 | ) { |
| 1181 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { | 1398 | guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { |
| 1182 | return | 1399 | return |
| @@ -1185,9 +1402,44 @@ final class DomainViewModel { | |||
| 1185 | trackedDomains[index].lastKnownAvailability = availabilityStatus | 1402 | trackedDomains[index].lastKnownAvailability = availabilityStatus |
| 1186 | trackedDomains[index].updatedAt = updatedAt | 1403 | trackedDomains[index].updatedAt = updatedAt |
| 1187 | trackedDomains[index].lastChangeSummary = changeSummary | 1404 | trackedDomains[index].lastChangeSummary = changeSummary |
| 1405 | trackedDomains[index].lastChangeSeverity = changeSeverity | ||
| 1406 | trackedDomains[index].certificateWarningLevel = certificateWarningLevel | ||
| 1407 | trackedDomains[index].certificateDaysRemaining = certificateDaysRemaining | ||
| 1188 | persistTrackedDomains() | 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 | private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? { | 1443 | private func previousSnapshot(for domain: String, trackedDomainID: UUID?, replacingLatest: Bool) -> LookupSnapshot? { |
| 1192 | let matchingEntries = history.filter { entry in | 1444 | let matchingEntries = history.filter { entry in |
| 1193 | if let trackedDomainID { | 1445 | if let trackedDomainID { |
| @@ -1235,7 +1487,10 @@ final class DomainViewModel { | |||
| 1235 | let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { | 1487 | let trackedIndex = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) { |
| 1236 | trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id | 1488 | trackedDomains[trackedIndex].lastSnapshotID = latestEntry.id |
| 1237 | trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary | 1489 | trackedDomains[trackedIndex].lastChangeSummary = latestEntry.changeSummary |
| 1490 | trackedDomains[trackedIndex].lastChangeSeverity = latestEntry.changeSummary?.severity | ||
| 1238 | trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status | 1491 | trackedDomains[trackedIndex].lastKnownAvailability = latestEntry.availabilityResult?.status |
| 1492 | trackedDomains[trackedIndex].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: latestEntry.snapshot) | ||
| 1493 | trackedDomains[trackedIndex].certificateDaysRemaining = latestEntry.sslInfo?.daysUntilExpiry | ||
| 1239 | trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp | 1494 | trackedDomains[trackedIndex].updatedAt = latestEntry.timestamp |
| 1240 | persistTrackedDomains() | 1495 | persistTrackedDomains() |
| 1241 | } | 1496 | } |
| @@ -1271,6 +1526,43 @@ final class DomainViewModel { | |||
| 1271 | return lookupID | 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 | private func clearBatchState() { | 1566 | private func clearBatchState() { |
| 1275 | batchResults = [] | 1567 | batchResults = [] |
| 1276 | batchLookupSource = .manual | 1568 | batchLookupSource = .manual |
| @@ -1278,6 +1570,9 @@ final class DomainViewModel { | |||
| 1278 | batchCompletedCount = 0 | 1570 | batchCompletedCount = 0 |
| 1279 | batchTotalCount = 0 | 1571 | batchTotalCount = 0 |
| 1280 | batchLookupRunning = false | 1572 | batchLookupRunning = false |
| 1573 | latestBatchSweepSummary = nil | ||
| 1574 | activeBatchDomains = [] | ||
| 1575 | batchTask = nil | ||
| 1281 | } | 1576 | } |
| 1282 | 1577 | ||
| 1283 | private func parsedDomains(from input: String) -> [String] { | 1578 | private func parsedDomains(from input: String) -> [String] { |
| @@ -1292,42 +1587,114 @@ final class DomainViewModel { | |||
| 1292 | } | 1587 | } |
| 1293 | 1588 | ||
| 1294 | private func runBatchLookup(domains: [String], source: BatchLookupSource) async { | 1589 | private func runBatchLookup(domains: [String], source: BatchLookupSource) async { |
| 1295 | for (index, domain) in domains.enumerated() { | 1590 | let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1)) |
| 1296 | guard !Task.isCancelled else { break } | 1591 | var nextIndex = 0 |
| 1297 | 1592 | ||
| 1298 | batchCurrentDomain = domain | 1593 | await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in |
| 1299 | if source == .watchlistRefresh { | 1594 | for _ in 0..<concurrencyLimit { |
| 1300 | refreshingTrackedDomainID = trackedDomain(for: domain)?.id | 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) | 1600 | |
| 1303 | 1601 | while let (domain, payload) = await group.next() { | |
| 1304 | let lookupID = beginLookup(for: domain, cancelExistingTask: false) | 1602 | completeBatchLookup(domain: domain, payload: payload) |
| 1305 | let entry = await performLookup(domain: domain, lookupID: lookupID) | 1603 | |
| 1306 | 1604 | if nextIndex < domains.count, !Task.isCancelled { | |
| 1307 | if let entry { | 1605 | let nextDomain = domains[nextIndex] |
| 1308 | updateBatchResult( | 1606 | nextIndex += 1 |
| 1309 | domain: domain, | 1607 | enqueueBatchLookup(domain: nextDomain, source: source, group: &group) |
| 1310 | status: .completed, | 1608 | } |
| 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 | ) | ||
| 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 | batchLookupRunning = false | 1671 | batchLookupRunning = false |
| 1329 | batchCurrentDomain = nil | 1672 | batchCurrentDomain = nil |
| 1673 | activeBatchDomains = [] | ||
| 1330 | refreshingTrackedDomainID = nil | 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 | private func updateBatchResult( | 1700 | private func updateBatchResult( |
| @@ -1348,6 +1715,9 @@ final class DomainViewModel { | |||
| 1348 | availability: entry?.availabilityResult?.status, | 1715 | availability: entry?.availabilityResult?.status, |
| 1349 | primaryIP: entry?.primaryIP, | 1716 | primaryIP: entry?.primaryIP, |
| 1350 | quickStatus: quickStatus, | 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 | timestamp: entry?.timestamp ?? Date(), | 1721 | timestamp: entry?.timestamp ?? Date(), |
| 1352 | status: status, | 1722 | status: status, |
| 1353 | errorMessage: errorMessage | 1723 | errorMessage: errorMessage |
| @@ -1594,8 +1964,8 @@ final class DomainViewModel { | |||
| 1594 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), | 1964 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), |
| 1595 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), | 1965 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), |
| 1596 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), | 1966 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), |
| 1597 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), | 1967 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), |
| 1598 | SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary) | 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 | at: 1 | 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 | return rows | 1997 | return rows |
| 1618 | } | 1998 | } |
| 1619 | 1999 | ||
| @@ -1663,7 +2043,7 @@ final class DomainViewModel { | |||
| 1663 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), | 2043 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), |
| 1664 | InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), | 2044 | InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), |
| 1665 | InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary), | 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 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) | 2047 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) |
| 1668 | ] | 2048 | ] |
| 1669 | if let tlsVersion = sslInfo.tlsVersion { | 2049 | if let tlsVersion = sslInfo.tlsVersion { |
| @@ -1855,7 +2235,8 @@ final class DomainViewModel { | |||
| 1855 | lines.append(" \(item.label): \(item.value)") | 2235 | lines.append(" \(item.label): \(item.value)") |
| 1856 | } | 2236 | } |
| 1857 | if let changeSummary { | 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 | lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") | 2240 | lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") |
| 1860 | lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))") | 2241 | lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))") |
| 1861 | } | 2242 | } |
| @@ -2080,6 +2461,43 @@ final class DomainViewModel { | |||
| 2080 | return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" | 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 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { | 2501 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { |
| 2084 | switch status { | 2502 | switch status { |
| 2085 | case .available: | 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 | struct DomainChangeSummary: Codable, Equatable { | 89 | struct DomainChangeSummary: Codable, Equatable { |
| 52 | let hasChanges: Bool | 90 | let hasChanges: Bool |
| 53 | let changedSections: [String] | 91 | let changedSections: [String] |
| 92 | let message: String | ||
| 93 | let severity: ChangeSeverity | ||
| 54 | let generatedAt: Date | 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 | enum BatchLookupSource: String, Codable { | 121 | enum BatchLookupSource: String, Codable { |
| @@ -73,6 +137,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 73 | let availability: DomainAvailabilityStatus? | 137 | let availability: DomainAvailabilityStatus? |
| 74 | let primaryIP: String? | 138 | let primaryIP: String? |
| 75 | let quickStatus: String | 139 | let quickStatus: String |
| 140 | let summaryMessage: String? | ||
| 141 | let changeSeverity: ChangeSeverity? | ||
| 142 | let certificateWarningLevel: CertificateWarningLevel | ||
| 76 | let timestamp: Date | 143 | let timestamp: Date |
| 77 | let status: BatchLookupStatus | 144 | let status: BatchLookupStatus |
| 78 | let errorMessage: String? | 145 | let errorMessage: String? |
| @@ -84,6 +151,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 84 | availability: DomainAvailabilityStatus?, | 151 | availability: DomainAvailabilityStatus?, |
| 85 | primaryIP: String?, | 152 | primaryIP: String?, |
| 86 | quickStatus: String, | 153 | quickStatus: String, |
| 154 | summaryMessage: String? = nil, | ||
| 155 | changeSeverity: ChangeSeverity? = nil, | ||
| 156 | certificateWarningLevel: CertificateWarningLevel = .none, | ||
| 87 | timestamp: Date, | 157 | timestamp: Date, |
| 88 | status: BatchLookupStatus, | 158 | status: BatchLookupStatus, |
| 89 | errorMessage: String? = nil | 159 | errorMessage: String? = nil |
| @@ -94,12 +164,26 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 94 | self.availability = availability | 164 | self.availability = availability |
| 95 | self.primaryIP = primaryIP | 165 | self.primaryIP = primaryIP |
| 96 | self.quickStatus = quickStatus | 166 | self.quickStatus = quickStatus |
| 167 | self.summaryMessage = summaryMessage | ||
| 168 | self.changeSeverity = changeSeverity | ||
| 169 | self.certificateWarningLevel = certificateWarningLevel | ||
| 97 | self.timestamp = timestamp | 170 | self.timestamp = timestamp |
| 98 | self.status = status | 171 | self.status = status |
| 99 | self.errorMessage = errorMessage | 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 | enum HistoryDateFilter: String, CaseIterable, Identifiable { | 187 | enum HistoryDateFilter: String, CaseIterable, Identifiable { |
| 104 | case today | 188 | case today |
| 105 | case last7Days | 189 | case last7Days |
| @@ -213,6 +297,9 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 213 | var lastKnownAvailability: DomainAvailabilityStatus? | 297 | var lastKnownAvailability: DomainAvailabilityStatus? |
| 214 | var lastSnapshotID: UUID? | 298 | var lastSnapshotID: UUID? |
| 215 | var lastChangeSummary: DomainChangeSummary? | 299 | var lastChangeSummary: DomainChangeSummary? |
| 300 | var lastChangeSeverity: ChangeSeverity? | ||
| 301 | var certificateWarningLevel: CertificateWarningLevel | ||
| 302 | var certificateDaysRemaining: Int? | ||
| 216 | 303 | ||
| 217 | init( | 304 | init( |
| 218 | id: UUID = UUID(), | 305 | id: UUID = UUID(), |
| @@ -223,7 +310,10 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 223 | isPinned: Bool = false, | 310 | isPinned: Bool = false, |
| 224 | lastKnownAvailability: DomainAvailabilityStatus? = nil, | 311 | lastKnownAvailability: DomainAvailabilityStatus? = nil, |
| 225 | lastSnapshotID: UUID? = nil, | 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 | self.id = id | 318 | self.id = id |
| 229 | self.domain = domain | 319 | self.domain = domain |
| @@ -234,6 +324,25 @@ struct TrackedDomain: Codable, Identifiable, Equatable { | |||
| 234 | self.lastKnownAvailability = lastKnownAvailability | 324 | self.lastKnownAvailability = lastKnownAvailability |
| 235 | self.lastSnapshotID = lastSnapshotID | 325 | self.lastSnapshotID = lastSnapshotID |
| 236 | self.lastChangeSummary = lastChangeSummary | 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 | let validFrom: Date | 65 | let validFrom: Date |
| 66 | let validUntil: Date | 66 | let validUntil: Date |
| 67 | 67 | ||
| 68 | if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? { | 68 | let derData = SecCertificateCopyData(leaf) as Data |
| 69 | validFrom = notBefore | 69 | let parsed = DERCertificateParser.parse(derData) |
| 70 | } else { | 70 | |
| 71 | validFrom = Date.distantPast | 71 | if #available(iOS 18.0, *) { |
| 72 | } | 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? { | 78 | if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? { |
| 75 | validUntil = notAfter | 79 | validUntil = notAfter |
| 80 | } else { | ||
| 81 | validUntil = parsed.notAfter ?? Date.distantFuture | ||
| 82 | } | ||
| 76 | } else { | 83 | } else { |
| 77 | validUntil = Date.distantFuture | 84 | validFrom = parsed.notBefore ?? Date.distantPast |
| 85 | validUntil = parsed.notAfter ?? Date.distantFuture | ||
| 78 | } | 86 | } |
| 79 | 87 | ||
| 80 | let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0 | 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 | let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames | 90 | let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames |
| 87 | 91 | ||
| 88 | // Issuer: prefer parsed issuer, fall back to chain's next cert summary | 92 | // Issuer: prefer parsed issuer, fall back to chain's next cert summary |
| @@ -116,6 +120,7 @@ struct SSLCheckService { | |||
| 116 | chain: chain | 120 | chain: chain |
| 117 | ) | 121 | ) |
| 118 | } | 122 | } |
| 123 | |||
| 119 | } | 124 | } |
| 120 | 125 | ||
| 121 | fileprivate struct TLSMetadata { | 126 | fileprivate struct TLSMetadata { |
| @@ -133,6 +138,8 @@ private enum DERCertificateParser { | |||
| 133 | struct Result { | 138 | struct Result { |
| 134 | var issuerCommonName: String? | 139 | var issuerCommonName: String? |
| 135 | var subjectAltNames: [String] = [] | 140 | var subjectAltNames: [String] = [] |
| 141 | var notBefore: Date? | ||
| 142 | var notAfter: Date? | ||
| 136 | } | 143 | } |
| 137 | 144 | ||
| 138 | static func parse(_ data: Data) -> Result { | 145 | static func parse(_ data: Data) -> Result { |
| @@ -171,8 +178,11 @@ private enum DERCertificateParser { | |||
| 171 | offset = issuerSeq.contentStart + issuerSeq.length | 178 | offset = issuerSeq.contentStart + issuerSeq.length |
| 172 | } | 179 | } |
| 173 | 180 | ||
| 174 | // Skip validity | 181 | // Validity |
| 175 | if let validity = readTagAndLength(bytes, offset: offset) { | 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 | offset = validity.contentStart + validity.length | 186 | offset = validity.contentStart + validity.length |
| 177 | } | 187 | } |
| 178 | 188 | ||
| @@ -287,6 +297,43 @@ private enum DERCertificateParser { | |||
| 287 | return sans | 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 | private struct TLV { | 337 | private struct TLV { |
| 291 | let contentStart: Int | 338 | let contentStart: Int |
| 292 | let length: Int | 339 | let length: Int |
DomainDig/WatchlistView.swift +63 −5
| @@ -9,12 +9,20 @@ struct WatchlistView: View { | |||
| 9 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { | 9 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { |
| 10 | Section("Refresh Progress") { | 10 | Section("Refresh Progress") { |
| 11 | VStack(alignment: .leading, spacing: 8) { | 11 | VStack(alignment: .leading, spacing: 8) { |
| 12 | if viewModel.batchLookupRunning { | 12 | ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) |
| 13 | ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1))) | 13 | .tint(.cyan) |
| 14 | .tint(.cyan) | 14 | HStack { |
| 15 | Text(viewModel.batchProgressLabel) | 15 | Text(viewModel.batchProgressLabel) |
| 16 | .font(.system(.caption, design: .monospaced)) | 16 | .font(.system(.caption, design: .monospaced)) |
| 17 | .foregroundStyle(.secondary) | 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 | ForEach(viewModel.batchResults.prefix(5)) { result in | 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 | viewModel.refreshAllTrackedDomains() | 141 | viewModel.refreshAllTrackedDomains() |
| 134 | } | 142 | } |
| 143 | .disabled(viewModel.batchLookupRunning) | ||
| 135 | 144 | ||
| 136 | Button("Export TXT") { | 145 | Button("Export TXT") { |
| 137 | shareTrackedDomains(asCSV: false) | 146 | shareTrackedDomains(asCSV: false) |
| @@ -151,9 +160,19 @@ struct WatchlistView: View { | |||
| 151 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | 160 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 152 | dismiss() | 161 | dismiss() |
| 153 | } | 162 | } |
| 163 | .sheet(item: batchSummaryBinding) { summary in | ||
| 164 | BatchSweepSummaryView(viewModel: viewModel, summary: summary) | ||
| 165 | } | ||
| 154 | .preferredColorScheme(.dark) | 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 | private func deleteFilteredTrackedDomains(at offsets: IndexSet) { | 176 | private func deleteFilteredTrackedDomains(at offsets: IndexSet) { |
| 158 | let domains = offsets.map { viewModel.filteredTrackedDomains[$0] } | 177 | let domains = offsets.map { viewModel.filteredTrackedDomains[$0] } |
| 159 | domains.forEach(viewModel.deleteTrackedDomain) | 178 | domains.forEach(viewModel.deleteTrackedDomain) |
| @@ -197,13 +216,15 @@ struct WatchlistRowView: View { | |||
| 197 | .font(.system(.caption2, design: .monospaced)) | 216 | .font(.system(.caption2, design: .monospaced)) |
| 198 | .foregroundStyle(.secondary) | 217 | .foregroundStyle(.secondary) |
| 199 | 218 | ||
| 219 | indicatorRow | ||
| 220 | |||
| 200 | if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { | 221 | if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { |
| 201 | Text(note) | 222 | Text(note) |
| 202 | .font(.system(.caption, design: .monospaced)) | 223 | .font(.system(.caption, design: .monospaced)) |
| 203 | .foregroundStyle(.secondary) | 224 | .foregroundStyle(.secondary) |
| 204 | .lineLimit(2) | 225 | .lineLimit(2) |
| 205 | } else if let summary = trackedDomain.lastChangeSummary { | 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 | .font(.system(.caption, design: .monospaced)) | 228 | .font(.system(.caption, design: .monospaced)) |
| 208 | .foregroundStyle(.secondary) | 229 | .foregroundStyle(.secondary) |
| 209 | .lineLimit(2) | 230 | .lineLimit(2) |
| @@ -270,6 +291,43 @@ struct WatchlistRowView: View { | |||
| 270 | return Color(.systemGray5).opacity(0.6) | 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 | struct TrackedDomainDetailView: View { | 333 | struct TrackedDomainDetailView: View { |