summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift108
-rw-r--r--DomainDig/DomainAvailabilityService.swift153
-rw-r--r--DomainDig/DomainViewModel.swift173
-rw-r--r--DomainDig/HistoryView.swift12
-rw-r--r--DomainDig/Models.swift51
-rw-r--r--DomainDig/WatchlistView.swift69
6 files changed, 551 insertions, 15 deletions
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,
@@ -101,6 +109,12 @@ struct ContentView: View {
.foregroundStyle(.secondary)
}
NavigationLink {
+ WatchlistView(viewModel: viewModel)
+ } label: {
+ Image(systemName: "eye")
+ .foregroundStyle(.secondary)
+ }
+ NavigationLink {
SettingsView()
} label: {
Image(systemName: "gearshape")
@@ -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<Content: View>: 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[..<midpoint])-\(base[midpoint...]).\(tld)"
+ if hyphenated != domain {
+ candidates.append(hyphenated)
+ }
+ }
+
+ return Array(candidates.prefix(limit))
+ }
+
+ private static func normalize(_ domain: String) -> 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) }
@@ -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 {
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<Value> {
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
+ }
+ }
+}