From 397c7c420c22fc1c235893bd85b2029da9145730 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Apr 2026 15:48:47 -0500 Subject: feat(v1.9.0): add domain availability lookup and watchlist foundation - implement availability detection (available/registered/unknown) - integrate availability into domain results - add lightweight domain suggestions - introduce local watchlist with persistence - add WatchlistView and toolbar access - include availability in export --- DomainDig.xcodeproj/project.pbxproj | 8 +- DomainDig/ContentView.swift | 108 +++++++++++++++++-- DomainDig/DomainAvailabilityService.swift | 153 ++++++++++++++++++++++++++ DomainDig/DomainViewModel.swift | 173 +++++++++++++++++++++++++++++- DomainDig/HistoryView.swift | 12 ++- DomainDig/Models.swift | 51 ++++++++- DomainDig/WatchlistView.swift | 69 ++++++++++++ 7 files changed, 555 insertions(+), 19 deletions(-) create mode 100644 DomainDig/DomainAvailabilityService.swift create mode 100644 DomainDig/WatchlistView.swift diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 29b49ce..b08ad23 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -267,7 +267,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -284,7 +284,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.2; + MARKETING_VERSION = 1.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -303,7 +303,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -320,7 +320,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.2; + MARKETING_VERSION = 1.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 68f3a6f..f70bc6a 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -16,7 +16,15 @@ struct ContentView: View { actionButtons SummaryView(fields: viewModel.summaryFields) .padding(.top, 8) - DomainSectionView(rows: viewModel.domainRows) + DomainSectionView( + rows: viewModel.domainRows, + suggestions: viewModel.suggestionRows, + showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, + availabilityLoading: viewModel.availabilityLoading, + suggestionsLoading: viewModel.suggestionsLoading, + isWatched: viewModel.isCurrentDomainWatched, + onToggleWatch: { viewModel.toggleWatchedDomain() } + ) .padding(.top, 16) DNSSectionView( dnssecLabel: viewModel.dnssecLabel, @@ -100,6 +108,12 @@ struct ContentView: View { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .foregroundStyle(.secondary) } + NavigationLink { + WatchlistView(viewModel: viewModel) + } label: { + Image(systemName: "eye") + .foregroundStyle(.secondary) + } NavigationLink { SettingsView() } label: { @@ -280,14 +294,58 @@ struct SummaryView: View { struct DomainSectionView: View { let rows: [InfoRowViewData] + let suggestions: [DomainSuggestionViewData] + let showSuggestions: Bool + let availabilityLoading: Bool + let suggestionsLoading: Bool + let isWatched: Bool + let onToggleWatch: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { - SectionTitleView(title: "Domain") + HStack { + SectionTitleView(title: "Domain") + Spacer() + Button(isWatched ? "Watching" : "Watch") { + onToggleWatch() + } + .buttonStyle(.bordered) + .font(.system(.caption, design: .monospaced)) + } CardView { ForEach(rows) { row in LabeledValueRow(row: row) } + if availabilityLoading { + ProgressView("Checking availability…") + .appLoadingStyle() + .padding(.top, 4) + } + if showSuggestions { + Text("Suggestions") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.top, 4) + if suggestionsLoading { + ProgressView("Checking alternatives…") + .appLoadingStyle() + } else if suggestions.isEmpty { + MessageRowView(text: "No suggestions", isError: false) + } else { + ForEach(suggestions) { suggestion in + HStack { + Text(suggestion.domain) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + Text(suggestion.status) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: suggestion.tone)) + } + } + } + } } } } @@ -384,6 +442,7 @@ struct WebSectionView: View { .foregroundStyle(.cyan) if sslLoading { ProgressView("Checking certificate…") + .appLoadingStyle() } else if let sslError { MessageRowView(text: sslError, isError: true) } else { @@ -410,6 +469,7 @@ struct WebSectionView: View { .foregroundStyle(.cyan) if headersLoading { ProgressView("Fetching headers…") + .appLoadingStyle() } else if let headersError { MessageRowView(text: headersError, isError: true) } else { @@ -441,6 +501,7 @@ struct WebSectionView: View { .foregroundStyle(.cyan) if redirectLoading { ProgressView("Tracing redirects…") + .appLoadingStyle() } else if let redirectError { MessageRowView(text: redirectError, isError: true) } else if redirects.isEmpty { @@ -486,6 +547,7 @@ struct EmailSectionView: View { CardView { if loading { ProgressView("Checking email records…") + .appLoadingStyle() } else if let error { MessageRowView(text: error, isError: true) } else if rows.isEmpty { @@ -549,6 +611,7 @@ struct NetworkSectionView: View { .foregroundStyle(.cyan) if reachabilityLoading { ProgressView("Checking ports…") + .appLoadingStyle() } else if let reachabilityError { MessageRowView(text: reachabilityError, isError: true) } else { @@ -568,13 +631,14 @@ struct NetworkSectionView: View { } } - CardView { + CardView(allowsHorizontalScroll: false) { Text("Location") .font(.system(.subheadline, design: .monospaced)) .fontWeight(.semibold) .foregroundStyle(.cyan) if geolocationLoading { ProgressView("Looking up location…") + .appLoadingStyle() } else if let geolocationError, geolocation == nil { MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") } else if let geolocation { @@ -590,6 +654,7 @@ struct NetworkSectionView: View { Marker(geolocation.ip, coordinate: coordinate) } .mapStyle(.standard) + .frame(maxWidth: .infinity) .frame(height: 180) .cornerRadius(8) } @@ -598,7 +663,7 @@ struct NetworkSectionView: View { } } - CardView { + CardView(allowsHorizontalScroll: false) { Text("Port Scan") .font(.system(.subheadline, design: .monospaced)) .fontWeight(.semibold) @@ -608,10 +673,12 @@ struct NetworkSectionView: View { Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) } if portScanLoading { ProgressView("Scanning ports…") + .appLoadingStyle() } else if let portScanError, standardPortRows.isEmpty { MessageRowView(text: portScanError, isError: true) } else { @@ -640,6 +707,7 @@ struct NetworkSectionView: View { if customPortScanLoading { ProgressView("Scanning custom ports…") + .appLoadingStyle() } else if let customPortScanError { MessageRowView(text: customPortScanError, isError: true) } else { @@ -704,25 +772,38 @@ struct SectionTitleView: View { } struct CardView: View { + let allowsHorizontalScroll: Bool let content: Content - init(@ViewBuilder content: () -> Content) { + init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) { + self.allowsHorizontalScroll = allowsHorizontalScroll self.content = content() } var body: some View { - ScrollView(.horizontal) { - VStack(alignment: .leading, spacing: 6) { - content + Group { + if allowsHorizontalScroll { + ScrollView(.horizontal) { + cardContent + .scrollTargetLayout() + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + } else { + cardContent + .frame(maxWidth: .infinity, alignment: .leading) } - .scrollTargetLayout() } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(Color(.systemGray6).opacity(0.5)) .cornerRadius(6) } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 6) { + content + } + } } struct LoadingCardView: View { @@ -731,6 +812,7 @@ struct LoadingCardView: View { var body: some View { CardView { ProgressView(text) + .appLoadingStyle() .frame(maxWidth: .infinity, alignment: .center) } } @@ -800,6 +882,12 @@ extension DateFormatter { }() } +private extension View { + func appLoadingStyle() -> some View { + font(.system(.caption, design: .monospaced)) + } +} + private struct SettingsView: View { @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift new file mode 100644 index 0000000..7bef586 --- /dev/null +++ b/DomainDig/DomainAvailabilityService.swift @@ -0,0 +1,153 @@ +import Foundation + +struct DomainAvailabilityService { + private static let suggestionTLDs = ["net", "io", "dev", "app", "co", "org"] + + static func check(domain: String) async -> DomainAvailabilityResult { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { + return DomainAvailabilityResult(domain: domain, status: .unknown) + } + + if await checkViaRDAP(domain: normalizedDomain) == .registered { + debugLog("rdap", domain: normalizedDomain, status: .registered) + return DomainAvailabilityResult(domain: normalizedDomain, status: .registered) + } + + let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) + let method = fallbackStatus == .registered ? "dns" : "fallback" + debugLog(method, domain: normalizedDomain, status: fallbackStatus) + return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) + } + + static func suggestions(for domain: String, limit: Int = 6) async -> [DomainSuggestionResult] { + let normalizedDomain = normalize(domain) + let candidates = suggestionCandidates(for: normalizedDomain, limit: limit) + guard !candidates.isEmpty else { return [] } + + var results: [DomainSuggestionResult] = [] + for candidate in candidates { + if Task.isCancelled { break } + let result = await check(domain: candidate) + results.append(DomainSuggestionResult(domain: result.domain, status: result.status)) + } + return results + } + + private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { + guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { + return nil + } + + do { + var request = URLRequest(url: url, timeoutInterval: 8) + request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + + switch httpResponse.statusCode { + case 200: + return isValidRDAPDomainResponse(data) ? .registered : nil + case 404: + debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org") + return nil + default: + return nil + } + } catch { + debugLog("rdap-error", domain: domain, details: error.localizedDescription) + return nil + } + } + + private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus { + do { + let aRecords = try await DNSLookupService.lookup(domain: domain, recordType: .A) + if !aRecords.isEmpty { + return .registered + } + } catch { + debugLog("dns-a-error", domain: domain, details: error.localizedDescription) + } + + do { + let nsRecords = try await DNSLookupService.lookup(domain: domain, recordType: .NS) + if !nsRecords.isEmpty { + return .registered + } + return .unknown + } catch { + debugLog("dns-ns-error", domain: domain, details: error.localizedDescription) + return .unknown + } + } + + private static func isValidRDAPDomainResponse(_ data: Data) -> Bool { + guard + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return false + } + + if object["ldhName"] as? String != nil { + return true + } + + if object["objectClassName"] as? String == "domain" { + return true + } + + if object["handle"] as? String != nil, object["unicodeName"] as? String != nil { + return true + } + + return false + } + + private static func suggestionCandidates(for domain: String, limit: Int) -> [String] { + let parts = domain.split(separator: ".") + guard parts.count >= 2 else { return [] } + + let base = parts.dropLast().joined(separator: ".") + let tld = String(parts.last ?? "") + + var candidates: [String] = [] + for suggestionTLD in suggestionTLDs where suggestionTLD != tld { + candidates.append("\(base).\(suggestionTLD)") + if candidates.count == limit { + return candidates + } + } + + if !base.contains("-"), base.count >= 6, candidates.count < limit { + let midpoint = base.index(base.startIndex, offsetBy: base.count / 2) + let hyphenated = "\(base[.. String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + + private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) { + #if DEBUG + print("[Availability] \(domain) -> \(status.rawValue) via \(method)") + #endif + } + + private static func debugLog(_ method: String, domain: String, details: String) { + #if DEBUG + print("[Availability] \(domain) -> \(method): \(details)") + #endif + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 638a96b..839790b 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -72,6 +72,13 @@ struct PortScanRowViewData: Identifiable { let durationLabel: String? } +struct DomainSuggestionViewData: Identifiable { + let id: UUID + let domain: String + let status: String + let tone: ResultTone +} + struct LookupSnapshot { let domain: String let timestamp: Date @@ -80,6 +87,8 @@ struct LookupSnapshot { let totalLookupDurationMs: Int? let dnsSections: [DNSSection] let dnsError: String? + let availabilityResult: DomainAvailabilityResult? + let suggestions: [DomainSuggestionResult] let sslInfo: SSLCertificateInfo? let sslError: String? let hstsPreloaded: Bool? @@ -115,6 +124,8 @@ extension HistoryEntry { totalLookupDurationMs: totalLookupDurationMs, dnsSections: dnsSections, dnsError: nil, + availabilityResult: availabilityResult, + suggestions: suggestions, sslInfo: sslInfo, sslError: sslError, hstsPreloaded: hstsPreloaded, @@ -150,6 +161,10 @@ final class DomainViewModel { var dnsSections: [DNSSection] = [] var dnsLoading = false var dnsError: String? + var availabilityResult: DomainAvailabilityResult? + var availabilityLoading = false + var suggestions: [DomainSuggestionResult] = [] + var suggestionsLoading = false var sslInfo: SSLCertificateInfo? var sslLoading = false @@ -209,6 +224,15 @@ final class DomainViewModel { private static let savedDomainsKey = "savedDomains" var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] + private static let watchedDomainsKey = "watchedDomains" + var watchedDomains: [WatchedDomain] = { + guard let data = UserDefaults.standard.data(forKey: watchedDomainsKey), + let domains = try? JSONDecoder().decode([WatchedDomain].self, from: data) else { + return [] + } + return domains + }() + private static let historyKey = "lookupHistory" private static let maxHistory = 50 var history: [HistoryEntry] = { @@ -230,6 +254,8 @@ final class DomainViewModel { var resultsLoaded: Bool { hasRun && !dnsLoading && + !availabilityLoading && + !suggestionsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && @@ -250,6 +276,10 @@ final class DomainViewModel { !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) } + var isCurrentDomainWatched: Bool { + !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() }) + } + var resolverDisplayName: String { DNSLookupService.currentResolverDisplayName() } @@ -276,6 +306,8 @@ final class DomainViewModel { totalLookupDurationMs: lastLookupDurationMs, dnsSections: dnsSections, dnsError: dnsError, + availabilityResult: availabilityResult, + suggestions: suggestions, sslInfo: sslInfo, sslError: sslError, hstsPreloaded: hstsPreloaded, @@ -314,6 +346,10 @@ final class DomainViewModel { Self.dnsRows(from: currentSnapshot) } + var suggestionRows: [DomainSuggestionViewData] { + Self.suggestionRows(from: currentSnapshot) + } + var dnssecLabel: String? { Self.dnssecLabel(from: currentSnapshot) } @@ -372,6 +408,33 @@ final class DomainViewModel { UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) } + func toggleWatchedDomain() { + guard !searchedDomain.isEmpty else { return } + toggleWatchedDomain(domain: searchedDomain, availabilityStatus: availabilityResult?.status) + } + + func toggleWatchedDomain(domain: String, availabilityStatus: DomainAvailabilityStatus?) { + guard !domain.isEmpty else { return } + + if watchedDomains.contains(where: { $0.domain.lowercased() == domain.lowercased() }) { + watchedDomains.removeAll { $0.domain.lowercased() == domain.lowercased() } + } else { + watchedDomains.insert( + WatchedDomain( + domain: domain, + lastKnownAvailability: availabilityStatus + ), + at: 0 + ) + } + persistWatchedDomains() + } + + func removeWatchedDomains(at offsets: IndexSet) { + watchedDomains.remove(atOffsets: offsets) + persistWatchedDomains() + } + func removeHistoryEntries(at offsets: IndexSet) { history.remove(atOffsets: offsets) persistHistory() @@ -456,6 +519,7 @@ final class DomainViewModel { private func performLookup(domain: String, lookupID: UUID) async { await withTaskGroup(of: Void.self) { group in group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } + group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } @@ -479,6 +543,15 @@ final class DomainViewModel { } } + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + + if availabilityResult?.status == .registered { + await runSuggestions(domain: domain, lookupID: lookupID) + } else { + suggestions = [] + suggestionsLoading = false + } + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } saveHistoryEntry(replaceLatest: false) @@ -501,6 +574,14 @@ final class DomainViewModel { dnsLoading = false } + private func runAvailability(domain: String, lookupID: UUID) async { + let result = await DomainAvailabilityService.check(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + availabilityResult = result + availabilityLoading = false + updateWatchedDomainAvailability(for: result.domain, status: result.status) + } + private func runSSL(domain: String, lookupID: UUID) async { let result = await SSLCheckService.check(domain: domain) guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } @@ -670,6 +751,13 @@ final class DomainViewModel { ipGeolocationError = "No A record available" } + private func runSuggestions(domain: String, lookupID: UUID) async { + let results = await DomainAvailabilityService.suggestions(for: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + suggestions = results + suggestionsLoading = false + } + private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { switch result { case let .success(results): @@ -727,6 +815,8 @@ final class DomainViewModel { redirectChain: redirectChain, portScanResults: allPortScanResults, hstsPreloaded: hstsPreloaded, + availabilityResult: availabilityResult, + suggestions: suggestions, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, totalLookupDurationMs: lastLookupDurationMs, @@ -757,6 +847,20 @@ final class DomainViewModel { } } + private func persistWatchedDomains() { + if let data = try? JSONEncoder().encode(watchedDomains) { + UserDefaults.standard.set(data, forKey: Self.watchedDomainsKey) + } + } + + private func updateWatchedDomainAvailability(for domain: String, status: DomainAvailabilityStatus) { + guard let index = watchedDomains.firstIndex(where: { $0.domain.lowercased() == domain.lowercased() }) else { + return + } + watchedDomains[index].lastKnownAvailability = status + persistWatchedDomains() + } + private func addRecentSearch(_ domain: String) { recentSearches.removeAll { $0.lowercased() == domain.lowercased() } recentSearches.insert(domain, at: 0) @@ -770,6 +874,10 @@ final class DomainViewModel { dnsSections = [] dnsError = nil dnsLoading = false + availabilityResult = nil + availabilityLoading = false + suggestions = [] + suggestionsLoading = false sslInfo = nil sslError = nil sslLoading = false @@ -808,6 +916,8 @@ final class DomainViewModel { private func setAllLoadingStates(_ loading: Bool) { dnsLoading = loading + availabilityLoading = loading + suggestionsLoading = loading sslLoading = loading hstsLoading = loading httpHeadersLoading = loading @@ -838,12 +948,32 @@ final class DomainViewModel { } static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { - [ + var rows = [ InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) ] + rows.insert( + InfoRowViewData( + label: "Availability", + value: availabilityLabel(snapshot.availabilityResult?.status), + tone: availabilityTone(snapshot.availabilityResult?.status) + ), + at: 1 + ) + return rows + } + + static func suggestionRows(from snapshot: LookupSnapshot) -> [DomainSuggestionViewData] { + snapshot.suggestions.map { + DomainSuggestionViewData( + id: $0.id, + domain: $0.domain, + status: availabilityLabel($0.status), + tone: availabilityTone($0.status) + ) + } } static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { @@ -879,8 +1009,8 @@ final class DomainViewModel { var rows = [ InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), - InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary), - InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary), + InfoRowViewData(label: "Valid From", value: certificateDateFormatter.string(from: sslInfo.validFrom), tone: .secondary), + InfoRowViewData(label: "Valid Until", value: certificateDateFormatter.string(from: sslInfo.validUntil), tone: .secondary), InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) ] @@ -1010,6 +1140,14 @@ final class DomainViewModel { for row in domainRows(from: snapshot) { lines.append(" \(row.label): \(row.value)") } + if snapshot.suggestions.isEmpty { + lines.append(" Suggestions: None") + } else { + lines.append(" Suggestions:") + for suggestion in snapshot.suggestions { + lines.append(" \(suggestion.domain): \(availabilityLabel(suggestion.status))") + } + } } appendSection("DNS") { @@ -1183,6 +1321,28 @@ final class DomainViewModel { return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" } + private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown, .none: + return "Unknown" + } + } + + private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { + switch status { + case .available: + return .success + case .registered: + return .warning + case .unknown, .none: + return .secondary + } + } + private static func securityGradeTone(_ grade: String) -> ResultTone { switch grade { case "A", "B": @@ -1199,6 +1359,13 @@ final class DomainViewModel { private static func durationLabel(_ durationMs: Int?) -> String { durationMs.map { "\($0) ms" } ?? "Unavailable" } + + private static let certificateDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() } private extension String { diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index cc4321f..5bbbb91 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -78,7 +78,17 @@ struct HistoryDetailView: View { snapshotBanner SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) .padding(.top, 8) - DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot)) + DomainSectionView( + rows: DomainViewModel.domainRows(from: snapshot), + suggestions: DomainViewModel.suggestionRows(from: snapshot), + showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, + availabilityLoading: false, + suggestionsLoading: false, + isWatched: viewModel.watchedDomains.contains(where: { $0.domain.lowercased() == entry.domain.lowercased() }), + onToggleWatch: { + viewModel.toggleWatchedDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) + } + ) .padding(.top, 16) DNSSectionView( dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index d854137..5a836ba 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -6,6 +6,48 @@ enum ServiceResult { case error(String) } +enum DomainAvailabilityStatus: String, Codable { + case available + case registered + case unknown +} + +struct DomainAvailabilityResult: Codable { + let domain: String + let status: DomainAvailabilityStatus +} + +struct DomainSuggestionResult: Identifiable, Codable { + let id: UUID + let domain: String + let status: DomainAvailabilityStatus + + init(id: UUID = UUID(), domain: String, status: DomainAvailabilityStatus) { + self.id = id + self.domain = domain + self.status = status + } +} + +struct WatchedDomain: Codable, Identifiable { + let id: UUID + let domain: String + let createdAt: Date + var lastKnownAvailability: DomainAvailabilityStatus? + + init( + id: UUID = UUID(), + domain: String, + createdAt: Date = Date(), + lastKnownAvailability: DomainAvailabilityStatus? = nil + ) { + self.id = id + self.domain = domain + self.createdAt = createdAt + self.lastKnownAvailability = lastKnownAvailability + } +} + // MARK: - DNS Models enum DNSRecordType: String, CaseIterable, Codable { @@ -290,6 +332,8 @@ struct HistoryEntry: Identifiable, Codable { var redirectChain: [RedirectHop] var portScanResults: [PortScanResult] var hstsPreloaded: Bool? + var availabilityResult: DomainAvailabilityResult? + var suggestions: [DomainSuggestionResult] var resolverDisplayName: String var resolverURLString: String var totalLookupDurationMs: Int? @@ -307,7 +351,8 @@ struct HistoryEntry: Identifiable, Codable { reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], - hstsPreloaded: Bool? = nil, resolverDisplayName: String, resolverURLString: String, + hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, + suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, reachabilityError: String? = nil, ipGeolocationError: String? = nil, emailSecurityError: String? = nil, ptrError: String? = nil, @@ -325,6 +370,8 @@ struct HistoryEntry: Identifiable, Codable { self.redirectChain = redirectChain self.portScanResults = portScanResults self.hstsPreloaded = hstsPreloaded + self.availabilityResult = availabilityResult + self.suggestions = suggestions self.resolverDisplayName = resolverDisplayName self.resolverURLString = resolverURLString self.totalLookupDurationMs = totalLookupDurationMs @@ -354,6 +401,8 @@ struct HistoryEntry: Identifiable, Codable { redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) + availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) + suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift new file mode 100644 index 0000000..c44fc65 --- /dev/null +++ b/DomainDig/WatchlistView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct WatchlistView: View { + @Bindable var viewModel: DomainViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + List { + if viewModel.watchedDomains.isEmpty { + Text("No watched domains") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.watchedDomains) { watchedDomain in + Button { + viewModel.domain = watchedDomain.domain + dismiss() + viewModel.run() + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(watchedDomain.domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + Text(statusLabel(watchedDomain.lastKnownAvailability)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(statusColor(watchedDomain.lastKnownAvailability)) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + viewModel.removeWatchedDomains(at: offsets) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Watchlist") + .toolbar { + if !viewModel.watchedDomains.isEmpty { + EditButton() + } + } + .preferredColorScheme(.dark) + } + + private func statusLabel(_ status: DomainAvailabilityStatus?) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown, .none: + return "Unknown" + } + } + + private func statusColor(_ status: DomainAvailabilityStatus?) -> Color { + switch status { + case .available: + return .green + case .registered: + return .yellow + case .unknown, .none: + return .secondary + } + } +} -- cgit v1.2.3