Commit 397c7c420c
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -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 = 11; | 270 | CURRENT_PROJECT_VERSION = 12; |
| 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; |
| @@ -284,7 +284,7 @@ | |||
| 284 | "$(inherited)", | 284 | "$(inherited)", |
| 285 | "@executable_path/Frameworks", | 285 | "@executable_path/Frameworks", |
| 286 | ); | 286 | ); |
| 287 | MARKETING_VERSION = 1.7.2; | 287 | MARKETING_VERSION = 1.9.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 = 11; | 306 | CURRENT_PROJECT_VERSION = 12; |
| 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; |
| @@ -320,7 +320,7 @@ | |||
| 320 | "$(inherited)", | 320 | "$(inherited)", |
| 321 | "@executable_path/Frameworks", | 321 | "@executable_path/Frameworks", |
| 322 | ); | 322 | ); |
| 323 | MARKETING_VERSION = 1.7.2; | 323 | MARKETING_VERSION = 1.9.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/ContentView.swift +98 −10
| @@ -16,7 +16,15 @@ struct ContentView: View { | |||
| 16 | actionButtons | 16 | actionButtons |
| 17 | SummaryView(fields: viewModel.summaryFields) | 17 | SummaryView(fields: viewModel.summaryFields) |
| 18 | .padding(.top, 8) | 18 | .padding(.top, 8) |
| 19 | DomainSectionView(rows: viewModel.domainRows) | 19 | DomainSectionView( |
| 20 | rows: viewModel.domainRows, | ||
| 21 | suggestions: viewModel.suggestionRows, | ||
| 22 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, | ||
| 23 | availabilityLoading: viewModel.availabilityLoading, | ||
| 24 | suggestionsLoading: viewModel.suggestionsLoading, | ||
| 25 | isWatched: viewModel.isCurrentDomainWatched, | ||
| 26 | onToggleWatch: { viewModel.toggleWatchedDomain() } | ||
| 27 | ) | ||
| 20 | .padding(.top, 16) | 28 | .padding(.top, 16) |
| 21 | DNSSectionView( | 29 | DNSSectionView( |
| 22 | dnssecLabel: viewModel.dnssecLabel, | 30 | dnssecLabel: viewModel.dnssecLabel, |
| @@ -100,6 +108,12 @@ struct ContentView: View { | |||
| 100 | Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") | 108 | Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") |
| 101 | .foregroundStyle(.secondary) | 109 | .foregroundStyle(.secondary) |
| 102 | } | 110 | } |
| 111 | NavigationLink { | ||
| 112 | WatchlistView(viewModel: viewModel) | ||
| 113 | } label: { | ||
| 114 | Image(systemName: "eye") | ||
| 115 | .foregroundStyle(.secondary) | ||
| 116 | } | ||
| 103 | NavigationLink { | 117 | NavigationLink { |
| 104 | SettingsView() | 118 | SettingsView() |
| 105 | } label: { | 119 | } label: { |
| @@ -280,14 +294,58 @@ struct SummaryView: View { | |||
| 280 | 294 | ||
| 281 | struct DomainSectionView: View { | 295 | struct DomainSectionView: View { |
| 282 | let rows: [InfoRowViewData] | 296 | let rows: [InfoRowViewData] |
| 297 | let suggestions: [DomainSuggestionViewData] | ||
| 298 | let showSuggestions: Bool | ||
| 299 | let availabilityLoading: Bool | ||
| 300 | let suggestionsLoading: Bool | ||
| 301 | let isWatched: Bool | ||
| 302 | let onToggleWatch: () -> Void | ||
| 283 | 303 | ||
| 284 | var body: some View { | 304 | var body: some View { |
| 285 | VStack(alignment: .leading, spacing: 12) { | 305 | VStack(alignment: .leading, spacing: 12) { |
| 286 | SectionTitleView(title: "Domain") | 306 | HStack { |
| 307 | SectionTitleView(title: "Domain") | ||
| 308 | Spacer() | ||
| 309 | Button(isWatched ? "Watching" : "Watch") { | ||
| 310 | onToggleWatch() | ||
| 311 | } | ||
| 312 | .buttonStyle(.bordered) | ||
| 313 | .font(.system(.caption, design: .monospaced)) | ||
| 314 | } | ||
| 287 | CardView { | 315 | CardView { |
| 288 | ForEach(rows) { row in | 316 | ForEach(rows) { row in |
| 289 | LabeledValueRow(row: row) | 317 | LabeledValueRow(row: row) |
| 290 | } | 318 | } |
| 319 | if availabilityLoading { | ||
| 320 | ProgressView("Checking availability…") | ||
| 321 | .appLoadingStyle() | ||
| 322 | .padding(.top, 4) | ||
| 323 | } | ||
| 324 | if showSuggestions { | ||
| 325 | Text("Suggestions") | ||
| 326 | .font(.system(.caption, design: .monospaced)) | ||
| 327 | .foregroundStyle(.secondary) | ||
| 328 | .padding(.top, 4) | ||
| 329 | if suggestionsLoading { | ||
| 330 | ProgressView("Checking alternatives…") | ||
| 331 | .appLoadingStyle() | ||
| 332 | } else if suggestions.isEmpty { | ||
| 333 | MessageRowView(text: "No suggestions", isError: false) | ||
| 334 | } else { | ||
| 335 | ForEach(suggestions) { suggestion in | ||
| 336 | HStack { | ||
| 337 | Text(suggestion.domain) | ||
| 338 | .font(.system(.caption, design: .monospaced)) | ||
| 339 | .foregroundStyle(.primary) | ||
| 340 | .textSelection(.enabled) | ||
| 341 | Spacer() | ||
| 342 | Text(suggestion.status) | ||
| 343 | .font(.system(.caption2, design: .monospaced)) | ||
| 344 | .foregroundStyle(ResultColors.color(for: suggestion.tone)) | ||
| 345 | } | ||
| 346 | } | ||
| 347 | } | ||
| 348 | } | ||
| 291 | } | 349 | } |
| 292 | } | 350 | } |
| 293 | } | 351 | } |
| @@ -384,6 +442,7 @@ struct WebSectionView: View { | |||
| 384 | .foregroundStyle(.cyan) | 442 | .foregroundStyle(.cyan) |
| 385 | if sslLoading { | 443 | if sslLoading { |
| 386 | ProgressView("Checking certificate…") | 444 | ProgressView("Checking certificate…") |
| 445 | .appLoadingStyle() | ||
| 387 | } else if let sslError { | 446 | } else if let sslError { |
| 388 | MessageRowView(text: sslError, isError: true) | 447 | MessageRowView(text: sslError, isError: true) |
| 389 | } else { | 448 | } else { |
| @@ -410,6 +469,7 @@ struct WebSectionView: View { | |||
| 410 | .foregroundStyle(.cyan) | 469 | .foregroundStyle(.cyan) |
| 411 | if headersLoading { | 470 | if headersLoading { |
| 412 | ProgressView("Fetching headers…") | 471 | ProgressView("Fetching headers…") |
| 472 | .appLoadingStyle() | ||
| 413 | } else if let headersError { | 473 | } else if let headersError { |
| 414 | MessageRowView(text: headersError, isError: true) | 474 | MessageRowView(text: headersError, isError: true) |
| 415 | } else { | 475 | } else { |
| @@ -441,6 +501,7 @@ struct WebSectionView: View { | |||
| 441 | .foregroundStyle(.cyan) | 501 | .foregroundStyle(.cyan) |
| 442 | if redirectLoading { | 502 | if redirectLoading { |
| 443 | ProgressView("Tracing redirects…") | 503 | ProgressView("Tracing redirects…") |
| 504 | .appLoadingStyle() | ||
| 444 | } else if let redirectError { | 505 | } else if let redirectError { |
| 445 | MessageRowView(text: redirectError, isError: true) | 506 | MessageRowView(text: redirectError, isError: true) |
| 446 | } else if redirects.isEmpty { | 507 | } else if redirects.isEmpty { |
| @@ -486,6 +547,7 @@ struct EmailSectionView: View { | |||
| 486 | CardView { | 547 | CardView { |
| 487 | if loading { | 548 | if loading { |
| 488 | ProgressView("Checking email records…") | 549 | ProgressView("Checking email records…") |
| 550 | .appLoadingStyle() | ||
| 489 | } else if let error { | 551 | } else if let error { |
| 490 | MessageRowView(text: error, isError: true) | 552 | MessageRowView(text: error, isError: true) |
| 491 | } else if rows.isEmpty { | 553 | } else if rows.isEmpty { |
| @@ -549,6 +611,7 @@ struct NetworkSectionView: View { | |||
| 549 | .foregroundStyle(.cyan) | 611 | .foregroundStyle(.cyan) |
| 550 | if reachabilityLoading { | 612 | if reachabilityLoading { |
| 551 | ProgressView("Checking ports…") | 613 | ProgressView("Checking ports…") |
| 614 | .appLoadingStyle() | ||
| 552 | } else if let reachabilityError { | 615 | } else if let reachabilityError { |
| 553 | MessageRowView(text: reachabilityError, isError: true) | 616 | MessageRowView(text: reachabilityError, isError: true) |
| 554 | } else { | 617 | } else { |
| @@ -568,13 +631,14 @@ struct NetworkSectionView: View { | |||
| 568 | } | 631 | } |
| 569 | } | 632 | } |
| 570 | 633 | ||
| 571 | CardView { | 634 | CardView(allowsHorizontalScroll: false) { |
| 572 | Text("Location") | 635 | Text("Location") |
| 573 | .font(.system(.subheadline, design: .monospaced)) | 636 | .font(.system(.subheadline, design: .monospaced)) |
| 574 | .fontWeight(.semibold) | 637 | .fontWeight(.semibold) |
| 575 | .foregroundStyle(.cyan) | 638 | .foregroundStyle(.cyan) |
| 576 | if geolocationLoading { | 639 | if geolocationLoading { |
| 577 | ProgressView("Looking up location…") | 640 | ProgressView("Looking up location…") |
| 641 | .appLoadingStyle() | ||
| 578 | } else if let geolocationError, geolocation == nil { | 642 | } else if let geolocationError, geolocation == nil { |
| 579 | MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") | 643 | MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") |
| 580 | } else if let geolocation { | 644 | } else if let geolocation { |
| @@ -590,6 +654,7 @@ struct NetworkSectionView: View { | |||
| 590 | Marker(geolocation.ip, coordinate: coordinate) | 654 | Marker(geolocation.ip, coordinate: coordinate) |
| 591 | } | 655 | } |
| 592 | .mapStyle(.standard) | 656 | .mapStyle(.standard) |
| 657 | .frame(maxWidth: .infinity) | ||
| 593 | .frame(height: 180) | 658 | .frame(height: 180) |
| 594 | .cornerRadius(8) | 659 | .cornerRadius(8) |
| 595 | } | 660 | } |
| @@ -598,7 +663,7 @@ struct NetworkSectionView: View { | |||
| 598 | } | 663 | } |
| 599 | } | 664 | } |
| 600 | 665 | ||
| 601 | CardView { | 666 | CardView(allowsHorizontalScroll: false) { |
| 602 | Text("Port Scan") | 667 | Text("Port Scan") |
| 603 | .font(.system(.subheadline, design: .monospaced)) | 668 | .font(.system(.subheadline, design: .monospaced)) |
| 604 | .fontWeight(.semibold) | 669 | .fontWeight(.semibold) |
| @@ -608,10 +673,12 @@ struct NetworkSectionView: View { | |||
| 608 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") | 673 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") |
| 609 | .font(.system(.caption2, design: .monospaced)) | 674 | .font(.system(.caption2, design: .monospaced)) |
| 610 | .foregroundStyle(.orange) | 675 | .foregroundStyle(.orange) |
| 676 | .fixedSize(horizontal: false, vertical: true) | ||
| 611 | } | 677 | } |
| 612 | 678 | ||
| 613 | if portScanLoading { | 679 | if portScanLoading { |
| 614 | ProgressView("Scanning ports…") | 680 | ProgressView("Scanning ports…") |
| 681 | .appLoadingStyle() | ||
| 615 | } else if let portScanError, standardPortRows.isEmpty { | 682 | } else if let portScanError, standardPortRows.isEmpty { |
| 616 | MessageRowView(text: portScanError, isError: true) | 683 | MessageRowView(text: portScanError, isError: true) |
| 617 | } else { | 684 | } else { |
| @@ -640,6 +707,7 @@ struct NetworkSectionView: View { | |||
| 640 | 707 | ||
| 641 | if customPortScanLoading { | 708 | if customPortScanLoading { |
| 642 | ProgressView("Scanning custom ports…") | 709 | ProgressView("Scanning custom ports…") |
| 710 | .appLoadingStyle() | ||
| 643 | } else if let customPortScanError { | 711 | } else if let customPortScanError { |
| 644 | MessageRowView(text: customPortScanError, isError: true) | 712 | MessageRowView(text: customPortScanError, isError: true) |
| 645 | } else { | 713 | } else { |
| @@ -704,25 +772,38 @@ struct SectionTitleView: View { | |||
| 704 | } | 772 | } |
| 705 | 773 | ||
| 706 | struct CardView<Content: View>: View { | 774 | struct CardView<Content: View>: View { |
| 775 | let allowsHorizontalScroll: Bool | ||
| 707 | let content: Content | 776 | let content: Content |
| 708 | 777 | ||
| 709 | init(@ViewBuilder content: () -> Content) { | 778 | init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) { |
| 779 | self.allowsHorizontalScroll = allowsHorizontalScroll | ||
| 710 | self.content = content() | 780 | self.content = content() |
| 711 | } | 781 | } |
| 712 | 782 | ||
| 713 | var body: some View { | 783 | var body: some View { |
| 714 | ScrollView(.horizontal) { | 784 | Group { |
| 715 | VStack(alignment: .leading, spacing: 6) { | 785 | if allowsHorizontalScroll { |
| 716 | content | 786 | ScrollView(.horizontal) { |
| 787 | cardContent | ||
| 788 | .scrollTargetLayout() | ||
| 789 | } | ||
| 790 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | ||
| 791 | } else { | ||
| 792 | cardContent | ||
| 793 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 717 | } | 794 | } |
| 718 | .scrollTargetLayout() | ||
| 719 | } | 795 | } |
| 720 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | ||
| 721 | .frame(maxWidth: .infinity, alignment: .leading) | 796 | .frame(maxWidth: .infinity, alignment: .leading) |
| 722 | .padding(10) | 797 | .padding(10) |
| 723 | .background(Color(.systemGray6).opacity(0.5)) | 798 | .background(Color(.systemGray6).opacity(0.5)) |
| 724 | .cornerRadius(6) | 799 | .cornerRadius(6) |
| 725 | } | 800 | } |
| 801 | |||
| 802 | private var cardContent: some View { | ||
| 803 | VStack(alignment: .leading, spacing: 6) { | ||
| 804 | content | ||
| 805 | } | ||
| 806 | } | ||
| 726 | } | 807 | } |
| 727 | 808 | ||
| 728 | struct LoadingCardView: View { | 809 | struct LoadingCardView: View { |
| @@ -731,6 +812,7 @@ struct LoadingCardView: View { | |||
| 731 | var body: some View { | 812 | var body: some View { |
| 732 | CardView { | 813 | CardView { |
| 733 | ProgressView(text) | 814 | ProgressView(text) |
| 815 | .appLoadingStyle() | ||
| 734 | .frame(maxWidth: .infinity, alignment: .center) | 816 | .frame(maxWidth: .infinity, alignment: .center) |
| 735 | } | 817 | } |
| 736 | } | 818 | } |
| @@ -800,6 +882,12 @@ extension DateFormatter { | |||
| 800 | }() | 882 | }() |
| 801 | } | 883 | } |
| 802 | 884 | ||
| 885 | private extension View { | ||
| 886 | func appLoadingStyle() -> some View { | ||
| 887 | font(.system(.caption, design: .monospaced)) | ||
| 888 | } | ||
| 889 | } | ||
| 890 | |||
| 803 | private struct SettingsView: View { | 891 | private struct SettingsView: View { |
| 804 | @AppStorage(DNSResolverOption.userDefaultsKey) | 892 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 805 | private var storedResolverURL = DNSResolverOption.defaultURLString | 893 | private var storedResolverURL = DNSResolverOption.defaultURLString |
DomainDig/DomainAvailabilityService.swift added +153
| @@ -0,0 +1,153 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct DomainAvailabilityService { | ||
| 4 | private static let suggestionTLDs = ["net", "io", "dev", "app", "co", "org"] | ||
| 5 | |||
| 6 | static func check(domain: String) async -> DomainAvailabilityResult { | ||
| 7 | let normalizedDomain = normalize(domain) | ||
| 8 | guard !normalizedDomain.isEmpty else { | ||
| 9 | return DomainAvailabilityResult(domain: domain, status: .unknown) | ||
| 10 | } | ||
| 11 | |||
| 12 | if await checkViaRDAP(domain: normalizedDomain) == .registered { | ||
| 13 | debugLog("rdap", domain: normalizedDomain, status: .registered) | ||
| 14 | return DomainAvailabilityResult(domain: normalizedDomain, status: .registered) | ||
| 15 | } | ||
| 16 | |||
| 17 | let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) | ||
| 18 | let method = fallbackStatus == .registered ? "dns" : "fallback" | ||
| 19 | debugLog(method, domain: normalizedDomain, status: fallbackStatus) | ||
| 20 | return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) | ||
| 21 | } | ||
| 22 | |||
| 23 | static func suggestions(for domain: String, limit: Int = 6) async -> [DomainSuggestionResult] { | ||
| 24 | let normalizedDomain = normalize(domain) | ||
| 25 | let candidates = suggestionCandidates(for: normalizedDomain, limit: limit) | ||
| 26 | guard !candidates.isEmpty else { return [] } | ||
| 27 | |||
| 28 | var results: [DomainSuggestionResult] = [] | ||
| 29 | for candidate in candidates { | ||
| 30 | if Task.isCancelled { break } | ||
| 31 | let result = await check(domain: candidate) | ||
| 32 | results.append(DomainSuggestionResult(domain: result.domain, status: result.status)) | ||
| 33 | } | ||
| 34 | return results | ||
| 35 | } | ||
| 36 | |||
| 37 | private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { | ||
| 38 | guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { | ||
| 39 | return nil | ||
| 40 | } | ||
| 41 | |||
| 42 | do { | ||
| 43 | var request = URLRequest(url: url, timeoutInterval: 8) | ||
| 44 | request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") | ||
| 45 | |||
| 46 | let (data, response) = try await URLSession.shared.data(for: request) | ||
| 47 | guard let httpResponse = response as? HTTPURLResponse else { | ||
| 48 | return nil | ||
| 49 | } | ||
| 50 | |||
| 51 | switch httpResponse.statusCode { | ||
| 52 | case 200: | ||
| 53 | return isValidRDAPDomainResponse(data) ? .registered : nil | ||
| 54 | case 404: | ||
| 55 | debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org") | ||
| 56 | return nil | ||
| 57 | default: | ||
| 58 | return nil | ||
| 59 | } | ||
| 60 | } catch { | ||
| 61 | debugLog("rdap-error", domain: domain, details: error.localizedDescription) | ||
| 62 | return nil | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus { | ||
| 67 | do { | ||
| 68 | let aRecords = try await DNSLookupService.lookup(domain: domain, recordType: .A) | ||
| 69 | if !aRecords.isEmpty { | ||
| 70 | return .registered | ||
| 71 | } | ||
| 72 | } catch { | ||
| 73 | debugLog("dns-a-error", domain: domain, details: error.localizedDescription) | ||
| 74 | } | ||
| 75 | |||
| 76 | do { | ||
| 77 | let nsRecords = try await DNSLookupService.lookup(domain: domain, recordType: .NS) | ||
| 78 | if !nsRecords.isEmpty { | ||
| 79 | return .registered | ||
| 80 | } | ||
| 81 | return .unknown | ||
| 82 | } catch { | ||
| 83 | debugLog("dns-ns-error", domain: domain, details: error.localizedDescription) | ||
| 84 | return .unknown | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | private static func isValidRDAPDomainResponse(_ data: Data) -> Bool { | ||
| 89 | guard | ||
| 90 | let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] | ||
| 91 | else { | ||
| 92 | return false | ||
| 93 | } | ||
| 94 | |||
| 95 | if object["ldhName"] as? String != nil { | ||
| 96 | return true | ||
| 97 | } | ||
| 98 | |||
| 99 | if object["objectClassName"] as? String == "domain" { | ||
| 100 | return true | ||
| 101 | } | ||
| 102 | |||
| 103 | if object["handle"] as? String != nil, object["unicodeName"] as? String != nil { | ||
| 104 | return true | ||
| 105 | } | ||
| 106 | |||
| 107 | return false | ||
| 108 | } | ||
| 109 | |||
| 110 | private static func suggestionCandidates(for domain: String, limit: Int) -> [String] { | ||
| 111 | let parts = domain.split(separator: ".") | ||
| 112 | guard parts.count >= 2 else { return [] } | ||
| 113 | |||
| 114 | let base = parts.dropLast().joined(separator: ".") | ||
| 115 | let tld = String(parts.last ?? "") | ||
| 116 | |||
| 117 | var candidates: [String] = [] | ||
| 118 | for suggestionTLD in suggestionTLDs where suggestionTLD != tld { | ||
| 119 | candidates.append("\(base).\(suggestionTLD)") | ||
| 120 | if candidates.count == limit { | ||
| 121 | return candidates | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | if !base.contains("-"), base.count >= 6, candidates.count < limit { | ||
| 126 | let midpoint = base.index(base.startIndex, offsetBy: base.count / 2) | ||
| 127 | let hyphenated = "\(base[..<midpoint])-\(base[midpoint...]).\(tld)" | ||
| 128 | if hyphenated != domain { | ||
| 129 | candidates.append(hyphenated) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | return Array(candidates.prefix(limit)) | ||
| 134 | } | ||
| 135 | |||
| 136 | private static func normalize(_ domain: String) -> String { | ||
| 137 | domain | ||
| 138 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 139 | .lowercased() | ||
| 140 | } | ||
| 141 | |||
| 142 | private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) { | ||
| 143 | #if DEBUG | ||
| 144 | print("[Availability] \(domain) -> \(status.rawValue) via \(method)") | ||
| 145 | #endif | ||
| 146 | } | ||
| 147 | |||
| 148 | private static func debugLog(_ method: String, domain: String, details: String) { | ||
| 149 | #if DEBUG | ||
| 150 | print("[Availability] \(domain) -> \(method): \(details)") | ||
| 151 | #endif | ||
| 152 | } | ||
| 153 | } | ||
DomainDig/DomainViewModel.swift +170 −3
| @@ -72,6 +72,13 @@ struct PortScanRowViewData: Identifiable { | |||
| 72 | let durationLabel: String? | 72 | let durationLabel: String? |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | struct DomainSuggestionViewData: Identifiable { | ||
| 76 | let id: UUID | ||
| 77 | let domain: String | ||
| 78 | let status: String | ||
| 79 | let tone: ResultTone | ||
| 80 | } | ||
| 81 | |||
| 75 | struct LookupSnapshot { | 82 | struct LookupSnapshot { |
| 76 | let domain: String | 83 | let domain: String |
| 77 | let timestamp: Date | 84 | let timestamp: Date |
| @@ -80,6 +87,8 @@ struct LookupSnapshot { | |||
| 80 | let totalLookupDurationMs: Int? | 87 | let totalLookupDurationMs: Int? |
| 81 | let dnsSections: [DNSSection] | 88 | let dnsSections: [DNSSection] |
| 82 | let dnsError: String? | 89 | let dnsError: String? |
| 90 | let availabilityResult: DomainAvailabilityResult? | ||
| 91 | let suggestions: [DomainSuggestionResult] | ||
| 83 | let sslInfo: SSLCertificateInfo? | 92 | let sslInfo: SSLCertificateInfo? |
| 84 | let sslError: String? | 93 | let sslError: String? |
| 85 | let hstsPreloaded: Bool? | 94 | let hstsPreloaded: Bool? |
| @@ -115,6 +124,8 @@ extension HistoryEntry { | |||
| 115 | totalLookupDurationMs: totalLookupDurationMs, | 124 | totalLookupDurationMs: totalLookupDurationMs, |
| 116 | dnsSections: dnsSections, | 125 | dnsSections: dnsSections, |
| 117 | dnsError: nil, | 126 | dnsError: nil, |
| 127 | availabilityResult: availabilityResult, | ||
| 128 | suggestions: suggestions, | ||
| 118 | sslInfo: sslInfo, | 129 | sslInfo: sslInfo, |
| 119 | sslError: sslError, | 130 | sslError: sslError, |
| 120 | hstsPreloaded: hstsPreloaded, | 131 | hstsPreloaded: hstsPreloaded, |
| @@ -150,6 +161,10 @@ final class DomainViewModel { | |||
| 150 | var dnsSections: [DNSSection] = [] | 161 | var dnsSections: [DNSSection] = [] |
| 151 | var dnsLoading = false | 162 | var dnsLoading = false |
| 152 | var dnsError: String? | 163 | var dnsError: String? |
| 164 | var availabilityResult: DomainAvailabilityResult? | ||
| 165 | var availabilityLoading = false | ||
| 166 | var suggestions: [DomainSuggestionResult] = [] | ||
| 167 | var suggestionsLoading = false | ||
| 153 | 168 | ||
| 154 | var sslInfo: SSLCertificateInfo? | 169 | var sslInfo: SSLCertificateInfo? |
| 155 | var sslLoading = false | 170 | var sslLoading = false |
| @@ -209,6 +224,15 @@ final class DomainViewModel { | |||
| 209 | private static let savedDomainsKey = "savedDomains" | 224 | private static let savedDomainsKey = "savedDomains" |
| 210 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] | 225 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] |
| 211 | 226 | ||
| 227 | private static let watchedDomainsKey = "watchedDomains" | ||
| 228 | var watchedDomains: [WatchedDomain] = { | ||
| 229 | guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey), | ||
| 230 | let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else { | ||
| 231 | return [] | ||
| 232 | } | ||
| 233 | return domains | ||
| 234 | }() | ||
| 235 | |||
| 212 | private static let historyKey = "lookupHistory" | 236 | private static let historyKey = "lookupHistory" |
| 213 | private static let maxHistory = 50 | 237 | private static let maxHistory = 50 |
| 214 | var history: [HistoryEntry] = { | 238 | var history: [HistoryEntry] = { |
| @@ -230,6 +254,8 @@ final class DomainViewModel { | |||
| 230 | var resultsLoaded: Bool { | 254 | var resultsLoaded: Bool { |
| 231 | hasRun && | 255 | hasRun && |
| 232 | !dnsLoading && | 256 | !dnsLoading && |
| 257 | !availabilityLoading && | ||
| 258 | !suggestionsLoading && | ||
| 233 | !sslLoading && | 259 | !sslLoading && |
| 234 | !hstsLoading && | 260 | !hstsLoading && |
| 235 | !httpHeadersLoading && | 261 | !httpHeadersLoading && |
| @@ -250,6 +276,10 @@ final class DomainViewModel { | |||
| 250 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) | 276 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) |
| 251 | } | 277 | } |
| 252 | 278 | ||
| 279 | var isCurrentDomainWatched: Bool { | ||
| 280 | !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() }) | ||
| 281 | } | ||
| 282 | |||
| 253 | var resolverDisplayName: String { | 283 | var resolverDisplayName: String { |
| 254 | DNSLookupService.currentResolverDisplayName() | 284 | DNSLookupService.currentResolverDisplayName() |
| 255 | } | 285 | } |
| @@ -276,6 +306,8 @@ final class DomainViewModel { | |||
| 276 | totalLookupDurationMs: lastLookupDurationMs, | 306 | totalLookupDurationMs: lastLookupDurationMs, |
| 277 | dnsSections: dnsSections, | 307 | dnsSections: dnsSections, |
| 278 | dnsError: dnsError, | 308 | dnsError: dnsError, |
| 309 | availabilityResult: availabilityResult, | ||
| 310 | suggestions: suggestions, | ||
| 279 | sslInfo: sslInfo, | 311 | sslInfo: sslInfo, |
| 280 | sslError: sslError, | 312 | sslError: sslError, |
| 281 | hstsPreloaded: hstsPreloaded, | 313 | hstsPreloaded: hstsPreloaded, |
| @@ -314,6 +346,10 @@ final class DomainViewModel { | |||
| 314 | Self.dnsRows(from: currentSnapshot) | 346 | Self.dnsRows(from: currentSnapshot) |
| 315 | } | 347 | } |
| 316 | 348 | ||
| 349 | var suggestionRows: [DomainSuggestionViewData] { | ||
| 350 | Self.suggestionRows(from: currentSnapshot) | ||
| 351 | } | ||
| 352 | |||
| 317 | var dnssecLabel: String? { | 353 | var dnssecLabel: String? { |
| 318 | Self.dnssecLabel(from: currentSnapshot) | 354 | Self.dnssecLabel(from: currentSnapshot) |
| 319 | } | 355 | } |
| @@ -372,6 +408,33 @@ final class DomainViewModel { | |||
| 372 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | 408 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) |
| 373 | } | 409 | } |
| 374 | 410 | ||
| 411 | func toggleWatchedDomain() { | ||
| 412 | guard !searchedDomain.isEmpty else { return } | ||
| 413 | toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) | ||
| 414 | } | ||
| 415 | |||
| 416 | func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) { | ||
| 417 | guard !domain.isEmpty else { return } | ||
| 418 | |||
| 419 | if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) { | ||
| 420 | watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() } | ||
| 421 | } else { | ||
| 422 | watchedDomains.insert( | ||
| 423 | WatchedDomain( | ||
| 424 | domain: domain, | ||
| 425 | lastKnownAvailability: availabilityStatus | ||
| 426 | ), | ||
| 427 | at: 0 | ||
| 428 | ) | ||
| 429 | } | ||
| 430 | persistWatchedDomains() | ||
| 431 | } | ||
| 432 | |||
| 433 | func removeWatchedDomains(at offsets: IndexSet) { | ||
| 434 | watchedDomains.remove(atOffsets: offsets) | ||
| 435 | persistWatchedDomains() | ||
| 436 | } | ||
| 437 | |||
| 375 | func removeHistoryEntries(at offsets: IndexSet) { | 438 | func removeHistoryEntries(at offsets: IndexSet) { |
| 376 | history.remove(atOffsets: offsets) | 439 | history.remove(atOffsets: offsets) |
| 377 | persistHistory() | 440 | persistHistory() |
| @@ -456,6 +519,7 @@ final class DomainViewModel { | |||
| 456 | private func performLookup(domain: String, lookupID: UUID) async { | 519 | private func performLookup(domain: String, lookupID: UUID) async { |
| 457 | await withTaskGroup(of: Void.self) { group in | 520 | await withTaskGroup(of: Void.self) { group in |
| 458 | group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } | 521 | group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } |
| 522 | group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } | ||
| 459 | group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } | 523 | group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } |
| 460 | group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } | 524 | group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } |
| 461 | group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } | 525 | group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } |
| @@ -479,6 +543,15 @@ final class DomainViewModel { | |||
| 479 | } | 543 | } |
| 480 | } | 544 | } |
| 481 | 545 | ||
| 546 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | ||
| 547 | |||
| 548 | if availabilityResult?.status == .registered { | ||
| 549 | await runSuggestions(domain: domain, lookupID: lookupID) | ||
| 550 | } else { | ||
| 551 | suggestions = [] | ||
| 552 | suggestionsLoading = false | ||
| 553 | } | ||
| 554 | |||
| 482 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | 555 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 483 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } | 556 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } |
| 484 | saveHistoryEntry(replaceLatest: false) | 557 | saveHistoryEntry(replaceLatest: false) |
| @@ -501,6 +574,14 @@ final class DomainViewModel { | |||
| 501 | dnsLoading = false | 574 | dnsLoading = false |
| 502 | } | 575 | } |
| 503 | 576 | ||
| 577 | private func runAvailability(domain: String, lookupID: UUID) async { | ||
| 578 | let result = await DomainAvailabilityService.check(domain: domain) | ||
| 579 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | ||
| 580 | availabilityResult = result | ||
| 581 | availabilityLoading = false | ||
| 582 | updateWatchedDomainAvailability(for: result.domain, status: result.status) | ||
| 583 | } | ||
| 584 | |||
| 504 | private func runSSL(domain: String, lookupID: UUID) async { | 585 | private func runSSL(domain: String, lookupID: UUID) async { |
| 505 | let result = await SSLCheckService.check(domain: domain) | 586 | let result = await SSLCheckService.check(domain: domain) |
| 506 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | 587 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| @@ -670,6 +751,13 @@ final class DomainViewModel { | |||
| 670 | ipGeolocationError = "No A record available" | 751 | ipGeolocationError = "No A record available" |
| 671 | } | 752 | } |
| 672 | 753 | ||
| 754 | private func runSuggestions(domain: String, lookupID: UUID) async { | ||
| 755 | let results = await DomainAvailabilityService.suggestions(for: domain) | ||
| 756 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | ||
| 757 | suggestions = results | ||
| 758 | suggestionsLoading = false | ||
| 759 | } | ||
| 760 | |||
| 673 | private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { | 761 | private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { |
| 674 | switch result { | 762 | switch result { |
| 675 | case let .success(results): | 763 | case let .success(results): |
| @@ -727,6 +815,8 @@ final class DomainViewModel { | |||
| 727 | redirectChain: redirectChain, | 815 | redirectChain: redirectChain, |
| 728 | portScanResults: allPortScanResults, | 816 | portScanResults: allPortScanResults, |
| 729 | hstsPreloaded: hstsPreloaded, | 817 | hstsPreloaded: hstsPreloaded, |
| 818 | availabilityResult: availabilityResult, | ||
| 819 | suggestions: suggestions, | ||
| 730 | resolverDisplayName: resolverDisplayName, | 820 | resolverDisplayName: resolverDisplayName, |
| 731 | resolverURLString: resolverURLString, | 821 | resolverURLString: resolverURLString, |
| 732 | totalLookupDurationMs: lastLookupDurationMs, | 822 | totalLookupDurationMs: lastLookupDurationMs, |
| @@ -757,6 +847,20 @@ final class DomainViewModel { | |||
| 757 | } | 847 | } |
| 758 | } | 848 | } |
| 759 | 849 | ||
| 850 | private func persistWatchedDomains() { | ||
| 851 | if let data = try? JSONEncoder().encode(watchedDomains) { | ||
| 852 | UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey) | ||
| 853 | } | ||
| 854 | } | ||
| 855 | |||
| 856 | private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { | ||
| 857 | guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else { | ||
| 858 | return | ||
| 859 | } | ||
| 860 | watchedDomains[index].lastKnownAvailability = status | ||
| 861 | persistWatchedDomains() | ||
| 862 | } | ||
| 863 | |||
| 760 | private func addRecentSearch(_ domain: String) { | 864 | private func addRecentSearch(_ domain: String) { |
| 761 | recentSearches.removeAll { $0.lowercased() == domain.lowercased() } | 865 | recentSearches.removeAll { $0.lowercased() == domain.lowercased() } |
| 762 | recentSearches.insert(domain, at: 0) | 866 | recentSearches.insert(domain, at: 0) |
| @@ -770,6 +874,10 @@ final class DomainViewModel { | |||
| 770 | dnsSections = [] | 874 | dnsSections = [] |
| 771 | dnsError = nil | 875 | dnsError = nil |
| 772 | dnsLoading = false | 876 | dnsLoading = false |
| 877 | availabilityResult = nil | ||
| 878 | availabilityLoading = false | ||
| 879 | suggestions = [] | ||
| 880 | suggestionsLoading = false | ||
| 773 | sslInfo = nil | 881 | sslInfo = nil |
| 774 | sslError = nil | 882 | sslError = nil |
| 775 | sslLoading = false | 883 | sslLoading = false |
| @@ -808,6 +916,8 @@ final class DomainViewModel { | |||
| 808 | 916 | ||
| 809 | private func setAllLoadingStates(_ loading: Bool) { | 917 | private func setAllLoadingStates(_ loading: Bool) { |
| 810 | dnsLoading = loading | 918 | dnsLoading = loading |
| 919 | availabilityLoading = loading | ||
| 920 | suggestionsLoading = loading | ||
| 811 | sslLoading = loading | 921 | sslLoading = loading |
| 812 | hstsLoading = loading | 922 | hstsLoading = loading |
| 813 | httpHeadersLoading = loading | 923 | httpHeadersLoading = loading |
| @@ -838,12 +948,32 @@ final class DomainViewModel { | |||
| 838 | } | 948 | } |
| 839 | 949 | ||
| 840 | static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { | 950 | static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { |
| 841 | [ | 951 | var rows = [ |
| 842 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), | 952 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), |
| 843 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), | 953 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), |
| 844 | InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), | 954 | InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), |
| 845 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) | 955 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) |
| 846 | ] | 956 | ] |
| 957 | rows.insert( | ||
| 958 | InfoRowViewData( | ||
| 959 | label: "Availability", | ||
| 960 | value: availabilityLabel(snapshot.availabilityResult?.status), | ||
| 961 | tone: availabilityTone(snapshot.availabilityResult?.status) | ||
| 962 | ), | ||
| 963 | at: 1 | ||
| 964 | ) | ||
| 965 | return rows | ||
| 966 | } | ||
| 967 | |||
| 968 | static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] { | ||
| 969 | snapshot.suggestions.map { | ||
| 970 | DomainSuggestionViewData( | ||
| 971 | id: $0.id, | ||
| 972 | domain: $0.domain, | ||
| 973 | status: availabilityLabel($0.status), | ||
| 974 | tone: availabilityTone($0.status) | ||
| 975 | ) | ||
| 976 | } | ||
| 847 | } | 977 | } |
| 848 | 978 | ||
| 849 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { | 979 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { |
| @@ -879,8 +1009,8 @@ final class DomainViewModel { | |||
| 879 | var rows = [ | 1009 | var rows = [ |
| 880 | InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), | 1010 | InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), |
| 881 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), | 1011 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), |
| 882 | InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary), | 1012 | InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), |
| 883 | InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary), | 1013 | InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary), |
| 884 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), | 1014 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), |
| 885 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) | 1015 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) |
| 886 | ] | 1016 | ] |
| @@ -1010,6 +1140,14 @@ final class DomainViewModel { | |||
| 1010 | for row in domainRows(from: snapshot) { | 1140 | for row in domainRows(from: snapshot) { |
| 1011 | lines.append(" \(row.label): \(row.value)") | 1141 | lines.append(" \(row.label): \(row.value)") |
| 1012 | } | 1142 | } |
| 1143 | if snapshot.suggestions.isEmpty { | ||
| 1144 | lines.append(" Suggestions: None") | ||
| 1145 | } else { | ||
| 1146 | lines.append(" Suggestions:") | ||
| 1147 | for suggestion in snapshot.suggestions { | ||
| 1148 | lines.append(" \(suggestion.domain): \(availabilityLabel(suggestion.status))") | ||
| 1149 | } | ||
| 1150 | } | ||
| 1013 | } | 1151 | } |
| 1014 | 1152 | ||
| 1015 | appendSection("DNS") { | 1153 | appendSection("DNS") { |
| @@ -1183,6 +1321,28 @@ final class DomainViewModel { | |||
| 1183 | return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" | 1321 | return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" |
| 1184 | } | 1322 | } |
| 1185 | 1323 | ||
| 1324 | private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { | ||
| 1325 | switch status { | ||
| 1326 | case .available: | ||
| 1327 | return "Available" | ||
| 1328 | case .registered: | ||
| 1329 | return "Registered" | ||
| 1330 | case .unknown, .none: | ||
| 1331 | return "Unknown" | ||
| 1332 | } | ||
| 1333 | } | ||
| 1334 | |||
| 1335 | private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { | ||
| 1336 | switch status { | ||
| 1337 | case .available: | ||
| 1338 | return .success | ||
| 1339 | case .registered: | ||
| 1340 | return .warning | ||
| 1341 | case .unknown, .none: | ||
| 1342 | return .secondary | ||
| 1343 | } | ||
| 1344 | } | ||
| 1345 | |||
| 1186 | private static func securityGradeTone(_ grade: String) -> ResultTone { | 1346 | private static func securityGradeTone(_ grade: String) -> ResultTone { |
| 1187 | switch grade { | 1347 | switch grade { |
| 1188 | case "A", "B": | 1348 | case "A", "B": |
| @@ -1199,6 +1359,13 @@ final class DomainViewModel { | |||
| 1199 | private static func durationLabel(_ durationMs: Int?) -> String { | 1359 | private static func durationLabel(_ durationMs: Int?) -> String { |
| 1200 | durationMs.map { "\($0) ms" } ?? "Unavailable" | 1360 | durationMs.map { "\($0) ms" } ?? "Unavailable" |
| 1201 | } | 1361 | } |
| 1362 | |||
| 1363 | private static let certificateDateFormatter: DateFormatter = { | ||
| 1364 | let formatter = DateFormatter() | ||
| 1365 | formatter.dateStyle = .medium | ||
| 1366 | formatter.timeStyle = .short | ||
| 1367 | return formatter | ||
| 1368 | }() | ||
| 1202 | } | 1369 | } |
| 1203 | 1370 | ||
| 1204 | private extension String { | 1371 | private extension String { |
DomainDig/HistoryView.swift +11 −1
| @@ -78,7 +78,17 @@ struct HistoryDetailView: View { | |||
| 78 | snapshotBanner | 78 | snapshotBanner |
| 79 | SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) | 79 | SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) |
| 80 | .padding(.top, 8) | 80 | .padding(.top, 8) |
| 81 | DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot)) | 81 | DomainSectionView( |
| 82 | rows: DomainViewModel.domainRows(from: snapshot), | ||
| 83 | suggestions: DomainViewModel.suggestionRows(from: snapshot), | ||
| 84 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, | ||
| 85 | availabilityLoading: false, | ||
| 86 | suggestionsLoading: false, | ||
| 87 | isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | ||
| 88 | onToggleWatch: { | ||
| 89 | viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | ||
| 90 | } | ||
| 91 | ) | ||
| 82 | .padding(.top, 16) | 92 | .padding(.top, 16) |
| 83 | DNSSectionView( | 93 | DNSSectionView( |
| 84 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), | 94 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), |
DomainDig/Models.swift +50 −1
| @@ -6,6 +6,48 @@ enum ServiceResult<Value> { | |||
| 6 | case error(String) | 6 | case error(String) |
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | enum DomainAvailabilityStatus: String, Codable { | ||
| 10 | case available | ||
| 11 | case registered | ||
| 12 | case unknown | ||
| 13 | } | ||
| 14 | |||
| 15 | struct DomainAvailabilityResult: Codable { | ||
| 16 | let domain: String | ||
| 17 | let status: DomainAvailabilityStatus | ||
| 18 | } | ||
| 19 | |||
| 20 | struct DomainSuggestionResult: Identifiable, Codable { | ||
| 21 | let id: UUID | ||
| 22 | let domain: String | ||
| 23 | let status: DomainAvailabilityStatus | ||
| 24 | |||
| 25 | init(id: UUID = UUID(), domain: String, status: DomainAvailabilityStatus) { | ||
| 26 | self.id = id | ||
| 27 | self.domain = domain | ||
| 28 | self.status = status | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | struct WatchedDomain: Codable, Identifiable { | ||
| 33 | let id: UUID | ||
| 34 | let domain: String | ||
| 35 | let createdAt: Date | ||
| 36 | var lastKnownAvailability: DomainAvailabilityStatus? | ||
| 37 | |||
| 38 | init( | ||
| 39 | id: UUID = UUID(), | ||
| 40 | domain: String, | ||
| 41 | createdAt: Date = Date(), | ||
| 42 | lastKnownAvailability: DomainAvailabilityStatus? = nil | ||
| 43 | ) { | ||
| 44 | self.id = id | ||
| 45 | self.domain = domain | ||
| 46 | self.createdAt = createdAt | ||
| 47 | self.lastKnownAvailability = lastKnownAvailability | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 9 | // MARK: - DNS Models | 51 | // MARK: - DNS Models |
| 10 | 52 | ||
| 11 | enum DNSRecordType: String, CaseIterable, Codable { | 53 | enum DNSRecordType: String, CaseIterable, Codable { |
| @@ -290,6 +332,8 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 290 | var redirectChain: [RedirectHop] | 332 | var redirectChain: [RedirectHop] |
| 291 | var portScanResults: [PortScanResult] | 333 | var portScanResults: [PortScanResult] |
| 292 | var hstsPreloaded: Bool? | 334 | var hstsPreloaded: Bool? |
| 335 | var availabilityResult: DomainAvailabilityResult? | ||
| 336 | var suggestions: [DomainSuggestionResult] | ||
| 293 | var resolverDisplayName: String | 337 | var resolverDisplayName: String |
| 294 | var resolverURLString: String | 338 | var resolverURLString: String |
| 295 | var totalLookupDurationMs: Int? | 339 | var totalLookupDurationMs: Int? |
| @@ -307,7 +351,8 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 307 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 351 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 308 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, | 352 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 309 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], | 353 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 310 | hstsPreloaded: Bool? = nil, resolverDisplayName: String, resolverURLString: String, | 354 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, |
| 355 | suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, | ||
| 311 | totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | 356 | totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, |
| 312 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | 357 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 313 | emailSecurityError: String? = nil, ptrError: String? = nil, | 358 | emailSecurityError: String? = nil, ptrError: String? = nil, |
| @@ -325,6 +370,8 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 325 | self.redirectChain = redirectChain | 370 | self.redirectChain = redirectChain |
| 326 | self.portScanResults = portScanResults | 371 | self.portScanResults = portScanResults |
| 327 | self.hstsPreloaded = hstsPreloaded | 372 | self.hstsPreloaded = hstsPreloaded |
| 373 | self.availabilityResult = availabilityResult | ||
| 374 | self.suggestions = suggestions | ||
| 328 | self.resolverDisplayName = resolverDisplayName | 375 | self.resolverDisplayName = resolverDisplayName |
| 329 | self.resolverURLString = resolverURLString | 376 | self.resolverURLString = resolverURLString |
| 330 | self.totalLookupDurationMs = totalLookupDurationMs | 377 | self.totalLookupDurationMs = totalLookupDurationMs |
| @@ -354,6 +401,8 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 354 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] | 401 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 355 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] | 402 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |
| 356 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) | 403 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) |
| 404 | availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) | ||
| 405 | suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] | ||
| 357 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" | 406 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" |
| 358 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString | 407 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString |
| 359 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) | 408 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) |
DomainDig/WatchlistView.swift added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct WatchlistView: View { | ||
| 4 | @Bindable var viewModel: DomainViewModel | ||
| 5 | @Environment(\.dismiss) private var dismiss | ||
| 6 | |||
| 7 | var body: some View { | ||
| 8 | List { | ||
| 9 | if viewModel.watchedDomains.isEmpty { | ||
| 10 | Text("No watched domains") | ||
| 11 | .font(.system(.callout, design: .monospaced)) | ||
| 12 | .foregroundStyle(.secondary) | ||
| 13 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 14 | } else { | ||
| 15 | ForEach(viewModel.watchedDomains) { watchedDomain in | ||
| 16 | Button { | ||
| 17 | viewModel.domain = watchedDomain.domain | ||
| 18 | dismiss() | ||
| 19 | viewModel.run() | ||
| 20 | } label: { | ||
| 21 | VStack(alignment: .leading, spacing: 4) { | ||
| 22 | Text(watchedDomain.domain) | ||
| 23 | .font(.system(.callout, design: .monospaced)) | ||
| 24 | .foregroundStyle(.primary) | ||
| 25 | Text(statusLabel(watchedDomain.lastKnownAvailability)) | ||
| 26 | .font(.system(.caption2, design: .monospaced)) | ||
| 27 | .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability)) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 31 | } | ||
| 32 | .onDelete { offsets in | ||
| 33 | viewModel.removeWatchedDomains(at: offsets) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | .scrollContentBackground(.hidden) | ||
| 38 | .background(Color.black) | ||
| 39 | .navigationTitle("Watchlist") | ||
| 40 | .toolbar { | ||
| 41 | if !viewModel.watchedDomains.isEmpty { | ||
| 42 | EditButton() | ||
| 43 | } | ||
| 44 | } | ||
| 45 | .preferredColorScheme(.dark) | ||
| 46 | } | ||
| 47 | |||
| 48 | private func statusLabel(_ status: DomainAvailabilityStatus?) -> String { | ||
| 49 | switch status { | ||
| 50 | case .available: | ||
| 51 | return "Available" | ||
| 52 | case .registered: | ||
| 53 | return "Registered" | ||
| 54 | case .unknown, .none: | ||
| 55 | return "Unknown" | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | private func statusColor(_ status: DomainAvailabilityStatus?) -> Color { | ||
| 60 | switch status { | ||
| 61 | case .available: | ||
| 62 | return .green | ||
| 63 | case .registered: | ||
| 64 | return .yellow | ||
| 65 | case .unknown, .none: | ||
| 66 | return .secondary | ||
| 67 | } | ||
| 68 | } | ||
| 69 | } | ||