Commit 397c7c420c
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -267,7 +267,7 @@ | ||
| 267 | 267 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | 268 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | 269 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 11; | |
| 270 | CURRENT_PROJECT_VERSION = 12; | |
| 271 | 271 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | 272 | ENABLE_PREVIEWS = YES; |
| 273 | 273 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -284,7 +284,7 @@ | ||
| 284 | 284 | "$(inherited)", |
| 285 | 285 | "@executable_path/Frameworks", |
| 286 | 286 | ); |
| 287 | MARKETING_VERSION = 1.7.2; | |
| 287 | MARKETING_VERSION = 1.9.0; | |
| 288 | 288 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | 289 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | 290 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +303,7 @@ | ||
| 303 | 303 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | 304 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | 305 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 11; | |
| 306 | CURRENT_PROJECT_VERSION = 12; | |
| 307 | 307 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | 308 | ENABLE_PREVIEWS = YES; |
| 309 | 309 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -320,7 +320,7 @@ | ||
| 320 | 320 | "$(inherited)", |
| 321 | 321 | "@executable_path/Frameworks", |
| 322 | 322 | ); |
| 323 | MARKETING_VERSION = 1.7.2; | |
| 323 | MARKETING_VERSION = 1.9.0; | |
| 324 | 324 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | 325 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | 326 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +98 −10
| @@ -16,7 +16,15 @@ struct ContentView: View { | ||
| 16 | 16 | actionButtons |
| 17 | 17 | SummaryView(fields: viewModel.summaryFields) |
| 18 | 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 | 28 | .padding(.top, 16) |
| 21 | 29 | DNSSectionView( |
| 22 | 30 | dnssecLabel: viewModel.dnssecLabel, |
| @@ -100,6 +108,12 @@ struct ContentView: View { | ||
| 100 | 108 | Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") |
| 101 | 109 | .foregroundStyle(.secondary) |
| 102 | 110 | } |
| 111 | NavigationLink { | |
| 112 | WatchlistView(viewModel: viewModel) | |
| 113 | } label: { | |
| 114 | Image(systemName: "eye") | |
| 115 | .foregroundStyle(.secondary) | |
| 116 | } | |
| 103 | 117 | NavigationLink { |
| 104 | 118 | SettingsView() |
| 105 | 119 | } label: { |
| @@ -280,14 +294,58 @@ struct SummaryView: View { | ||
| 280 | 294 | |
| 281 | 295 | struct DomainSectionView: View { |
| 282 | 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 | 304 | var body: some View { |
| 285 | 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 | 315 | CardView { |
| 288 | 316 | ForEach(rows) { row in |
| 289 | 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 | 442 | .foregroundStyle(.cyan) |
| 385 | 443 | if sslLoading { |
| 386 | 444 | ProgressView("Checking certificate…") |
| 445 | .appLoadingStyle() | |
| 387 | 446 | } else if let sslError { |
| 388 | 447 | MessageRowView(text: sslError, isError: true) |
| 389 | 448 | } else { |
| @@ -410,6 +469,7 @@ struct WebSectionView: View { | ||
| 410 | 469 | .foregroundStyle(.cyan) |
| 411 | 470 | if headersLoading { |
| 412 | 471 | ProgressView("Fetching headers…") |
| 472 | .appLoadingStyle() | |
| 413 | 473 | } else if let headersError { |
| 414 | 474 | MessageRowView(text: headersError, isError: true) |
| 415 | 475 | } else { |
| @@ -441,6 +501,7 @@ struct WebSectionView: View { | ||
| 441 | 501 | .foregroundStyle(.cyan) |
| 442 | 502 | if redirectLoading { |
| 443 | 503 | ProgressView("Tracing redirects…") |
| 504 | .appLoadingStyle() | |
| 444 | 505 | } else if let redirectError { |
| 445 | 506 | MessageRowView(text: redirectError, isError: true) |
| 446 | 507 | } else if redirects.isEmpty { |
| @@ -486,6 +547,7 @@ struct EmailSectionView: View { | ||
| 486 | 547 | CardView { |
| 487 | 548 | if loading { |
| 488 | 549 | ProgressView("Checking email records…") |
| 550 | .appLoadingStyle() | |
| 489 | 551 | } else if let error { |
| 490 | 552 | MessageRowView(text: error, isError: true) |
| 491 | 553 | } else if rows.isEmpty { |
| @@ -549,6 +611,7 @@ struct NetworkSectionView: View { | ||
| 549 | 611 | .foregroundStyle(.cyan) |
| 550 | 612 | if reachabilityLoading { |
| 551 | 613 | ProgressView("Checking ports…") |
| 614 | .appLoadingStyle() | |
| 552 | 615 | } else if let reachabilityError { |
| 553 | 616 | MessageRowView(text: reachabilityError, isError: true) |
| 554 | 617 | } else { |
| @@ -568,13 +631,14 @@ struct NetworkSectionView: View { | ||
| 568 | 631 | } |
| 569 | 632 | } |
| 570 | 633 | |
| 571 | CardView { | |
| 634 | CardView(allowsHorizontalScroll: false) { | |
| 572 | 635 | Text("Location") |
| 573 | 636 | .font(.system(.subheadline, design: .monospaced)) |
| 574 | 637 | .fontWeight(.semibold) |
| 575 | 638 | .foregroundStyle(.cyan) |
| 576 | 639 | if geolocationLoading { |
| 577 | 640 | ProgressView("Looking up location…") |
| 641 | .appLoadingStyle() | |
| 578 | 642 | } else if let geolocationError, geolocation == nil { |
| 579 | 643 | MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") |
| 580 | 644 | } else if let geolocation { |
| @@ -590,6 +654,7 @@ struct NetworkSectionView: View { | ||
| 590 | 654 | Marker(geolocation.ip, coordinate: coordinate) |
| 591 | 655 | } |
| 592 | 656 | .mapStyle(.standard) |
| 657 | .frame(maxWidth: .infinity) | |
| 593 | 658 | .frame(height: 180) |
| 594 | 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 | 667 | Text("Port Scan") |
| 603 | 668 | .font(.system(.subheadline, design: .monospaced)) |
| 604 | 669 | .fontWeight(.semibold) |
| @@ -608,10 +673,12 @@ struct NetworkSectionView: View { | ||
| 608 | 673 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") |
| 609 | 674 | .font(.system(.caption2, design: .monospaced)) |
| 610 | 675 | .foregroundStyle(.orange) |
| 676 | .fixedSize(horizontal: false, vertical: true) | |
| 611 | 677 | } |
| 612 | 678 | |
| 613 | 679 | if portScanLoading { |
| 614 | 680 | ProgressView("Scanning ports…") |
| 681 | .appLoadingStyle() | |
| 615 | 682 | } else if let portScanError, standardPortRows.isEmpty { |
| 616 | 683 | MessageRowView(text: portScanError, isError: true) |
| 617 | 684 | } else { |
| @@ -640,6 +707,7 @@ struct NetworkSectionView: View { | ||
| 640 | 707 | |
| 641 | 708 | if customPortScanLoading { |
| 642 | 709 | ProgressView("Scanning custom ports…") |
| 710 | .appLoadingStyle() | |
| 643 | 711 | } else if let customPortScanError { |
| 644 | 712 | MessageRowView(text: customPortScanError, isError: true) |
| 645 | 713 | } else { |
| @@ -704,25 +772,38 @@ struct SectionTitleView: View { | ||
| 704 | 772 | } |
| 705 | 773 | |
| 706 | 774 | struct CardView<Content: View>: View { |
| 775 | let allowsHorizontalScroll: Bool | |
| 707 | 776 | let content: Content |
| 708 | 777 | |
| 709 | init(@ViewBuilder content: () -> Content) { | |
| 778 | init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) { | |
| 779 | self.allowsHorizontalScroll = allowsHorizontalScroll | |
| 710 | 780 | self.content = content() |
| 711 | 781 | } |
| 712 | 782 | |
| 713 | 783 | var body: some View { |
| 714 | ScrollView(.horizontal) { | |
| 715 | VStack(alignment: .leading, spacing: 6) { | |
| 716 | content | |
| 784 | Group { | |
| 785 | if allowsHorizontalScroll { | |
| 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 | 796 | .frame(maxWidth: .infinity, alignment: .leading) |
| 722 | 797 | .padding(10) |
| 723 | 798 | .background(Color(.systemGray6).opacity(0.5)) |
| 724 | 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 | 809 | struct LoadingCardView: View { |
| @@ -731,6 +812,7 @@ struct LoadingCardView: View { | ||
| 731 | 812 | var body: some View { |
| 732 | 813 | CardView { |
| 733 | 814 | ProgressView(text) |
| 815 | .appLoadingStyle() | |
| 734 | 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 | 891 | private struct SettingsView: View { |
| 804 | 892 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 805 | 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 | 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 | 82 | struct LookupSnapshot { |
| 76 | 83 | let domain: String |
| 77 | 84 | let timestamp: Date |
| @@ -80,6 +87,8 @@ struct LookupSnapshot { | ||
| 80 | 87 | let totalLookupDurationMs: Int? |
| 81 | 88 | let dnsSections: [DNSSection] |
| 82 | 89 | let dnsError: String? |
| 90 | let availabilityResult: DomainAvailabilityResult? | |
| 91 | let suggestions: [DomainSuggestionResult] | |
| 83 | 92 | let sslInfo: SSLCertificateInfo? |
| 84 | 93 | let sslError: String? |
| 85 | 94 | let hstsPreloaded: Bool? |
| @@ -115,6 +124,8 @@ extension HistoryEntry { | ||
| 115 | 124 | totalLookupDurationMs: totalLookupDurationMs, |
| 116 | 125 | dnsSections: dnsSections, |
| 117 | 126 | dnsError: nil, |
| 127 | availabilityResult: availabilityResult, | |
| 128 | suggestions: suggestions, | |
| 118 | 129 | sslInfo: sslInfo, |
| 119 | 130 | sslError: sslError, |
| 120 | 131 | hstsPreloaded: hstsPreloaded, |
| @@ -150,6 +161,10 @@ final class DomainViewModel { | ||
| 150 | 161 | var dnsSections: [DNSSection] = [] |
| 151 | 162 | var dnsLoading = false |
| 152 | 163 | var dnsError: String? |
| 164 | var availabilityResult: DomainAvailabilityResult? | |
| 165 | var availabilityLoading = false | |
| 166 | var suggestions: [DomainSuggestionResult] = [] | |
| 167 | var suggestionsLoading = false | |
| 153 | 168 | |
| 154 | 169 | var sslInfo: SSLCertificateInfo? |
| 155 | 170 | var sslLoading = false |
| @@ -209,6 +224,15 @@ final class DomainViewModel { | ||
| 209 | 224 | private static let savedDomainsKey = "savedDomains" |
| 210 | 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 | 236 | private static let historyKey = "lookupHistory" |
| 213 | 237 | private static let maxHistory = 50 |
| 214 | 238 | var history: [HistoryEntry] = { |
| @@ -230,6 +254,8 @@ final class DomainViewModel { | ||
| 230 | 254 | var resultsLoaded: Bool { |
| 231 | 255 | hasRun && |
| 232 | 256 | !dnsLoading && |
| 257 | !availabilityLoading && | |
| 258 | !suggestionsLoading && | |
| 233 | 259 | !sslLoading && |
| 234 | 260 | !hstsLoading && |
| 235 | 261 | !httpHeadersLoading && |
| @@ -250,6 +276,10 @@ final class DomainViewModel { | ||
| 250 | 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 | 283 | var resolverDisplayName: String { |
| 254 | 284 | DNSLookupService.currentResolverDisplayName() |
| 255 | 285 | } |
| @@ -276,6 +306,8 @@ final class DomainViewModel { | ||
| 276 | 306 | totalLookupDurationMs: lastLookupDurationMs, |
| 277 | 307 | dnsSections: dnsSections, |
| 278 | 308 | dnsError: dnsError, |
| 309 | availabilityResult: availabilityResult, | |
| 310 | suggestions: suggestions, | |
| 279 | 311 | sslInfo: sslInfo, |
| 280 | 312 | sslError: sslError, |
| 281 | 313 | hstsPreloaded: hstsPreloaded, |
| @@ -314,6 +346,10 @@ final class DomainViewModel { | ||
| 314 | 346 | Self.dnsRows(from: currentSnapshot) |
| 315 | 347 | } |
| 316 | 348 | |
| 349 | var suggestionRows: [DomainSuggestionViewData] { | |
| 350 | Self.suggestionRows(from: currentSnapshot) | |
| 351 | } | |
| 352 | ||
| 317 | 353 | var dnssecLabel: String? { |
| 318 | 354 | Self.dnssecLabel(from: currentSnapshot) |
| 319 | 355 | } |
| @@ -372,6 +408,33 @@ final class DomainViewModel { | ||
| 372 | 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 | 438 | func removeHistoryEntries(at offsets: IndexSet) { |
| 376 | 439 | history.remove(atOffsets: offsets) |
| 377 | 440 | persistHistory() |
| @@ -456,6 +519,7 @@ final class DomainViewModel { | ||
| 456 | 519 | private func performLookup(domain: String, lookupID: UUID) async { |
| 457 | 520 | await withTaskGroup(of: Void.self) { group in |
| 458 | 521 | group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } |
| 522 | group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } | |
| 459 | 523 | group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } |
| 460 | 524 | group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } |
| 461 | 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 | 555 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| 483 | 556 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } |
| 484 | 557 | saveHistoryEntry(replaceLatest: false) |
| @@ -501,6 +574,14 @@ final class DomainViewModel { | ||
| 501 | 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 | 585 | private func runSSL(domain: String, lookupID: UUID) async { |
| 505 | 586 | let result = await SSLCheckService.check(domain: domain) |
| 506 | 587 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } |
| @@ -670,6 +751,13 @@ final class DomainViewModel { | ||
| 670 | 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 | 761 | private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { |
| 674 | 762 | switch result { |
| 675 | 763 | case let .success(results): |
| @@ -727,6 +815,8 @@ final class DomainViewModel { | ||
| 727 | 815 | redirectChain: redirectChain, |
| 728 | 816 | portScanResults: allPortScanResults, |
| 729 | 817 | hstsPreloaded: hstsPreloaded, |
| 818 | availabilityResult: availabilityResult, | |
| 819 | suggestions: suggestions, | |
| 730 | 820 | resolverDisplayName: resolverDisplayName, |
| 731 | 821 | resolverURLString: resolverURLString, |
| 732 | 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 | 864 | private func addRecentSearch(_ domain: String) { |
| 761 | 865 | recentSearches.removeAll { $0.lowercased() == domain.lowercased() } |
| 762 | 866 | recentSearches.insert(domain, at: 0) |
| @@ -770,6 +874,10 @@ final class DomainViewModel { | ||
| 770 | 874 | dnsSections = [] |
| 771 | 875 | dnsError = nil |
| 772 | 876 | dnsLoading = false |
| 877 | availabilityResult = nil | |
| 878 | availabilityLoading = false | |
| 879 | suggestions = [] | |
| 880 | suggestionsLoading = false | |
| 773 | 881 | sslInfo = nil |
| 774 | 882 | sslError = nil |
| 775 | 883 | sslLoading = false |
| @@ -808,6 +916,8 @@ final class DomainViewModel { | ||
| 808 | 916 | |
| 809 | 917 | private func setAllLoadingStates(_ loading: Bool) { |
| 810 | 918 | dnsLoading = loading |
| 919 | availabilityLoading = loading | |
| 920 | suggestionsLoading = loading | |
| 811 | 921 | sslLoading = loading |
| 812 | 922 | hstsLoading = loading |
| 813 | 923 | httpHeadersLoading = loading |
| @@ -838,12 +948,32 @@ final class DomainViewModel { | ||
| 838 | 948 | } |
| 839 | 949 | |
| 840 | 950 | static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { |
| 841 | [ | |
| 951 | var rows = [ | |
| 842 | 952 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), |
| 843 | 953 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), |
| 844 | 954 | InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), |
| 845 | 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 | 979 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { |
| @@ -879,8 +1009,8 @@ final class DomainViewModel { | ||
| 879 | 1009 | var rows = [ |
| 880 | 1010 | InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), |
| 881 | 1011 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), |
| 882 | InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary), | |
| 883 | InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary), | |
| 1012 | InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), | |
| 1013 | InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary), | |
| 884 | 1014 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), |
| 885 | 1015 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) |
| 886 | 1016 | ] |
| @@ -1010,6 +1140,14 @@ final class DomainViewModel { | ||
| 1010 | 1140 | for row in domainRows(from: snapshot) { |
| 1011 | 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 | 1153 | appendSection("DNS") { |
| @@ -1183,6 +1321,28 @@ final class DomainViewModel { | ||
| 1183 | 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 | 1346 | private static func securityGradeTone(_ grade: String) -> ResultTone { |
| 1187 | 1347 | switch grade { |
| 1188 | 1348 | case "A", "B": |
| @@ -1199,6 +1359,13 @@ final class DomainViewModel { | ||
| 1199 | 1359 | private static func durationLabel(_ durationMs: Int?) -> String { |
| 1200 | 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 | 1371 | private extension String { |
DomainDig/HistoryView.swift +11 −1
| @@ -78,7 +78,17 @@ struct HistoryDetailView: View { | ||
| 78 | 78 | snapshotBanner |
| 79 | 79 | SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) |
| 80 | 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 | 92 | .padding(.top, 16) |
| 83 | 93 | DNSSectionView( |
| 84 | 94 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), |
DomainDig/Models.swift +50 −1
| @@ -6,6 +6,48 @@ enum ServiceResult<Value> { | ||
| 6 | 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 | 51 | // MARK: - DNS Models |
| 10 | 52 | |
| 11 | 53 | enum DNSRecordType: String, CaseIterable, Codable { |
| @@ -290,6 +332,8 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 290 | 332 | var redirectChain: [RedirectHop] |
| 291 | 333 | var portScanResults: [PortScanResult] |
| 292 | 334 | var hstsPreloaded: Bool? |
| 335 | var availabilityResult: DomainAvailabilityResult? | |
| 336 | var suggestions: [DomainSuggestionResult] | |
| 293 | 337 | var resolverDisplayName: String |
| 294 | 338 | var resolverURLString: String |
| 295 | 339 | var totalLookupDurationMs: Int? |
| @@ -307,7 +351,8 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 307 | 351 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 308 | 352 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 309 | 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 | 356 | totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, |
| 312 | 357 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, |
| 313 | 358 | emailSecurityError: String? = nil, ptrError: String? = nil, |
| @@ -325,6 +370,8 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 325 | 370 | self.redirectChain = redirectChain |
| 326 | 371 | self.portScanResults = portScanResults |
| 327 | 372 | self.hstsPreloaded = hstsPreloaded |
| 373 | self.availabilityResult = availabilityResult | |
| 374 | self.suggestions = suggestions | |
| 328 | 375 | self.resolverDisplayName = resolverDisplayName |
| 329 | 376 | self.resolverURLString = resolverURLString |
| 330 | 377 | self.totalLookupDurationMs = totalLookupDurationMs |
| @@ -354,6 +401,8 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 354 | 401 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 355 | 402 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |
| 356 | 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 | 406 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" |
| 358 | 407 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString |
| 359 | 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 | } | |