diff options
| author | Christian Cleberg <[email protected]> | 2026-04-20 15:48:47 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-20 15:48:47 -0500 |
| commit | 397c7c420c22fc1c235893bd85b2029da9145730 (patch) | |
| tree | d5c12b992c4496eb928f7253252749acb9ac0386 /DomainDig/DomainViewModel.swift | |
| parent | ee2520f2c0c2d806fd284c3d6b8a81a3822f5270 (diff) | |
| download | domain-dig-397c7c420c22fc1c235893bd85b2029da9145730.tar.gz domain-dig-397c7c420c22fc1c235893bd85b2029da9145730.tar.bz2 domain-dig-397c7c420c22fc1c235893bd85b2029da9145730.zip | |
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
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 173 |
1 files changed, 170 insertions, 3 deletions
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) } @@ -480,6 +544,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 { |
