Commit 3846dc2f5d
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +39 −39
| @@ -12,16 +12,20 @@ | |||
| 12 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; | 12 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; |
| 13 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; | 13 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; |
| 14 | 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; | 14 | 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; |
| 15 | 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; | ||
| 16 | 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; | ||
| 17 | 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; | ||
| 18 | 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; | ||
| 15 | /* End PBXBuildFile section */ | 19 | /* End PBXBuildFile section */ |
| 16 | 20 | ||
| 17 | /* Begin PBXFileReference section */ | 21 | /* Begin PBXFileReference section */ |
| 18 | 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; | 22 | 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 19 | 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 20 | 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; | 23 | 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; |
| 21 | 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; | 24 | 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; |
| 22 | 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; | 25 | 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; |
| 23 | 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; | 26 | 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; |
| 24 | 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; | 27 | 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; |
| 28 | 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 25 | /* End PBXFileReference section */ | 29 | /* End PBXFileReference section */ |
| 26 | 30 | ||
| 27 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | 31 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| @@ -33,11 +37,9 @@ | |||
| 33 | ); | 37 | ); |
| 34 | target = 8B7800682F6090E300933221 /* DomainDig */; | 38 | target = 8B7800682F6090E300933221 /* DomainDig */; |
| 35 | }; | 39 | }; |
| 36 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||
| 37 | 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { | 40 | 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { |
| 38 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | 41 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; |
| 39 | membershipExceptions = ( | 42 | membershipExceptions = ( |
| 40 | Assets.xcassets, | ||
| 41 | BatchResultsView.swift, | 43 | BatchResultsView.swift, |
| 42 | BatchSweepSummaryView.swift, | 44 | BatchSweepSummaryView.swift, |
| 43 | ContentView.swift, | 45 | ContentView.swift, |
| @@ -45,13 +47,13 @@ | |||
| 45 | DomainViewModel.swift, | 47 | DomainViewModel.swift, |
| 46 | ExportPresenter.swift, | 48 | ExportPresenter.swift, |
| 47 | HistoryView.swift, | 49 | HistoryView.swift, |
| 48 | Info.plist, | ||
| 49 | LocalNotificationService.swift, | 50 | LocalNotificationService.swift, |
| 50 | SavedDomainsView.swift, | 51 | SavedDomainsView.swift, |
| 51 | WatchlistView.swift, | 52 | WatchlistView.swift, |
| 52 | ); | 53 | ); |
| 53 | target = 8BF124FB2F70000100933221 /* DomainDigCLI */; | 54 | target = 8BF124FB2F70000100933221 /* DomainDigCLI */; |
| 54 | }; | 55 | }; |
| 56 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||
| 55 | 57 | ||
| 56 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | 58 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| 57 | 8B78006B2F6090E300933221 /* DomainDig */ = { | 59 | 8B78006B2F6090E300933221 /* DomainDig */ = { |
| @@ -132,7 +134,7 @@ | |||
| 132 | }; | 134 | }; |
| 133 | 8BF124FB2F70000100933221 /* DomainDigCLI */ = { | 135 | 8BF124FB2F70000100933221 /* DomainDigCLI */ = { |
| 134 | isa = PBXNativeTarget; | 136 | isa = PBXNativeTarget; |
| 135 | buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; | 137 | buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */; |
| 136 | buildPhases = ( | 138 | buildPhases = ( |
| 137 | 8BF124FC2F70000100933221 /* Sources */, | 139 | 8BF124FC2F70000100933221 /* Sources */, |
| 138 | 8BF124FD2F70000100933221 /* Frameworks */, | 140 | 8BF124FD2F70000100933221 /* Frameworks */, |
| @@ -201,7 +203,6 @@ | |||
| 201 | isa = PBXSourcesBuildPhase; | 203 | isa = PBXSourcesBuildPhase; |
| 202 | buildActionMask = 2147483647; | 204 | buildActionMask = 2147483647; |
| 203 | files = ( | 205 | files = ( |
| 204 | 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, | ||
| 205 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, | 206 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, |
| 206 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, | 207 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, |
| 207 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, | 208 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, |
| @@ -209,6 +210,18 @@ | |||
| 209 | ); | 210 | ); |
| 210 | runOnlyForDeploymentPostprocessing = 0; | 211 | runOnlyForDeploymentPostprocessing = 0; |
| 211 | }; | 212 | }; |
| 213 | 8BF124FC2F70000100933221 /* Sources */ = { | ||
| 214 | isa = PBXSourcesBuildPhase; | ||
| 215 | buildActionMask = 2147483647; | ||
| 216 | files = ( | ||
| 217 | 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, | ||
| 218 | 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */, | ||
| 219 | 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, | ||
| 220 | 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, | ||
| 221 | 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */, | ||
| 222 | ); | ||
| 223 | runOnlyForDeploymentPostprocessing = 0; | ||
| 224 | }; | ||
| 212 | /* End PBXSourcesBuildPhase section */ | 225 | /* End PBXSourcesBuildPhase section */ |
| 213 | 226 | ||
| 214 | /* Begin XCBuildConfiguration section */ | 227 | /* Begin XCBuildConfiguration section */ |
| @@ -341,7 +354,7 @@ | |||
| 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 354 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 342 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 355 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 343 | CODE_SIGN_STYLE = Automatic; | 356 | CODE_SIGN_STYLE = Automatic; |
| 344 | CURRENT_PROJECT_VERSION = 17; | 357 | CURRENT_PROJECT_VERSION = 18; |
| 345 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 358 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 346 | ENABLE_PREVIEWS = YES; | 359 | ENABLE_PREVIEWS = YES; |
| 347 | GENERATE_INFOPLIST_FILE = YES; | 360 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -358,7 +371,7 @@ | |||
| 358 | "$(inherited)", | 371 | "$(inherited)", |
| 359 | "@executable_path/Frameworks", | 372 | "@executable_path/Frameworks", |
| 360 | ); | 373 | ); |
| 361 | MARKETING_VERSION = 2.4.0; | 374 | MARKETING_VERSION = 2.5.0; |
| 362 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 375 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 363 | PRODUCT_NAME = "$(TARGET_NAME)"; | 376 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 364 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 377 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -377,7 +390,7 @@ | |||
| 377 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 379 | CODE_SIGN_STYLE = Automatic; | 392 | CODE_SIGN_STYLE = Automatic; |
| 380 | CURRENT_PROJECT_VERSION = 17; | 393 | CURRENT_PROJECT_VERSION = 18; |
| 381 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 394 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 382 | ENABLE_PREVIEWS = YES; | 395 | ENABLE_PREVIEWS = YES; |
| 383 | GENERATE_INFOPLIST_FILE = YES; | 396 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -394,7 +407,7 @@ | |||
| 394 | "$(inherited)", | 407 | "$(inherited)", |
| 395 | "@executable_path/Frameworks", | 408 | "@executable_path/Frameworks", |
| 396 | ); | 409 | ); |
| 397 | MARKETING_VERSION = 2.4.0; | 410 | MARKETING_VERSION = 2.5.0; |
| 398 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 411 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 399 | PRODUCT_NAME = "$(TARGET_NAME)"; | 412 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 400 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 413 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -407,44 +420,22 @@ | |||
| 407 | }; | 420 | }; |
| 408 | name = Release; | 421 | name = Release; |
| 409 | }; | 422 | }; |
| 410 | 8BF125002F70000100933221 /* Debug */ = { | 423 | 8BBFF0272F987E8700E8E144 /* Debug */ = { |
| 411 | isa = XCBuildConfiguration; | 424 | isa = XCBuildConfiguration; |
| 412 | buildSettings = { | 425 | buildSettings = { |
| 413 | CODE_SIGNING_ALLOWED = NO; | 426 | COPY_PHASE_STRIP = NO; |
| 414 | CODE_SIGN_STYLE = Automatic; | 427 | GCC_DYNAMIC_NO_PIC = NO; |
| 415 | CURRENT_PROJECT_VERSION = 17; | 428 | GCC_OPTIMIZATION_LEVEL = 0; |
| 416 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 417 | ENABLE_TESTABILITY = YES; | ||
| 418 | GENERATE_INFOPLIST_FILE = YES; | ||
| 419 | MACOSX_DEPLOYMENT_TARGET = 14.0; | ||
| 420 | MARKETING_VERSION = 2.4.0; | ||
| 421 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; | ||
| 422 | PRODUCT_NAME = domaindig; | 429 | PRODUCT_NAME = domaindig; |
| 423 | SDKROOT = macosx; | ||
| 424 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | ||
| 425 | SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 426 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | ||
| 427 | SWIFT_VERSION = 5.0; | ||
| 428 | }; | 430 | }; |
| 429 | name = Debug; | 431 | name = Debug; |
| 430 | }; | 432 | }; |
| 431 | 8BF125012F70000100933221 /* Release */ = { | 433 | 8BBFF0282F987E8700E8E144 /* Release */ = { |
| 432 | isa = XCBuildConfiguration; | 434 | isa = XCBuildConfiguration; |
| 433 | buildSettings = { | 435 | buildSettings = { |
| 434 | CODE_SIGNING_ALLOWED = NO; | 436 | COPY_PHASE_STRIP = YES; |
| 435 | CODE_SIGN_STYLE = Automatic; | 437 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; |
| 436 | CURRENT_PROJECT_VERSION = 17; | ||
| 437 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 438 | GENERATE_INFOPLIST_FILE = YES; | ||
| 439 | MACOSX_DEPLOYMENT_TARGET = 14.0; | ||
| 440 | MARKETING_VERSION = 2.4.0; | ||
| 441 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; | ||
| 442 | PRODUCT_NAME = domaindig; | 438 | PRODUCT_NAME = domaindig; |
| 443 | SDKROOT = macosx; | ||
| 444 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | ||
| 445 | SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 446 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | ||
| 447 | SWIFT_VERSION = 5.0; | ||
| 448 | }; | 439 | }; |
| 449 | name = Release; | 440 | name = Release; |
| 450 | }; | 441 | }; |
| @@ -469,6 +460,15 @@ | |||
| 469 | defaultConfigurationIsVisible = 0; | 460 | defaultConfigurationIsVisible = 0; |
| 470 | defaultConfigurationName = Release; | 461 | defaultConfigurationName = Release; |
| 471 | }; | 462 | }; |
| 463 | 8BBFF0292F987EF700E8E144 /* Build configuration list */ = { | ||
| 464 | isa = XCConfigurationList; | ||
| 465 | buildConfigurations = ( | ||
| 466 | 8BBFF0272F987E8700E8E144 /* Debug */, | ||
| 467 | 8BBFF0282F987E8700E8E144 /* Release */, | ||
| 468 | ); | ||
| 469 | defaultConfigurationIsVisible = 0; | ||
| 470 | defaultConfigurationName = Release; | ||
| 471 | }; | ||
| 472 | /* End XCConfigurationList section */ | 472 | /* End XCConfigurationList section */ |
| 473 | }; | 473 | }; |
| 474 | rootObject = 8B7800612F6090E300933221 /* Project object */; | 474 | rootObject = 8B7800612F6090E300933221 /* Project object */; |
DomainDig/BatchResultsView.swift +4 −1
| @@ -58,6 +58,9 @@ struct BatchResultRowView: View { | |||
| 58 | .foregroundStyle(.primary) | 58 | .foregroundStyle(.primary) |
| 59 | .lineLimit(1) | 59 | .lineLimit(1) |
| 60 | Spacer(minLength: 8) | 60 | Spacer(minLength: 8) |
| 61 | Text(result.resultSource.label.lowercased()) | ||
| 62 | .font(.system(.caption2, design: .monospaced)) | ||
| 63 | .foregroundStyle(.secondary) | ||
| 61 | Text(result.quickStatus) | 64 | Text(result.quickStatus) |
| 62 | .font(.system(.caption2, design: .monospaced)) | 65 | .font(.system(.caption2, design: .monospaced)) |
| 63 | .foregroundStyle(quickStatusColor) | 66 | .foregroundStyle(quickStatusColor) |
| @@ -84,7 +87,7 @@ struct BatchResultRowView: View { | |||
| 84 | if let errorMessage = result.errorMessage { | 87 | if let errorMessage = result.errorMessage { |
| 85 | Text(errorMessage) | 88 | Text(errorMessage) |
| 86 | .font(.system(.caption2, design: .monospaced)) | 89 | .font(.system(.caption2, design: .monospaced)) |
| 87 | .foregroundStyle(.red) | 90 | .foregroundStyle(result.status == .failed ? .red : .secondary) |
| 88 | } | 91 | } |
| 89 | } | 92 | } |
| 90 | .frame(maxWidth: .infinity, alignment: .leading) | 93 | .frame(maxWidth: .infinity, alignment: .leading) |
DomainDig/ContentView.swift +50
| @@ -30,6 +30,10 @@ struct ContentView: View { | |||
| 30 | } | 30 | } |
| 31 | if viewModel.hasRun { | 31 | if viewModel.hasRun { |
| 32 | actionButtons | 32 | actionButtons |
| 33 | if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { | ||
| 34 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) | ||
| 35 | .padding(.top, 8) | ||
| 36 | } | ||
| 33 | SummaryView(fields: viewModel.summaryFields) | 37 | SummaryView(fields: viewModel.summaryFields) |
| 34 | .padding(.top, 8) | 38 | .padding(.top, 8) |
| 35 | if let changeSummary = viewModel.currentChangeSummary { | 39 | if let changeSummary = viewModel.currentChangeSummary { |
| @@ -494,6 +498,52 @@ struct SummaryView: View { | |||
| 494 | } | 498 | } |
| 495 | } | 499 | } |
| 496 | 500 | ||
| 501 | struct LookupStatusBannerView: View { | ||
| 502 | let message: String | ||
| 503 | let resultSource: LookupResultSource | ||
| 504 | |||
| 505 | var body: some View { | ||
| 506 | HStack(spacing: 8) { | ||
| 507 | Image(systemName: iconName) | ||
| 508 | .font(.caption) | ||
| 509 | Text(message) | ||
| 510 | .font(.system(.caption, design: .monospaced)) | ||
| 511 | Spacer() | ||
| 512 | } | ||
| 513 | .foregroundStyle(color) | ||
| 514 | .padding(8) | ||
| 515 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 516 | .background(color.opacity(0.12)) | ||
| 517 | .clipShape(RoundedRectangle(cornerRadius: 8)) | ||
| 518 | } | ||
| 519 | |||
| 520 | private var color: Color { | ||
| 521 | switch resultSource { | ||
| 522 | case .live: | ||
| 523 | return .green | ||
| 524 | case .cached: | ||
| 525 | return .secondary | ||
| 526 | case .mixed: | ||
| 527 | return .yellow | ||
| 528 | case .snapshot: | ||
| 529 | return .orange | ||
| 530 | } | ||
| 531 | } | ||
| 532 | |||
| 533 | private var iconName: String { | ||
| 534 | switch resultSource { | ||
| 535 | case .live: | ||
| 536 | return "bolt.horizontal" | ||
| 537 | case .cached: | ||
| 538 | return "clock.arrow.trianglehead.counterclockwise.rotate.90" | ||
| 539 | case .mixed: | ||
| 540 | return "arrow.triangle.branch" | ||
| 541 | case .snapshot: | ||
| 542 | return "archivebox" | ||
| 543 | } | ||
| 544 | } | ||
| 545 | } | ||
| 546 | |||
| 497 | struct DomainChangeSummaryView: View { | 547 | struct DomainChangeSummaryView: View { |
| 498 | let summary: DomainChangeSummary | 548 | let summary: DomainChangeSummary |
| 499 | 549 | ||
DomainDig/DomainViewModel.swift +217 −50
| @@ -184,6 +184,12 @@ final class DomainViewModel { | |||
| 184 | private var activeBatchDomains: [String] = [] | 184 | private var activeBatchDomains: [String] = [] |
| 185 | private var lastBatchStartedAt: Date? | 185 | private var lastBatchStartedAt: Date? |
| 186 | private let reportBuilder = DomainReportBuilder() | 186 | private let reportBuilder = DomainReportBuilder() |
| 187 | private let inspectionService = DomainInspectionService() | ||
| 188 | private(set) var currentResultSource: LookupResultSource = .live | ||
| 189 | private(set) var currentCachedSections: [LookupSectionKind] = [] | ||
| 190 | private(set) var currentStatusMessage: String? | ||
| 191 | private(set) var currentSnapshotTimestamp = Date() | ||
| 192 | private(set) var currentHistoryEntryID: UUID? | ||
| 187 | 193 | ||
| 188 | private static let recentSearchesKey = "recentSearches" | 194 | private static let recentSearchesKey = "recentSearches" |
| 189 | private static let maxRecent = 20 | 195 | private static let maxRecent = 20 |
| @@ -355,9 +361,9 @@ final class DomainViewModel { | |||
| 355 | 361 | ||
| 356 | var currentSnapshot: LookupSnapshot { | 362 | var currentSnapshot: LookupSnapshot { |
| 357 | LookupSnapshot( | 363 | LookupSnapshot( |
| 358 | historyEntryID: nil, | 364 | historyEntryID: currentHistoryEntryID, |
| 359 | domain: searchedDomain, | 365 | domain: searchedDomain, |
| 360 | timestamp: Date(), | 366 | timestamp: currentSnapshotTimestamp, |
| 361 | trackedDomainID: currentTrackedDomain?.id, | 367 | trackedDomainID: currentTrackedDomain?.id, |
| 362 | resolverDisplayName: resolverDisplayName, | 368 | resolverDisplayName: resolverDisplayName, |
| 363 | resolverURLString: resolverURLString, | 369 | resolverURLString: resolverURLString, |
| @@ -393,7 +399,9 @@ final class DomainViewModel { | |||
| 393 | portScanResults: allPortScanResults, | 399 | portScanResults: allPortScanResults, |
| 394 | portScanError: combinedPortScanError, | 400 | portScanError: combinedPortScanError, |
| 395 | changeSummary: currentChangeSummary, | 401 | changeSummary: currentChangeSummary, |
| 396 | isLive: true | 402 | resultSource: currentResultSource, |
| 403 | cachedSections: currentCachedSections, | ||
| 404 | statusMessage: currentStatusMessage | ||
| 397 | ) | 405 | ) |
| 398 | } | 406 | } |
| 399 | 407 | ||
| @@ -746,51 +754,163 @@ final class DomainViewModel { | |||
| 746 | } | 754 | } |
| 747 | 755 | ||
| 748 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { | 756 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 749 | await withTaskGroup(of: Void.self) { group in | 757 | let previous = previousSnapshot( |
| 750 | group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } | 758 | for: domain, |
| 751 | group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } | 759 | trackedDomainID: currentTrackedDomain?.id, |
| 752 | group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } | 760 | replacingLatest: false |
| 753 | group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } | 761 | ) |
| 754 | group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) } | 762 | let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous) |
| 755 | group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) } | 763 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } |
| 756 | } | ||
| 757 | 764 | ||
| 758 | await withTaskGroup(of: Void.self) { group in | 765 | let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous) |
| 759 | group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } | 766 | applySnapshot(snapshot) |
| 760 | group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } | 767 | lastLookupDurationMs = snapshot.totalLookupDurationMs |
| 761 | group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } | 768 | refreshingTrackedDomainID = nil |
| 762 | group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } | 769 | |
| 770 | guard snapshot.statusMessage == nil else { | ||
| 771 | return history.first(where: { $0.id == snapshot.historyEntryID }) | ||
| 763 | } | 772 | } |
| 764 | 773 | ||
| 765 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } | 774 | return saveHistoryEntry(replaceLatest: false) |
| 775 | } | ||
| 766 | 776 | ||
| 767 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | 777 | private func applySnapshot(_ snapshot: LookupSnapshot) { |
| 768 | let primaryIP = primaryIPAddress(from: dnsSections) | 778 | currentHistoryEntryID = snapshot.historyEntryID |
| 779 | currentSnapshotTimestamp = snapshot.timestamp | ||
| 780 | currentResultSource = snapshot.resultSource | ||
| 781 | currentCachedSections = snapshot.cachedSections | ||
| 782 | currentStatusMessage = snapshot.statusMessage | ||
| 783 | currentChangeSummary = snapshot.changeSummary | ||
| 784 | currentDiffSections = [] | ||
| 785 | ownershipDiff = [] | ||
| 769 | 786 | ||
| 770 | await withTaskGroup(of: Void.self) { group in | 787 | dnsSections = snapshot.dnsSections |
| 771 | group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) } | 788 | dnsError = snapshot.dnsError |
| 772 | if let primaryIP { | 789 | availabilityResult = snapshot.availabilityResult |
| 773 | group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) } | 790 | suggestions = snapshot.suggestions |
| 774 | group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) } | 791 | sslInfo = snapshot.sslInfo |
| 775 | } else { | 792 | sslError = snapshot.sslError |
| 776 | group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) } | 793 | hstsPreloaded = snapshot.hstsPreloaded |
| 777 | } | 794 | httpHeaders = snapshot.httpHeaders |
| 778 | } | 795 | httpSecurityGrade = snapshot.httpSecurityGrade |
| 796 | httpStatusCode = snapshot.httpStatusCode | ||
| 797 | httpResponseTimeMs = snapshot.httpResponseTimeMs | ||
| 798 | httpProtocol = snapshot.httpProtocol | ||
| 799 | http3Advertised = snapshot.http3Advertised | ||
| 800 | httpHeadersError = snapshot.httpHeadersError | ||
| 801 | reachabilityResults = snapshot.reachabilityResults | ||
| 802 | reachabilityError = snapshot.reachabilityError | ||
| 803 | ipGeolocation = snapshot.ipGeolocation | ||
| 804 | ipGeolocationError = snapshot.ipGeolocationError | ||
| 805 | emailSecurity = snapshot.emailSecurity | ||
| 806 | emailSecurityError = snapshot.emailSecurityError | ||
| 807 | ownershipResult = snapshot.ownership | ||
| 808 | ownershipError = snapshot.ownershipError | ||
| 809 | ptrRecord = snapshot.ptrRecord | ||
| 810 | ptrError = snapshot.ptrError | ||
| 811 | redirectChain = snapshot.redirectChain | ||
| 812 | redirectChainError = snapshot.redirectChainError | ||
| 813 | subdomains = snapshot.subdomains | ||
| 814 | subdomainsError = snapshot.subdomainsError | ||
| 815 | portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } | ||
| 816 | customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } | ||
| 817 | portScanError = snapshot.portScanError | ||
| 818 | customPortScanError = nil | ||
| 779 | 819 | ||
| 780 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } | 820 | dnsLoading = false |
| 821 | availabilityLoading = false | ||
| 822 | suggestionsLoading = false | ||
| 823 | sslLoading = false | ||
| 824 | hstsLoading = false | ||
| 825 | httpHeadersLoading = false | ||
| 826 | reachabilityLoading = false | ||
| 827 | ipGeolocationLoading = false | ||
| 828 | emailSecurityLoading = false | ||
| 829 | ownershipLoading = false | ||
| 830 | ptrLoading = false | ||
| 831 | redirectChainLoading = false | ||
| 832 | subdomainsLoading = false | ||
| 833 | portScanLoading = false | ||
| 834 | customPortScanLoading = false | ||
| 835 | } | ||
| 781 | 836 | ||
| 782 | if availabilityResult?.status == .registered { | 837 | private static func resolvedSnapshotAfterFallback( |
| 783 | await runSuggestions(domain: domain, lookupID: lookupID) | 838 | _ snapshot: LookupSnapshot, |
| 784 | } else { | 839 | previousSnapshot: LookupSnapshot? |
| 785 | suggestions = [] | 840 | ) -> LookupSnapshot { |
| 786 | suggestionsLoading = false | 841 | guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else { |
| 842 | return snapshot | ||
| 843 | } | ||
| 844 | |||
| 845 | return LookupSnapshot( | ||
| 846 | historyEntryID: previousSnapshot.historyEntryID, | ||
| 847 | domain: previousSnapshot.domain, | ||
| 848 | timestamp: previousSnapshot.timestamp, | ||
| 849 | trackedDomainID: previousSnapshot.trackedDomainID, | ||
| 850 | resolverDisplayName: previousSnapshot.resolverDisplayName, | ||
| 851 | resolverURLString: previousSnapshot.resolverURLString, | ||
| 852 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, | ||
| 853 | dnsSections: previousSnapshot.dnsSections, | ||
| 854 | dnsError: previousSnapshot.dnsError, | ||
| 855 | availabilityResult: previousSnapshot.availabilityResult, | ||
| 856 | suggestions: previousSnapshot.suggestions, | ||
| 857 | sslInfo: previousSnapshot.sslInfo, | ||
| 858 | sslError: previousSnapshot.sslError, | ||
| 859 | hstsPreloaded: previousSnapshot.hstsPreloaded, | ||
| 860 | httpHeaders: previousSnapshot.httpHeaders, | ||
| 861 | httpSecurityGrade: previousSnapshot.httpSecurityGrade, | ||
| 862 | httpStatusCode: previousSnapshot.httpStatusCode, | ||
| 863 | httpResponseTimeMs: previousSnapshot.httpResponseTimeMs, | ||
| 864 | httpProtocol: previousSnapshot.httpProtocol, | ||
| 865 | http3Advertised: previousSnapshot.http3Advertised, | ||
| 866 | httpHeadersError: previousSnapshot.httpHeadersError, | ||
| 867 | reachabilityResults: previousSnapshot.reachabilityResults, | ||
| 868 | reachabilityError: previousSnapshot.reachabilityError, | ||
| 869 | ipGeolocation: previousSnapshot.ipGeolocation, | ||
| 870 | ipGeolocationError: previousSnapshot.ipGeolocationError, | ||
| 871 | emailSecurity: previousSnapshot.emailSecurity, | ||
| 872 | emailSecurityError: previousSnapshot.emailSecurityError, | ||
| 873 | ownership: previousSnapshot.ownership, | ||
| 874 | ownershipError: previousSnapshot.ownershipError, | ||
| 875 | ptrRecord: previousSnapshot.ptrRecord, | ||
| 876 | ptrError: previousSnapshot.ptrError, | ||
| 877 | redirectChain: previousSnapshot.redirectChain, | ||
| 878 | redirectChainError: previousSnapshot.redirectChainError, | ||
| 879 | subdomains: previousSnapshot.subdomains, | ||
| 880 | subdomainsError: previousSnapshot.subdomainsError, | ||
| 881 | portScanResults: previousSnapshot.portScanResults, | ||
| 882 | portScanError: previousSnapshot.portScanError, | ||
| 883 | changeSummary: previousSnapshot.changeSummary, | ||
| 884 | resultSource: .snapshot, | ||
| 885 | cachedSections: [], | ||
| 886 | statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))" | ||
| 887 | ) | ||
| 888 | } | ||
| 889 | |||
| 890 | private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool { | ||
| 891 | let candidateMessages = [ | ||
| 892 | snapshot.dnsError, | ||
| 893 | snapshot.httpHeadersError, | ||
| 894 | snapshot.sslError, | ||
| 895 | snapshot.ownershipError, | ||
| 896 | snapshot.subdomainsError, | ||
| 897 | snapshot.redirectChainError, | ||
| 898 | snapshot.ipGeolocationError | ||
| 899 | ] | ||
| 900 | .compactMap { $0?.lowercased() } | ||
| 901 | |||
| 902 | guard !candidateMessages.isEmpty else { return false } | ||
| 903 | let failedDueToConnectivity = candidateMessages.allSatisfy { message in | ||
| 904 | message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:") | ||
| 787 | } | 905 | } |
| 788 | 906 | ||
| 789 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } | 907 | let hasMaterialData = !snapshot.dnsSections.isEmpty |
| 790 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } | 908 | || !snapshot.httpHeaders.isEmpty |
| 791 | let entry = saveHistoryEntry(replaceLatest: false) | 909 | || snapshot.sslInfo != nil |
| 792 | refreshingTrackedDomainID = nil | 910 | || snapshot.ownership != nil |
| 793 | return entry | 911 | || !snapshot.subdomains.isEmpty |
| 912 | |||
| 913 | return failedDueToConnectivity && !hasMaterialData | ||
| 794 | } | 914 | } |
| 795 | 915 | ||
| 796 | private func runDNS(domain: String, lookupID: UUID) async { | 916 | private func runDNS(domain: String, lookupID: UUID) async { |
| @@ -1044,11 +1164,12 @@ final class DomainViewModel { | |||
| 1044 | customPortScanLoading = false | 1164 | customPortScanLoading = false |
| 1045 | } | 1165 | } |
| 1046 | 1166 | ||
| 1047 | private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { | 1167 | private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? { |
| 1048 | guard !Task.isCancelled else { return nil } | 1168 | guard !Task.isCancelled else { return nil } |
| 1049 | let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain) | 1169 | let inspectionService = DomainInspectionService() |
| 1170 | let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot) | ||
| 1050 | guard !Task.isCancelled else { return nil } | 1171 | guard !Task.isCancelled else { return nil } |
| 1051 | return BatchLookupPayload(snapshot: snapshot) | 1172 | return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot)) |
| 1052 | } | 1173 | } |
| 1053 | 1174 | ||
| 1054 | private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] { | 1175 | private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] { |
| @@ -1141,6 +1262,10 @@ final class DomainViewModel { | |||
| 1141 | portScanError: snapshot.portScanError | 1262 | portScanError: snapshot.portScanError |
| 1142 | ) | 1263 | ) |
| 1143 | 1264 | ||
| 1265 | if updateCurrentState { | ||
| 1266 | currentHistoryEntryID = entry.id | ||
| 1267 | } | ||
| 1268 | |||
| 1144 | if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame { | 1269 | if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame { |
| 1145 | history[0] = entry | 1270 | history[0] = entry |
| 1146 | } else { | 1271 | } else { |
| @@ -1318,6 +1443,11 @@ final class DomainViewModel { | |||
| 1318 | addRecentSearch(target) | 1443 | addRecentSearch(target) |
| 1319 | searchedDomain = target | 1444 | searchedDomain = target |
| 1320 | hasRun = true | 1445 | hasRun = true |
| 1446 | currentHistoryEntryID = nil | ||
| 1447 | currentSnapshotTimestamp = Date() | ||
| 1448 | currentResultSource = .live | ||
| 1449 | currentCachedSections = [] | ||
| 1450 | currentStatusMessage = nil | ||
| 1321 | currentDiffSections = [] | 1451 | currentDiffSections = [] |
| 1322 | currentChangeSummary = nil | 1452 | currentChangeSummary = nil |
| 1323 | ownershipDiff = [] | 1453 | ownershipDiff = [] |
| @@ -1424,9 +1554,10 @@ final class DomainViewModel { | |||
| 1424 | refreshingTrackedDomainID = trackedDomain(for: domain)?.id | 1554 | refreshingTrackedDomainID = trackedDomain(for: domain)?.id |
| 1425 | } | 1555 | } |
| 1426 | updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) | 1556 | updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) |
| 1557 | let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false) | ||
| 1427 | 1558 | ||
| 1428 | group.addTask { [domain] in | 1559 | group.addTask { [domain, previousSnapshot] in |
| 1429 | let payload = await Self.performBatchLookup(domain: domain) | 1560 | let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot) |
| 1430 | return (domain, payload) | 1561 | return (domain, payload) |
| 1431 | } | 1562 | } |
| 1432 | } | 1563 | } |
| @@ -1441,13 +1572,21 @@ final class DomainViewModel { | |||
| 1441 | status: .failed, | 1572 | status: .failed, |
| 1442 | quickStatus: "Failed", | 1573 | quickStatus: "Failed", |
| 1443 | entry: nil, | 1574 | entry: nil, |
| 1575 | resultSource: .live, | ||
| 1444 | errorMessage: "Lookup cancelled" | 1576 | errorMessage: "Lookup cancelled" |
| 1445 | ) | 1577 | ) |
| 1446 | batchCompletedCount += 1 | 1578 | batchCompletedCount += 1 |
| 1447 | return | 1579 | return |
| 1448 | } | 1580 | } |
| 1449 | 1581 | ||
| 1450 | let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) | 1582 | let entry: HistoryEntry? |
| 1583 | if payload.snapshot.statusMessage == nil { | ||
| 1584 | entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) | ||
| 1585 | } else { | ||
| 1586 | entry = payload.snapshot.historyEntryID.flatMap { id in | ||
| 1587 | history.first(where: { $0.id == id }) | ||
| 1588 | } | ||
| 1589 | } | ||
| 1451 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) | 1590 | let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) |
| 1452 | let quickStatus: String | 1591 | let quickStatus: String |
| 1453 | if entry?.changeSummary?.hasChanges == true { | 1592 | if entry?.changeSummary?.hasChanges == true { |
| @@ -1463,7 +1602,8 @@ final class DomainViewModel { | |||
| 1463 | status: .completed, | 1602 | status: .completed, |
| 1464 | quickStatus: quickStatus, | 1603 | quickStatus: quickStatus, |
| 1465 | entry: entry, | 1604 | entry: entry, |
| 1466 | errorMessage: nil | 1605 | resultSource: payload.snapshot.resultSource, |
| 1606 | errorMessage: payload.snapshot.statusMessage | ||
| 1467 | ) | 1607 | ) |
| 1468 | batchCompletedCount += 1 | 1608 | batchCompletedCount += 1 |
| 1469 | } | 1609 | } |
| @@ -1503,6 +1643,7 @@ final class DomainViewModel { | |||
| 1503 | status: BatchLookupStatus, | 1643 | status: BatchLookupStatus, |
| 1504 | quickStatus: String, | 1644 | quickStatus: String, |
| 1505 | entry: HistoryEntry?, | 1645 | entry: HistoryEntry?, |
| 1646 | resultSource: LookupResultSource = .live, | ||
| 1506 | errorMessage: String? | 1647 | errorMessage: String? |
| 1507 | ) { | 1648 | ) { |
| 1508 | guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { | 1649 | guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { |
| @@ -1513,6 +1654,7 @@ final class DomainViewModel { | |||
| 1513 | id: batchResults[index].id, | 1654 | id: batchResults[index].id, |
| 1514 | domain: domain, | 1655 | domain: domain, |
| 1515 | historyEntryID: entry?.id, | 1656 | historyEntryID: entry?.id, |
| 1657 | resultSource: resultSource, | ||
| 1516 | availability: entry?.availabilityResult?.status, | 1658 | availability: entry?.availabilityResult?.status, |
| 1517 | primaryIP: entry?.primaryIP, | 1659 | primaryIP: entry?.primaryIP, |
| 1518 | quickStatus: quickStatus, | 1660 | quickStatus: quickStatus, |
| @@ -1573,6 +1715,11 @@ final class DomainViewModel { | |||
| 1573 | customPortResults = [] | 1715 | customPortResults = [] |
| 1574 | customPortScanError = nil | 1716 | customPortScanError = nil |
| 1575 | customPortScanLoading = false | 1717 | customPortScanLoading = false |
| 1718 | currentHistoryEntryID = nil | ||
| 1719 | currentSnapshotTimestamp = Date() | ||
| 1720 | currentResultSource = .live | ||
| 1721 | currentCachedSections = [] | ||
| 1722 | currentStatusMessage = nil | ||
| 1576 | } | 1723 | } |
| 1577 | 1724 | ||
| 1578 | private func setAllLoadingStates(_ loading: Bool) { | 1725 | private func setAllLoadingStates(_ loading: Bool) { |
| @@ -1715,7 +1862,9 @@ final class DomainViewModel { | |||
| 1715 | portScanResults: [], | 1862 | portScanResults: [], |
| 1716 | portScanError: nil, | 1863 | portScanError: nil, |
| 1717 | changeSummary: trackedDomain.lastChangeSummary, | 1864 | changeSummary: trackedDomain.lastChangeSummary, |
| 1718 | isLive: false | 1865 | resultSource: .snapshot, |
| 1866 | cachedSections: [], | ||
| 1867 | statusMessage: nil | ||
| 1719 | ) | 1868 | ) |
| 1720 | } | 1869 | } |
| 1721 | 1870 | ||
| @@ -1801,7 +1950,8 @@ final class DomainViewModel { | |||
| 1801 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), | 1950 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), |
| 1802 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), | 1951 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), |
| 1803 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), | 1952 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), |
| 1804 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary) | 1953 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), |
| 1954 | SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) | ||
| 1805 | ] | 1955 | ] |
| 1806 | } | 1956 | } |
| 1807 | 1957 | ||
| @@ -1809,7 +1959,7 @@ final class DomainViewModel { | |||
| 1809 | var rows = [ | 1959 | var rows = [ |
| 1810 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), | 1960 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), |
| 1811 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), | 1961 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), |
| 1812 | InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), | 1962 | InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), |
| 1813 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) | 1963 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) |
| 1814 | ] | 1964 | ] |
| 1815 | rows.insert( | 1965 | rows.insert( |
| @@ -2083,7 +2233,7 @@ final class DomainViewModel { | |||
| 2083 | "DomainDig Export", | 2233 | "DomainDig Export", |
| 2084 | "Domain: \(snapshot.domain)", | 2234 | "Domain: \(snapshot.domain)", |
| 2085 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", | 2235 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", |
| 2086 | "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", | 2236 | "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)", |
| 2087 | "Resolver: \(snapshot.resolverDisplayName)", | 2237 | "Resolver: \(snapshot.resolverDisplayName)", |
| 2088 | "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", | 2238 | "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", |
| 2089 | "Tracked: \(trackedDomain == nil ? "No" : "Yes")" | 2239 | "Tracked: \(trackedDomain == nil ? "No" : "Yes")" |
| @@ -2417,6 +2567,23 @@ final class DomainViewModel { | |||
| 2417 | } | 2567 | } |
| 2418 | } | 2568 | } |
| 2419 | 2569 | ||
| 2570 | private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone { | ||
| 2571 | if snapshot.statusMessage != nil { | ||
| 2572 | return .warning | ||
| 2573 | } | ||
| 2574 | |||
| 2575 | switch snapshot.resultSource { | ||
| 2576 | case .live: | ||
| 2577 | return .success | ||
| 2578 | case .cached: | ||
| 2579 | return .secondary | ||
| 2580 | case .mixed: | ||
| 2581 | return .warning | ||
| 2582 | case .snapshot: | ||
| 2583 | return .warning | ||
| 2584 | } | ||
| 2585 | } | ||
| 2586 | |||
| 2420 | private static func securityGradeTone(_ grade: String) -> ResultTone { | 2587 | private static func securityGradeTone(_ grade: String) -> ResultTone { |
| 2421 | switch grade { | 2588 | switch grade { |
| 2422 | case "A", "B": | 2589 | case "A", "B": |
DomainDig/LookupRuntime.swift added +289
| @@ -0,0 +1,289 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct CachedLookupResult<Value> { | ||
| 4 | let value: Value | ||
| 5 | let source: LookupResultSource | ||
| 6 | } | ||
| 7 | |||
| 8 | actor LookupRuntime { | ||
| 9 | static let shared = LookupRuntime() | ||
| 10 | |||
| 11 | private let ttl: TimeInterval = 300 | ||
| 12 | |||
| 13 | private enum RequestKey: Hashable { | ||
| 14 | case domain(String, LookupSectionKind) | ||
| 15 | case subject(String, LookupSectionKind) | ||
| 16 | } | ||
| 17 | |||
| 18 | private enum RateLimitBucket: Hashable { | ||
| 19 | case crtsh | ||
| 20 | case rdap | ||
| 21 | case ipGeolocation | ||
| 22 | |||
| 23 | var minimumSpacing: TimeInterval { | ||
| 24 | switch self { | ||
| 25 | case .crtsh: | ||
| 26 | return 1.0 | ||
| 27 | case .rdap: | ||
| 28 | return 0.75 | ||
| 29 | case .ipGeolocation: | ||
| 30 | return 0.75 | ||
| 31 | } | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | private enum CachedPayload { | ||
| 36 | case dns(ServiceResult<[DNSSection]>) | ||
| 37 | case availability(DomainAvailabilityResult) | ||
| 38 | case ssl(ServiceResult<SSLCertificateInfo>) | ||
| 39 | case hsts(Bool?) | ||
| 40 | case http(ServiceResult<HTTPHeadersResult>) | ||
| 41 | case reachability(ServiceResult<[PortReachability]>) | ||
| 42 | case ownership(ServiceResult<DomainOwnership>) | ||
| 43 | case redirect(ServiceResult<[RedirectHop]>) | ||
| 44 | case subdomains(ServiceResult<[DiscoveredSubdomain]>) | ||
| 45 | case portScan(ServiceResult<[PortScanResult]>) | ||
| 46 | case email(ServiceResult<EmailSecurityResult>) | ||
| 47 | case ptr(ServiceResult<String>) | ||
| 48 | case ipGeolocation(ServiceResult<IPGeolocation>) | ||
| 49 | case suggestions([DomainSuggestionResult]) | ||
| 50 | } | ||
| 51 | |||
| 52 | private struct CacheEntry { | ||
| 53 | let payload: CachedPayload | ||
| 54 | let expiresAt: Date | ||
| 55 | } | ||
| 56 | |||
| 57 | private var cache: [RequestKey: CacheEntry] = [:] | ||
| 58 | private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:] | ||
| 59 | private var nextAllowedAt: [RateLimitBucket: Date] = [:] | ||
| 60 | |||
| 61 | func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> { | ||
| 62 | await execute( | ||
| 63 | key: .domain(domain, .dns), | ||
| 64 | extract: { payload in | ||
| 65 | guard case let .dns(result) = payload else { return nil } | ||
| 66 | return result | ||
| 67 | }, | ||
| 68 | operation: { | ||
| 69 | .dns(await DNSLookupService.lookupAll(domain: domain)) | ||
| 70 | } | ||
| 71 | ) | ||
| 72 | } | ||
| 73 | |||
| 74 | func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> { | ||
| 75 | await execute( | ||
| 76 | key: .domain(domain, .availability), | ||
| 77 | extract: { payload in | ||
| 78 | guard case let .availability(result) = payload else { return nil } | ||
| 79 | return result | ||
| 80 | }, | ||
| 81 | operation: { | ||
| 82 | .availability(await DomainAvailabilityService.check(domain: domain)) | ||
| 83 | } | ||
| 84 | ) | ||
| 85 | } | ||
| 86 | |||
| 87 | func ssl(domain: String) async -> CachedLookupResult<ServiceResult<SSLCertificateInfo>> { | ||
| 88 | await execute( | ||
| 89 | key: .domain(domain, .ssl), | ||
| 90 | extract: { payload in | ||
| 91 | guard case let .ssl(result) = payload else { return nil } | ||
| 92 | return result | ||
| 93 | }, | ||
| 94 | operation: { | ||
| 95 | .ssl(await SSLCheckService.check(domain: domain)) | ||
| 96 | } | ||
| 97 | ) | ||
| 98 | } | ||
| 99 | |||
| 100 | func hsts(domain: String) async -> CachedLookupResult<Bool?> { | ||
| 101 | await execute( | ||
| 102 | key: .domain(domain, .hsts), | ||
| 103 | extract: { payload in | ||
| 104 | guard case let .hsts(result) = payload else { return nil } | ||
| 105 | return result | ||
| 106 | }, | ||
| 107 | operation: { | ||
| 108 | .hsts(await SSLCheckService.checkHSTSPreload(domain: domain)) | ||
| 109 | } | ||
| 110 | ) | ||
| 111 | } | ||
| 112 | |||
| 113 | func http(domain: String) async -> CachedLookupResult<ServiceResult<HTTPHeadersResult>> { | ||
| 114 | await execute( | ||
| 115 | key: .domain(domain, .httpHeaders), | ||
| 116 | extract: { payload in | ||
| 117 | guard case let .http(result) = payload else { return nil } | ||
| 118 | return result | ||
| 119 | }, | ||
| 120 | operation: { | ||
| 121 | .http(await HTTPHeadersService.fetch(domain: domain)) | ||
| 122 | } | ||
| 123 | ) | ||
| 124 | } | ||
| 125 | |||
| 126 | func reachability(domain: String) async -> CachedLookupResult<ServiceResult<[PortReachability]>> { | ||
| 127 | await execute( | ||
| 128 | key: .domain(domain, .reachability), | ||
| 129 | extract: { payload in | ||
| 130 | guard case let .reachability(result) = payload else { return nil } | ||
| 131 | return result | ||
| 132 | }, | ||
| 133 | operation: { | ||
| 134 | .reachability(await ReachabilityService.checkAll(domain: domain)) | ||
| 135 | } | ||
| 136 | ) | ||
| 137 | } | ||
| 138 | |||
| 139 | func ownership(domain: String) async -> CachedLookupResult<ServiceResult<DomainOwnership>> { | ||
| 140 | await execute( | ||
| 141 | key: .domain(domain, .ownership), | ||
| 142 | rateLimitBucket: .rdap, | ||
| 143 | extract: { payload in | ||
| 144 | guard case let .ownership(result) = payload else { return nil } | ||
| 145 | return result | ||
| 146 | }, | ||
| 147 | operation: { | ||
| 148 | .ownership(await DomainOwnershipService.lookup(domain: domain)) | ||
| 149 | } | ||
| 150 | ) | ||
| 151 | } | ||
| 152 | |||
| 153 | func redirectChain(domain: String) async -> CachedLookupResult<ServiceResult<[RedirectHop]>> { | ||
| 154 | await execute( | ||
| 155 | key: .domain(domain, .redirectChain), | ||
| 156 | extract: { payload in | ||
| 157 | guard case let .redirect(result) = payload else { return nil } | ||
| 158 | return result | ||
| 159 | }, | ||
| 160 | operation: { | ||
| 161 | .redirect(await RedirectChainService.trace(domain: domain)) | ||
| 162 | } | ||
| 163 | ) | ||
| 164 | } | ||
| 165 | |||
| 166 | func subdomains(domain: String) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> { | ||
| 167 | await execute( | ||
| 168 | key: .domain(domain, .subdomains), | ||
| 169 | rateLimitBucket: .crtsh, | ||
| 170 | extract: { payload in | ||
| 171 | guard case let .subdomains(result) = payload else { return nil } | ||
| 172 | return result | ||
| 173 | }, | ||
| 174 | operation: { | ||
| 175 | .subdomains(await SubdomainDiscoveryService.discover(for: domain)) | ||
| 176 | } | ||
| 177 | ) | ||
| 178 | } | ||
| 179 | |||
| 180 | func portScan(domain: String) async -> CachedLookupResult<ServiceResult<[PortScanResult]>> { | ||
| 181 | await execute( | ||
| 182 | key: .domain(domain, .portScan), | ||
| 183 | extract: { payload in | ||
| 184 | guard case let .portScan(result) = payload else { return nil } | ||
| 185 | return result | ||
| 186 | }, | ||
| 187 | operation: { | ||
| 188 | .portScan(await PortScanService.scanAll(domain: domain)) | ||
| 189 | } | ||
| 190 | ) | ||
| 191 | } | ||
| 192 | |||
| 193 | func email(domain: String, txtRecords: [DNSRecord]) async -> CachedLookupResult<ServiceResult<EmailSecurityResult>> { | ||
| 194 | await execute( | ||
| 195 | key: .domain(domain, .emailSecurity), | ||
| 196 | extract: { payload in | ||
| 197 | guard case let .email(result) = payload else { return nil } | ||
| 198 | return result | ||
| 199 | }, | ||
| 200 | operation: { | ||
| 201 | .email(await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)) | ||
| 202 | } | ||
| 203 | ) | ||
| 204 | } | ||
| 205 | |||
| 206 | func ptr(ip: String, resolverURLString: String) async -> CachedLookupResult<ServiceResult<String>> { | ||
| 207 | await execute( | ||
| 208 | key: .subject("\(resolverURLString)|\(ip)", .ptr), | ||
| 209 | extract: { payload in | ||
| 210 | guard case let .ptr(result) = payload else { return nil } | ||
| 211 | return result | ||
| 212 | }, | ||
| 213 | operation: { | ||
| 214 | .ptr(await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)) | ||
| 215 | } | ||
| 216 | ) | ||
| 217 | } | ||
| 218 | |||
| 219 | func ipGeolocation(ip: String) async -> CachedLookupResult<ServiceResult<IPGeolocation>> { | ||
| 220 | await execute( | ||
| 221 | key: .subject(ip, .ipGeolocation), | ||
| 222 | rateLimitBucket: .ipGeolocation, | ||
| 223 | extract: { payload in | ||
| 224 | guard case let .ipGeolocation(result) = payload else { return nil } | ||
| 225 | return result | ||
| 226 | }, | ||
| 227 | operation: { | ||
| 228 | .ipGeolocation(await IPGeolocationService.lookup(ip: ip)) | ||
| 229 | } | ||
| 230 | ) | ||
| 231 | } | ||
| 232 | |||
| 233 | func suggestions(domain: String) async -> CachedLookupResult<[DomainSuggestionResult]> { | ||
| 234 | await execute( | ||
| 235 | key: .domain(domain, .suggestions), | ||
| 236 | extract: { payload in | ||
| 237 | guard case let .suggestions(result) = payload else { return nil } | ||
| 238 | return result | ||
| 239 | }, | ||
| 240 | operation: { | ||
| 241 | .suggestions(await DomainAvailabilityService.suggestions(for: domain)) | ||
| 242 | } | ||
| 243 | ) | ||
| 244 | } | ||
| 245 | |||
| 246 | private func execute<T>( | ||
| 247 | key: RequestKey, | ||
| 248 | rateLimitBucket: RateLimitBucket? = nil, | ||
| 249 | extract: @escaping (CachedPayload) -> T?, | ||
| 250 | operation: @escaping @Sendable () async -> CachedPayload | ||
| 251 | ) async -> CachedLookupResult<T> { | ||
| 252 | if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) { | ||
| 253 | return CachedLookupResult(value: value, source: .cached) | ||
| 254 | } | ||
| 255 | |||
| 256 | if let task = inFlight[key], let value = extract(await task.value) { | ||
| 257 | return CachedLookupResult(value: value, source: .mixed) | ||
| 258 | } | ||
| 259 | |||
| 260 | let task = Task<CachedPayload, Never> { | ||
| 261 | if let rateLimitBucket { | ||
| 262 | await self.enforceRateLimit(for: rateLimitBucket) | ||
| 263 | } | ||
| 264 | return await operation() | ||
| 265 | } | ||
| 266 | inFlight[key] = task | ||
| 267 | |||
| 268 | let payload = await task.value | ||
| 269 | cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl)) | ||
| 270 | inFlight[key] = nil | ||
| 271 | |||
| 272 | guard let value = extract(payload) else { | ||
| 273 | fatalError("LookupRuntime payload extraction mismatch") | ||
| 274 | } | ||
| 275 | |||
| 276 | return CachedLookupResult(value: value, source: .live) | ||
| 277 | } | ||
| 278 | |||
| 279 | private func enforceRateLimit(for bucket: RateLimitBucket) async { | ||
| 280 | let now = Date() | ||
| 281 | if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now { | ||
| 282 | let delay = nextAllowed.timeIntervalSince(now) | ||
| 283 | if delay > 0 { | ||
| 284 | try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) | ||
| 285 | } | ||
| 286 | } | ||
| 287 | nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing) | ||
| 288 | } | ||
| 289 | } | ||
DomainDig/Models.swift +40
| @@ -6,6 +6,43 @@ enum ServiceResult<Value> { | |||
| 6 | case error(String) | 6 | case error(String) |
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | enum LookupResultSource: String, Codable { | ||
| 10 | case live | ||
| 11 | case cached | ||
| 12 | case mixed | ||
| 13 | case snapshot | ||
| 14 | |||
| 15 | var label: String { | ||
| 16 | switch self { | ||
| 17 | case .live: | ||
| 18 | return "Live" | ||
| 19 | case .cached: | ||
| 20 | return "Cached" | ||
| 21 | case .mixed: | ||
| 22 | return "Mixed" | ||
| 23 | case .snapshot: | ||
| 24 | return "Snapshot" | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | enum LookupSectionKind: String, Codable, CaseIterable { | ||
| 30 | case dns | ||
| 31 | case availability | ||
| 32 | case ssl | ||
| 33 | case hsts | ||
| 34 | case httpHeaders | ||
| 35 | case reachability | ||
| 36 | case ipGeolocation | ||
| 37 | case emailSecurity | ||
| 38 | case ownership | ||
| 39 | case ptr | ||
| 40 | case redirectChain | ||
| 41 | case subdomains | ||
| 42 | case portScan | ||
| 43 | case suggestions | ||
| 44 | } | ||
| 45 | |||
| 9 | enum DomainAvailabilityStatus: String, Codable { | 46 | enum DomainAvailabilityStatus: String, Codable { |
| 10 | case available | 47 | case available |
| 11 | case registered | 48 | case registered |
| @@ -134,6 +171,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 134 | let id: UUID | 171 | let id: UUID |
| 135 | let domain: String | 172 | let domain: String |
| 136 | let historyEntryID: UUID? | 173 | let historyEntryID: UUID? |
| 174 | let resultSource: LookupResultSource | ||
| 137 | let availability: DomainAvailabilityStatus? | 175 | let availability: DomainAvailabilityStatus? |
| 138 | let primaryIP: String? | 176 | let primaryIP: String? |
| 139 | let quickStatus: String | 177 | let quickStatus: String |
| @@ -148,6 +186,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 148 | id: UUID = UUID(), | 186 | id: UUID = UUID(), |
| 149 | domain: String, | 187 | domain: String, |
| 150 | historyEntryID: UUID?, | 188 | historyEntryID: UUID?, |
| 189 | resultSource: LookupResultSource = .live, | ||
| 151 | availability: DomainAvailabilityStatus?, | 190 | availability: DomainAvailabilityStatus?, |
| 152 | primaryIP: String?, | 191 | primaryIP: String?, |
| 153 | quickStatus: String, | 192 | quickStatus: String, |
| @@ -161,6 +200,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 161 | self.id = id | 200 | self.id = id |
| 162 | self.domain = domain | 201 | self.domain = domain |
| 163 | self.historyEntryID = historyEntryID | 202 | self.historyEntryID = historyEntryID |
| 203 | self.resultSource = resultSource | ||
| 164 | self.availability = availability | 204 | self.availability = availability |
| 165 | self.primaryIP = primaryIP | 205 | self.primaryIP = primaryIP |
| 166 | self.quickStatus = quickStatus | 206 | self.quickStatus = quickStatus |
DomainDig/RDAPService.swift +2 −29
| @@ -5,7 +5,7 @@ enum RDAPService { | |||
| 5 | let normalizedDomain = normalize(domain) | 5 | let normalizedDomain = normalize(domain) |
| 6 | guard !normalizedDomain.isEmpty else { return nil } | 6 | guard !normalizedDomain.isEmpty else { return nil } |
| 7 | 7 | ||
| 8 | switch await cache.response(for: normalizedDomain) { | 8 | switch await fetchRDAPResponse(for: normalizedDomain) { |
| 9 | case let .success(response): | 9 | case let .success(response): |
| 10 | return response.isDomainRecord ? .registered : nil | 10 | return response.isDomainRecord ? .registered : nil |
| 11 | case .empty: | 11 | case .empty: |
| @@ -21,7 +21,7 @@ enum RDAPService { | |||
| 21 | return .empty("Unavailable") | 21 | return .empty("Unavailable") |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | switch await cache.response(for: normalizedDomain) { | 24 | switch await fetchRDAPResponse(for: normalizedDomain) { |
| 25 | case let .success(response): | 25 | case let .success(response): |
| 26 | let ownership = DomainOwnership( | 26 | let ownership = DomainOwnership( |
| 27 | registrar: response.registrarName, | 27 | registrar: response.registrarName, |
| @@ -39,8 +39,6 @@ enum RDAPService { | |||
| 39 | } | 39 | } |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | private static let cache = RDAPCache() | ||
| 43 | |||
| 44 | private static func normalize(_ domain: String) -> String { | 42 | private static func normalize(_ domain: String) -> String { |
| 45 | domain | 43 | domain |
| 46 | .trimmingCharacters(in: .whitespacesAndNewlines) | 44 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| @@ -48,31 +46,6 @@ enum RDAPService { | |||
| 48 | } | 46 | } |
| 49 | } | 47 | } |
| 50 | 48 | ||
| 51 | private actor RDAPCache { | ||
| 52 | private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:] | ||
| 53 | private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:] | ||
| 54 | |||
| 55 | func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> { | ||
| 56 | if let cachedResponse = cachedResponses[domain] { | ||
| 57 | return cachedResponse | ||
| 58 | } | ||
| 59 | |||
| 60 | if let inFlightTask = inFlightTasks[domain] { | ||
| 61 | return await inFlightTask.value | ||
| 62 | } | ||
| 63 | |||
| 64 | let task = Task<ServiceResult<RDAPDomainResponse>, Never> { | ||
| 65 | await fetchRDAPResponse(for: domain) | ||
| 66 | } | ||
| 67 | inFlightTasks[domain] = task | ||
| 68 | |||
| 69 | let result = await task.value | ||
| 70 | cachedResponses[domain] = result | ||
| 71 | inFlightTasks[domain] = nil | ||
| 72 | return result | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { | 49 | private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { |
| 77 | guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { | 50 | guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { |
| 78 | return .error("Unavailable") | 51 | return .error("Unavailable") |
DomainDig/SubdomainDiscoveryService.swift +3 −42
| @@ -7,55 +7,16 @@ enum SubdomainDiscoveryService { | |||
| 7 | return .empty("No passive subdomains found") | 7 | return .empty("No passive subdomains found") |
| 8 | } | 8 | } |
| 9 | 9 | ||
| 10 | return await cache.subdomains(for: normalizedDomain, limit: limit) | 10 | return await fetchSubdomains(for: normalizedDomain, limit: limit) |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | private static let cache = SubdomainDiscoveryCache() | ||
| 14 | |||
| 15 | private static func normalize(_ domain: String) -> String { | 13 | private static func normalize(_ domain: String) -> String { |
| 16 | domain | 14 | domain |
| 17 | .trimmingCharacters(in: .whitespacesAndNewlines) | 15 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| 18 | .lowercased() | 16 | .lowercased() |
| 19 | } | 17 | } |
| 20 | } | ||
| 21 | |||
| 22 | private actor SubdomainDiscoveryCache { | ||
| 23 | private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:] | ||
| 24 | private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:] | ||
| 25 | private var lastRequestAt: Date? | ||
| 26 | |||
| 27 | func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { | ||
| 28 | if let cachedResult = cachedResults[domain] { | ||
| 29 | return cachedResult | ||
| 30 | } | ||
| 31 | |||
| 32 | if let inFlightTask = inFlightTasks[domain] { | ||
| 33 | return await inFlightTask.value | ||
| 34 | } | ||
| 35 | |||
| 36 | let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> { | ||
| 37 | await enforceRateLimit() | ||
| 38 | return await fetchSubdomains(for: domain, limit: limit) | ||
| 39 | } | ||
| 40 | inFlightTasks[domain] = task | ||
| 41 | |||
| 42 | let result = await task.value | ||
| 43 | cachedResults[domain] = result | ||
| 44 | inFlightTasks[domain] = nil | ||
| 45 | return result | ||
| 46 | } | ||
| 47 | |||
| 48 | private func enforceRateLimit() async { | ||
| 49 | if let lastRequestAt { | ||
| 50 | let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt)) | ||
| 51 | if delay > 0 { | ||
| 52 | try? await Task.sleep(for: .seconds(delay)) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | lastRequestAt = Date() | ||
| 56 | } | ||
| 57 | 18 | ||
| 58 | private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { | 19 | private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { |
| 59 | var components = URLComponents(string: "https://crt.sh/")! | 20 | var components = URLComponents(string: "https://crt.sh/")! |
| 60 | components.queryItems = [ | 21 | components.queryItems = [ |
| 61 | URLQueryItem(name: "q", value: "%.\(domain)"), | 22 | URLQueryItem(name: "q", value: "%.\(domain)"), |
| @@ -81,7 +42,7 @@ private actor SubdomainDiscoveryCache { | |||
| 81 | } | 42 | } |
| 82 | } | 43 | } |
| 83 | 44 | ||
| 84 | private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] { | 45 | private static func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] { |
| 85 | var seen = Set<String>() | 46 | var seen = Set<String>() |
| 86 | var results: [DiscoveredSubdomain] = [] | 47 | var results: [DiscoveredSubdomain] = [] |
| 87 | 48 | ||
DomainDigCLI.swift +26 −6
| @@ -14,19 +14,39 @@ struct DomainDigCLI { | |||
| 14 | let wantsJSON = arguments.contains("--json") || arguments.contains("-j") | 14 | let wantsJSON = arguments.contains("--json") || arguments.contains("-j") |
| 15 | let domains = arguments.filter { !$0.hasPrefix("-") } | 15 | let domains = arguments.filter { !$0.hasPrefix("-") } |
| 16 | 16 | ||
| 17 | guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { | 17 | let requestedDomains = domains |
| 18 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | ||
| 19 | .filter { !$0.isEmpty } | ||
| 20 | |||
| 21 | guard !requestedDomains.isEmpty else { | ||
| 18 | fputs("usage: domaindig <domain> [--json]\n", stderr) | 22 | fputs("usage: domaindig <domain> [--json]\n", stderr) |
| 19 | Foundation.exit(1) | 23 | Foundation.exit(1) |
| 20 | } | 24 | } |
| 21 | 25 | ||
| 22 | let inspectionService = DomainInspectionService() | 26 | let inspectionService = DomainInspectionService() |
| 23 | let report = await inspectionService.inspect(domain: domain) | 27 | var reports: [DomainReport] = [] |
| 28 | var seen = Set<String>() | ||
| 29 | |||
| 30 | for domain in requestedDomains { | ||
| 31 | let normalizedDomain = domain.lowercased() | ||
| 32 | guard seen.insert(normalizedDomain).inserted else { continue } | ||
| 33 | reports.append(await inspectionService.inspect(domain: domain)) | ||
| 34 | } | ||
| 24 | 35 | ||
| 25 | do { | 36 | do { |
| 26 | let data = try DomainReportExporter.data( | 37 | let data: Data |
| 27 | for: report, | 38 | if reports.count == 1, let report = reports.first { |
| 28 | format: wantsJSON ? .json : .text | 39 | data = try DomainReportExporter.data( |
| 29 | ) | 40 | for: report, |
| 41 | format: wantsJSON ? .json : .text | ||
| 42 | ) | ||
| 43 | } else { | ||
| 44 | data = try DomainReportExporter.data( | ||
| 45 | for: reports, | ||
| 46 | format: wantsJSON ? .json : .text, | ||
| 47 | title: "DomainDig Batch Report" | ||
| 48 | ) | ||
| 49 | } | ||
| 30 | FileHandle.standardOutput.write(data) | 50 | FileHandle.standardOutput.write(data) |
| 31 | if data.last != 0x0A { | 51 | if data.last != 0x0A { |
| 32 | FileHandle.standardOutput.write(Data([0x0A])) | 52 | FileHandle.standardOutput.write(Data([0x0A])) |
DomainInspectionService.swift +231 −54
| @@ -2,89 +2,155 @@ import Foundation | |||
| 2 | 2 | ||
| 3 | struct DomainInspectionService { | 3 | struct DomainInspectionService { |
| 4 | private let reportBuilder = DomainReportBuilder() | 4 | private let reportBuilder = DomainReportBuilder() |
| 5 | private let runtime: LookupRuntime | ||
| 5 | 6 | ||
| 6 | func inspect(domain: String) async -> DomainReport { | 7 | init(runtime: LookupRuntime = .shared) { |
| 7 | let snapshot = await inspectSnapshot(domain: domain) | 8 | self.runtime = runtime |
| 9 | } | ||
| 10 | |||
| 11 | func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport { | ||
| 12 | let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot) | ||
| 8 | return reportBuilder.build(from: snapshot) | 13 | return reportBuilder.build(from: snapshot) |
| 9 | } | 14 | } |
| 10 | 15 | ||
| 11 | func inspectSnapshot(domain: String) async -> LookupSnapshot { | 16 | func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot { |
| 12 | let normalizedDomain = normalize(domain) | 17 | let normalizedDomain = normalize(domain) |
| 13 | let startedAt = Date() | 18 | let startedAt = Date() |
| 14 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() | 19 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() |
| 15 | let resolverURLString = DNSLookupService.currentResolverURLString() | 20 | let resolverURLString = DNSLookupService.currentResolverURLString() |
| 21 | var cachedSections = Set<LookupSectionKind>() | ||
| 22 | var sectionSources: [LookupResultSource] = [] | ||
| 23 | |||
| 24 | async let dnsFetch = runtime.dns(domain: normalizedDomain) | ||
| 25 | async let availabilityFetch = runtime.availability(domain: normalizedDomain) | ||
| 26 | async let sslFetch = runtime.ssl(domain: normalizedDomain) | ||
| 27 | async let hstsFetch = runtime.hsts(domain: normalizedDomain) | ||
| 28 | async let httpFetch = runtime.http(domain: normalizedDomain) | ||
| 29 | async let reachabilityFetch = runtime.reachability(domain: normalizedDomain) | ||
| 30 | async let ownershipFetch = runtime.ownership(domain: normalizedDomain) | ||
| 31 | async let redirectFetch = runtime.redirectChain(domain: normalizedDomain) | ||
| 32 | async let subdomainFetch = runtime.subdomains(domain: normalizedDomain) | ||
| 33 | async let portScanFetch = runtime.portScan(domain: normalizedDomain) | ||
| 34 | |||
| 35 | let resolvedDNS = await dnsFetch | ||
| 36 | let dnsResult = normalizeErrors(in: resolvedDNS.value) | ||
| 37 | track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 38 | |||
| 39 | let availability = await availabilityFetch | ||
| 40 | track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 41 | |||
| 42 | let resolvedSSL = await sslFetch | ||
| 43 | let sslResult = normalizeErrors(in: resolvedSSL.value) | ||
| 44 | track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 45 | |||
| 46 | let hsts = await hstsFetch | ||
| 47 | track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 16 | 48 | ||
| 17 | async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) | 49 | let http = await httpFetch |
| 18 | async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) | 50 | let httpResult = normalizeErrors(in: http.value) |
| 19 | async let sslResult = SSLCheckService.check(domain: normalizedDomain) | 51 | track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: §ionSources) |
| 20 | async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) | 52 | |
| 21 | async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) | 53 | let reachability = await reachabilityFetch |
| 22 | async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) | 54 | let reachabilityResult = normalizeErrors(in: reachability.value) |
| 23 | async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) | 55 | track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: §ionSources) |
| 24 | async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) | 56 | |
| 25 | async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) | 57 | let resolvedOwnership = await ownershipFetch |
| 26 | async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) | 58 | let ownershipResult = normalizeErrors(in: resolvedOwnership.value) |
| 27 | 59 | track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: §ionSources) | |
| 28 | let resolvedDNS = await dnsResult | 60 | |
| 29 | let availability = await availabilityResult | 61 | let redirects = await redirectFetch |
| 30 | let resolvedSSL = await sslResult | 62 | let redirectResult = normalizeErrors(in: redirects.value) |
| 31 | let hsts = await hstsResult | 63 | track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: §ionSources) |
| 32 | let http = await httpResult | 64 | |
| 33 | let reachability = await reachabilityResult | 65 | let resolvedSubdomains = await subdomainFetch |
| 34 | let resolvedOwnership = await ownershipResult | 66 | let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) |
| 35 | let redirects = await redirectResult | 67 | track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: §ionSources) |
| 36 | let resolvedSubdomains = await subdomainResult | 68 | |
| 37 | let ports = await portScanResult | 69 | let ports = await portScanFetch |
| 38 | 70 | let portScanResult = normalizeErrors(in: ports.value) | |
| 39 | let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) | 71 | track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: §ionSources) |
| 40 | let sslInfo = mapOptionalValueServiceResult(resolvedSSL) | 72 | |
| 41 | let httpHeadersResult = mapHTTPResult(http) | 73 | let dnsSections = mapServiceResult(dnsResult, emptyValue: []) |
| 42 | let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) | 74 | let sslInfo = mapOptionalValueServiceResult(sslResult) |
| 43 | let redirectChain = mapServiceResult(redirects, emptyValue: []) | 75 | let httpHeadersResult = mapHTTPResult(httpResult) |
| 44 | let ownership = mapOptionalValueServiceResult(resolvedOwnership) | 76 | let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) |
| 45 | let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) | 77 | let redirectChain = mapServiceResult(redirectResult, emptyValue: []) |
| 46 | let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) | 78 | let ownership = mapOptionalValueServiceResult(ownershipResult) |
| 79 | let subdomains = mapServiceResult(subdomainResult, emptyValue: []) | ||
| 80 | let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain) | ||
| 47 | 81 | ||
| 48 | let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] | 82 | let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] |
| 49 | let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value | 83 | let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value |
| 84 | let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value) | ||
| 85 | let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP) | ||
| 50 | 86 | ||
| 51 | async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) | 87 | let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>> |
| 52 | async let suggestions = availability.status == .registered | 88 | if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity { |
| 53 | ? DomainAvailabilityService.suggestions(for: normalizedDomain) | 89 | emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached) |
| 54 | : [] | 90 | } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError { |
| 91 | emailOutcome = CachedLookupResult(value: .error(error), source: .cached) | ||
| 92 | } else { | ||
| 93 | emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) | ||
| 94 | } | ||
| 95 | track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 55 | 96 | ||
| 56 | let resolvedEmail = await emailResult | 97 | let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]> |
| 57 | let resolvedSuggestions = await suggestions | 98 | if availability.value.status == .registered { |
| 99 | suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain) | ||
| 100 | track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 101 | } else { | ||
| 102 | suggestionsOutcome = CachedLookupResult(value: [], source: .live) | ||
| 103 | } | ||
| 58 | 104 | ||
| 59 | let ptrResult: ServiceResult<String>? | 105 | let ptrOutcome: CachedLookupResult<ServiceResult<String>>? |
| 60 | let geoResult: ServiceResult<IPGeolocation>? | 106 | let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>? |
| 61 | if let primaryIP { | 107 | if let primaryIP { |
| 62 | ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) | 108 | if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord { |
| 63 | geoResult = await IPGeolocationService.lookup(ip: primaryIP) | 109 | ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached) |
| 110 | } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError { | ||
| 111 | ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached) | ||
| 112 | } else { | ||
| 113 | ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString) | ||
| 114 | } | ||
| 115 | |||
| 116 | if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation { | ||
| 117 | geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached) | ||
| 118 | } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError { | ||
| 119 | geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached) | ||
| 120 | } else { | ||
| 121 | geoOutcome = await runtime.ipGeolocation(ip: primaryIP) | ||
| 122 | } | ||
| 123 | |||
| 124 | if let ptrOutcome { | ||
| 125 | track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 126 | } | ||
| 127 | if let geoOutcome { | ||
| 128 | track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | ||
| 129 | } | ||
| 64 | } else { | 130 | } else { |
| 65 | ptrResult = nil | 131 | ptrOutcome = nil |
| 66 | geoResult = nil | 132 | geoOutcome = nil |
| 67 | } | 133 | } |
| 68 | 134 | ||
| 69 | let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) | 135 | let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value)) |
| 70 | let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") | 136 | let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") |
| 71 | let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") | 137 | let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") |
| 72 | 138 | ||
| 73 | return LookupSnapshot( | 139 | return LookupSnapshot( |
| 74 | historyEntryID: nil, | 140 | historyEntryID: nil, |
| 75 | domain: availability.domain, | 141 | domain: availability.value.domain, |
| 76 | timestamp: Date(), | 142 | timestamp: Date(), |
| 77 | trackedDomainID: nil, | 143 | trackedDomainID: previousSnapshot?.trackedDomainID, |
| 78 | resolverDisplayName: resolverDisplayName, | 144 | resolverDisplayName: resolverDisplayName, |
| 79 | resolverURLString: resolverURLString, | 145 | resolverURLString: resolverURLString, |
| 80 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | 146 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), |
| 81 | dnsSections: dnsSections.value, | 147 | dnsSections: dnsSections.value, |
| 82 | dnsError: dnsSections.message, | 148 | dnsError: dnsSections.message, |
| 83 | availabilityResult: availability, | 149 | availabilityResult: availability.value, |
| 84 | suggestions: resolvedSuggestions, | 150 | suggestions: suggestionsOutcome.value, |
| 85 | sslInfo: sslInfo.value, | 151 | sslInfo: sslInfo.value, |
| 86 | sslError: sslInfo.message, | 152 | sslError: sslInfo.message, |
| 87 | hstsPreloaded: hsts, | 153 | hstsPreloaded: hsts.value, |
| 88 | httpHeaders: httpHeadersResult.headers, | 154 | httpHeaders: httpHeadersResult.headers, |
| 89 | httpSecurityGrade: httpHeadersResult.securityGrade, | 155 | httpSecurityGrade: httpHeadersResult.securityGrade, |
| 90 | httpStatusCode: httpHeadersResult.statusCode, | 156 | httpStatusCode: httpHeadersResult.statusCode, |
| @@ -109,7 +175,9 @@ struct DomainInspectionService { | |||
| 109 | portScanResults: portScanResults.value, | 175 | portScanResults: portScanResults.value, |
| 110 | portScanError: portScanResults.message, | 176 | portScanError: portScanResults.message, |
| 111 | changeSummary: nil, | 177 | changeSummary: nil, |
| 112 | isLive: false | 178 | resultSource: aggregateSource(sectionSources), |
| 179 | cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue }, | ||
| 180 | statusMessage: nil | ||
| 113 | ) | 181 | ) |
| 114 | } | 182 | } |
| 115 | 183 | ||
| @@ -122,6 +190,115 @@ struct DomainInspectionService { | |||
| 122 | .lowercased() ?? domain.lowercased() | 190 | .lowercased() ?? domain.lowercased() |
| 123 | } | 191 | } |
| 124 | 192 | ||
| 193 | private func track( | ||
| 194 | _ section: LookupSectionKind, | ||
| 195 | source: LookupResultSource, | ||
| 196 | cachedSections: inout Set<LookupSectionKind>, | ||
| 197 | sectionSources: inout [LookupResultSource] | ||
| 198 | ) { | ||
| 199 | sectionSources.append(source) | ||
| 200 | if source != .live { | ||
| 201 | cachedSections.insert(section) | ||
| 202 | } | ||
| 203 | } | ||
| 204 | |||
| 205 | private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource { | ||
| 206 | let normalizedSources = sectionSources.map { source -> LookupResultSource in | ||
| 207 | source == .mixed ? .cached : source | ||
| 208 | } | ||
| 209 | |||
| 210 | let hasLive = normalizedSources.contains(.live) | ||
| 211 | let hasCached = normalizedSources.contains(.cached) | ||
| 212 | |||
| 213 | switch (hasLive, hasCached) { | ||
| 214 | case (true, true): | ||
| 215 | return .mixed | ||
| 216 | case (false, true): | ||
| 217 | return .cached | ||
| 218 | default: | ||
| 219 | return .live | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool { | ||
| 224 | guard let previousSnapshot else { return false } | ||
| 225 | return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections) | ||
| 226 | } | ||
| 227 | |||
| 228 | private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool { | ||
| 229 | guard let previousSnapshot else { return false } | ||
| 230 | let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | ||
| 231 | return primaryIP == previousIP | ||
| 232 | } | ||
| 233 | |||
| 234 | private func dnsSignature(for sections: [DNSSection]) -> String { | ||
| 235 | sections | ||
| 236 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue } | ||
| 237 | .map { section in | ||
| 238 | let records = section.records | ||
| 239 | .sorted { $0.value < $1.value } | ||
| 240 | .map { "\($0.value)|\($0.ttl)" } | ||
| 241 | .joined(separator: ",") | ||
| 242 | let wildcardRecords = section.wildcardRecords | ||
| 243 | .sorted { $0.value < $1.value } | ||
| 244 | .map { "\($0.value)|\($0.ttl)" } | ||
| 245 | .joined(separator: ",") | ||
| 246 | return [ | ||
| 247 | section.recordType.rawValue, | ||
| 248 | records, | ||
| 249 | wildcardRecords, | ||
| 250 | section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown", | ||
| 251 | section.error ?? "" | ||
| 252 | ].joined(separator: "#") | ||
| 253 | } | ||
| 254 | .joined(separator: "||") | ||
| 255 | } | ||
| 256 | |||
| 257 | private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> { | ||
| 258 | switch result { | ||
| 259 | case let .success(value): | ||
| 260 | return .success(value) | ||
| 261 | case let .empty(message): | ||
| 262 | return .empty(message) | ||
| 263 | case let .error(message): | ||
| 264 | return .error(classifiedMessage(from: message)) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | private func classifiedMessage(from message: String) -> String { | ||
| 269 | let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 270 | let lowercasedMessage = normalizedMessage.lowercased() | ||
| 271 | |||
| 272 | if lowercasedMessage.hasPrefix("network error:") | ||
| 273 | || lowercasedMessage.hasPrefix("timeout:") | ||
| 274 | || lowercasedMessage.hasPrefix("rate limit:") | ||
| 275 | || lowercasedMessage.hasPrefix("parsing error:") { | ||
| 276 | return normalizedMessage | ||
| 277 | } | ||
| 278 | |||
| 279 | if lowercasedMessage.contains("timed out") { | ||
| 280 | return "Timeout: Request timed out" | ||
| 281 | } | ||
| 282 | if lowercasedMessage.contains("429") | ||
| 283 | || lowercasedMessage.contains("too many requests") | ||
| 284 | || lowercasedMessage.contains("rate limit") { | ||
| 285 | return "Rate limit: Try again shortly" | ||
| 286 | } | ||
| 287 | if lowercasedMessage.contains("cannot parse") | ||
| 288 | || lowercasedMessage.contains("decoding") | ||
| 289 | || lowercasedMessage.contains("json") { | ||
| 290 | return "Parsing error: Invalid server response" | ||
| 291 | } | ||
| 292 | if lowercasedMessage.contains("offline") | ||
| 293 | || lowercasedMessage.contains("internet connection") | ||
| 294 | || lowercasedMessage.contains("not connected") | ||
| 295 | || lowercasedMessage.contains("network connection") { | ||
| 296 | return "Network error: Offline" | ||
| 297 | } | ||
| 298 | |||
| 299 | return "Network error: \(normalizedMessage)" | ||
| 300 | } | ||
| 301 | |||
| 125 | private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { | 302 | private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { |
| 126 | switch result { | 303 | switch result { |
| 127 | case let .success(value): | 304 | case let .success(value): |
DomainReportBuilder.swift +2
| @@ -3,6 +3,7 @@ import Foundation | |||
| 3 | struct DomainReport: Codable { | 3 | struct DomainReport: Codable { |
| 4 | let domain: String | 4 | let domain: String |
| 5 | let timestamp: Date | 5 | let timestamp: Date |
| 6 | let resultSource: LookupResultSource | ||
| 6 | let availability: DomainAvailabilityStatus | 7 | let availability: DomainAvailabilityStatus |
| 7 | let ownership: DomainOwnership? | 8 | let ownership: DomainOwnership? |
| 8 | let dns: DNSResultSummary | 9 | let dns: DNSResultSummary |
| @@ -70,6 +71,7 @@ struct DomainReportBuilder { | |||
| 70 | return DomainReport( | 71 | return DomainReport( |
| 71 | domain: snapshot.domain, | 72 | domain: snapshot.domain, |
| 72 | timestamp: snapshot.timestamp, | 73 | timestamp: snapshot.timestamp, |
| 74 | resultSource: snapshot.resultSource, | ||
| 73 | availability: snapshot.availabilityResult?.status ?? .unknown, | 75 | availability: snapshot.availabilityResult?.status ?? .unknown, |
| 74 | ownership: snapshot.ownership, | 76 | ownership: snapshot.ownership, |
| 75 | dns: DNSResultSummary( | 77 | dns: DNSResultSummary( |
DomainReportExporter.swift +3
| @@ -36,6 +36,7 @@ enum DomainReportExporter { | |||
| 36 | "DomainDig Report", | 36 | "DomainDig Report", |
| 37 | "Domain: \(report.domain)", | 37 | "Domain: \(report.domain)", |
| 38 | "Timestamp: \(textDateFormatter.string(from: report.timestamp))", | 38 | "Timestamp: \(textDateFormatter.string(from: report.timestamp))", |
| 39 | "Source: \(report.resultSource.label)", | ||
| 39 | "Availability: \(availabilityLabel(report.availability))" | 40 | "Availability: \(availabilityLabel(report.availability))" |
| 40 | ] | 41 | ] |
| 41 | 42 | ||
| @@ -212,6 +213,7 @@ enum DomainReportExporter { | |||
| 212 | let headers = [ | 213 | let headers = [ |
| 213 | "domain", | 214 | "domain", |
| 214 | "timestamp", | 215 | "timestamp", |
| 216 | "result_source", | ||
| 215 | "availability", | 217 | "availability", |
| 216 | "registrar", | 218 | "registrar", |
| 217 | "ownership_expires", | 219 | "ownership_expires", |
| @@ -247,6 +249,7 @@ enum DomainReportExporter { | |||
| 247 | return [ | 249 | return [ |
| 248 | report.domain, | 250 | report.domain, |
| 249 | csvDateFormatter.string(from: report.timestamp), | 251 | csvDateFormatter.string(from: report.timestamp), |
| 252 | report.resultSource.rawValue, | ||
| 250 | availabilityLabel(report.availability), | 253 | availabilityLabel(report.availability), |
| 251 | report.ownership?.registrar ?? "", | 254 | report.ownership?.registrar ?? "", |
| 252 | expirationDate, | 255 | expirationDate, |
LookupSnapshot.swift +10 −2
| @@ -39,7 +39,13 @@ struct LookupSnapshot { | |||
| 39 | let portScanResults: [PortScanResult] | 39 | let portScanResults: [PortScanResult] |
| 40 | let portScanError: String? | 40 | let portScanError: String? |
| 41 | let changeSummary: DomainChangeSummary? | 41 | let changeSummary: DomainChangeSummary? |
| 42 | let isLive: Bool | 42 | let resultSource: LookupResultSource |
| 43 | let cachedSections: [LookupSectionKind] | ||
| 44 | let statusMessage: String? | ||
| 45 | |||
| 46 | var isLive: Bool { | ||
| 47 | resultSource == .live | ||
| 48 | } | ||
| 43 | } | 49 | } |
| 44 | 50 | ||
| 45 | extension HistoryEntry { | 51 | extension HistoryEntry { |
| @@ -83,7 +89,9 @@ extension HistoryEntry { | |||
| 83 | portScanResults: portScanResults, | 89 | portScanResults: portScanResults, |
| 84 | portScanError: portScanError, | 90 | portScanError: portScanError, |
| 85 | changeSummary: changeSummary, | 91 | changeSummary: changeSummary, |
| 86 | isLive: false | 92 | resultSource: .snapshot, |
| 93 | cachedSections: [], | ||
| 94 | statusMessage: nil | ||
| 87 | ) | 95 | ) |
| 88 | } | 96 | } |
| 89 | } | 97 | } |