Commit 397c7c420c

397c7c420c22fc1c235893bd85b2029da9145730

parent: ee2520f2c0

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-20 20:48 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -267,7 +267,7 @@
267267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 11;
270 CURRENT_PROJECT_VERSION = 12;
271271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272272 ENABLE_PREVIEWS = YES;
273273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284284 "$(inherited)",
285285 "@executable_path/Frameworks",
286286 );
287 MARKETING_VERSION = 1.7.2;
287 MARKETING_VERSION = 1.9.0;
288288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289289 PRODUCT_NAME = "$(TARGET_NAME)";
290290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 11;
306 CURRENT_PROJECT_VERSION = 12;
307307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308308 ENABLE_PREVIEWS = YES;
309309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320320 "$(inherited)",
321321 "@executable_path/Frameworks",
322322 );
323 MARKETING_VERSION = 1.7.2;
323 MARKETING_VERSION = 1.9.0;
324324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325325 PRODUCT_NAME = "$(TARGET_NAME)";
326326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +98 −10
@@ -16,7 +16,15 @@ struct ContentView: View {
1616 actionButtons
1717 SummaryView(fields: viewModel.summaryFields)
1818 .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 )
2028 .padding(.top, 16)
2129 DNSSectionView(
2230 dnssecLabel: viewModel.dnssecLabel,
@@ -100,6 +108,12 @@ struct ContentView: View {
100108 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
101109 .foregroundStyle(.secondary)
102110 }
111 NavigationLink {
112 WatchlistView(viewModel: viewModel)
113 } label: {
114 Image(systemName: "eye")
115 .foregroundStyle(.secondary)
116 }
103117 NavigationLink {
104118 SettingsView()
105119 } label: {
@@ -280,14 +294,58 @@ struct SummaryView: View {
280294
281295struct DomainSectionView: View {
282296 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
283303
284304 var body: some View {
285305 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 }
287315 CardView {
288316 ForEach(rows) { row in
289317 LabeledValueRow(row: row)
290318 }
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 }
291349 }
292350 }
293351 }
@@ -384,6 +442,7 @@ struct WebSectionView: View {
384442 .foregroundStyle(.cyan)
385443 if sslLoading {
386444 ProgressView("Checking certificate…")
445 .appLoadingStyle()
387446 } else if let sslError {
388447 MessageRowView(text: sslError, isError: true)
389448 } else {
@@ -410,6 +469,7 @@ struct WebSectionView: View {
410469 .foregroundStyle(.cyan)
411470 if headersLoading {
412471 ProgressView("Fetching headers…")
472 .appLoadingStyle()
413473 } else if let headersError {
414474 MessageRowView(text: headersError, isError: true)
415475 } else {
@@ -441,6 +501,7 @@ struct WebSectionView: View {
441501 .foregroundStyle(.cyan)
442502 if redirectLoading {
443503 ProgressView("Tracing redirects…")
504 .appLoadingStyle()
444505 } else if let redirectError {
445506 MessageRowView(text: redirectError, isError: true)
446507 } else if redirects.isEmpty {
@@ -486,6 +547,7 @@ struct EmailSectionView: View {
486547 CardView {
487548 if loading {
488549 ProgressView("Checking email records…")
550 .appLoadingStyle()
489551 } else if let error {
490552 MessageRowView(text: error, isError: true)
491553 } else if rows.isEmpty {
@@ -549,6 +611,7 @@ struct NetworkSectionView: View {
549611 .foregroundStyle(.cyan)
550612 if reachabilityLoading {
551613 ProgressView("Checking ports…")
614 .appLoadingStyle()
552615 } else if let reachabilityError {
553616 MessageRowView(text: reachabilityError, isError: true)
554617 } else {
@@ -568,13 +631,14 @@ struct NetworkSectionView: View {
568631 }
569632 }
570633
571 CardView {
634 CardView(allowsHorizontalScroll: false) {
572635 Text("Location")
573636 .font(.system(.subheadline, design: .monospaced))
574637 .fontWeight(.semibold)
575638 .foregroundStyle(.cyan)
576639 if geolocationLoading {
577640 ProgressView("Looking up location…")
641 .appLoadingStyle()
578642 } else if let geolocationError, geolocation == nil {
579643 MessageRowView(text: geolocationError, isError: geolocationError != "No A record available")
580644 } else if let geolocation {
@@ -590,6 +654,7 @@ struct NetworkSectionView: View {
590654 Marker(geolocation.ip, coordinate: coordinate)
591655 }
592656 .mapStyle(.standard)
657 .frame(maxWidth: .infinity)
593658 .frame(height: 180)
594659 .cornerRadius(8)
595660 }
@@ -598,7 +663,7 @@ struct NetworkSectionView: View {
598663 }
599664 }
600665
601 CardView {
666 CardView(allowsHorizontalScroll: false) {
602667 Text("Port Scan")
603668 .font(.system(.subheadline, design: .monospaced))
604669 .fontWeight(.semibold)
@@ -608,10 +673,12 @@ struct NetworkSectionView: View {
608673 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
609674 .font(.system(.caption2, design: .monospaced))
610675 .foregroundStyle(.orange)
676 .fixedSize(horizontal: false, vertical: true)
611677 }
612678
613679 if portScanLoading {
614680 ProgressView("Scanning ports…")
681 .appLoadingStyle()
615682 } else if let portScanError, standardPortRows.isEmpty {
616683 MessageRowView(text: portScanError, isError: true)
617684 } else {
@@ -640,6 +707,7 @@ struct NetworkSectionView: View {
640707
641708 if customPortScanLoading {
642709 ProgressView("Scanning custom ports…")
710 .appLoadingStyle()
643711 } else if let customPortScanError {
644712 MessageRowView(text: customPortScanError, isError: true)
645713 } else {
@@ -704,25 +772,38 @@ struct SectionTitleView: View {
704772}
705773
706774struct CardView<Content: View>: View {
775 let allowsHorizontalScroll: Bool
707776 let content: Content
708777
709 init(@ViewBuilder content: () -> Content) {
778 init(allowsHorizontalScroll: Bool = true, @ViewBuilder content: () -> Content) {
779 self.allowsHorizontalScroll = allowsHorizontalScroll
710780 self.content = content()
711781 }
712782
713783 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)
717794 }
718 .scrollTargetLayout()
719795 }
720 .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
721796 .frame(maxWidth: .infinity, alignment: .leading)
722797 .padding(10)
723798 .background(Color(.systemGray6).opacity(0.5))
724799 .cornerRadius(6)
725800 }
801
802 private var cardContent: some View {
803 VStack(alignment: .leading, spacing: 6) {
804 content
805 }
806 }
726807}
727808
728809struct LoadingCardView: View {
@@ -731,6 +812,7 @@ struct LoadingCardView: View {
731812 var body: some View {
732813 CardView {
733814 ProgressView(text)
815 .appLoadingStyle()
734816 .frame(maxWidth: .infinity, alignment: .center)
735817 }
736818 }
@@ -800,6 +882,12 @@ extension DateFormatter {
800882 }()
801883}
802884
885private extension View {
886 func appLoadingStyle() -> some View {
887 font(.system(.caption, design: .monospaced))
888 }
889}
890
803891private struct SettingsView: View {
804892 @AppStorage(DNSResolverOption.userDefaultsKey)
805893 private var storedResolverURL = DNSResolverOption.defaultURLString
DomainDig/DomainAvailabilityService.swift added +153
@@ -0,0 +1,153 @@
1import Foundation
2
3struct 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 {
7272 let durationLabel: String?
7373}
7474
75struct DomainSuggestionViewData: Identifiable {
76 let id: UUID
77 let domain: String
78 let status: String
79 let tone: ResultTone
80}
81
7582struct LookupSnapshot {
7683 let domain: String
7784 let timestamp: Date
@@ -80,6 +87,8 @@ struct LookupSnapshot {
8087 let totalLookupDurationMs: Int?
8188 let dnsSections: [DNSSection]
8289 let dnsError: String?
90 let availabilityResult: DomainAvailabilityResult?
91 let suggestions: [DomainSuggestionResult]
8392 let sslInfo: SSLCertificateInfo?
8493 let sslError: String?
8594 let hstsPreloaded: Bool?
@@ -115,6 +124,8 @@ extension HistoryEntry {
115124 totalLookupDurationMs: totalLookupDurationMs,
116125 dnsSections: dnsSections,
117126 dnsError: nil,
127 availabilityResult: availabilityResult,
128 suggestions: suggestions,
118129 sslInfo: sslInfo,
119130 sslError: sslError,
120131 hstsPreloaded: hstsPreloaded,
@@ -150,6 +161,10 @@ final class DomainViewModel {
150161 var dnsSections: [DNSSection] = []
151162 var dnsLoading = false
152163 var dnsError: String?
164 var availabilityResult: DomainAvailabilityResult?
165 var availabilityLoading = false
166 var suggestions: [DomainSuggestionResult] = []
167 var suggestionsLoading = false
153168
154169 var sslInfo: SSLCertificateInfo?
155170 var sslLoading = false
@@ -209,6 +224,15 @@ final class DomainViewModel {
209224 private static let savedDomainsKey = "savedDomains"
210225 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
211226
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
212236 private static let historyKey = "lookupHistory"
213237 private static let maxHistory = 50
214238 var history: [HistoryEntry] = {
@@ -230,6 +254,8 @@ final class DomainViewModel {
230254 var resultsLoaded: Bool {
231255 hasRun &&
232256 !dnsLoading &&
257 !availabilityLoading &&
258 !suggestionsLoading &&
233259 !sslLoading &&
234260 !hstsLoading &&
235261 !httpHeadersLoading &&
@@ -250,6 +276,10 @@ final class DomainViewModel {
250276 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
251277 }
252278
279 var isCurrentDomainWatched: Bool {
280 !searchedDomain.isEmpty && watchedDomains.contains(where: { $0.domain.lowercased() == searchedDomain.lowercased() })
281 }
282
253283 var resolverDisplayName: String {
254284 DNSLookupService.currentResolverDisplayName()
255285 }
@@ -276,6 +306,8 @@ final class DomainViewModel {
276306 totalLookupDurationMs: lastLookupDurationMs,
277307 dnsSections: dnsSections,
278308 dnsError: dnsError,
309 availabilityResult: availabilityResult,
310 suggestions: suggestions,
279311 sslInfo: sslInfo,
280312 sslError: sslError,
281313 hstsPreloaded: hstsPreloaded,
@@ -314,6 +346,10 @@ final class DomainViewModel {
314346 Self.dnsRows(from: currentSnapshot)
315347 }
316348
349 var suggestionRows: [DomainSuggestionViewData] {
350 Self.suggestionRows(from: currentSnapshot)
351 }
352
317353 var dnssecLabel: String? {
318354 Self.dnssecLabel(from: currentSnapshot)
319355 }
@@ -372,6 +408,33 @@ final class DomainViewModel {
372408 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
373409 }
374410
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
375438 func removeHistoryEntries(at offsets: IndexSet) {
376439 history.remove(atOffsets: offsets)
377440 persistHistory()
@@ -456,6 +519,7 @@ final class DomainViewModel {
456519 private func performLookup(domain: String, lookupID: UUID) async {
457520 await withTaskGroup(of: Void.self) { group in
458521 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
522 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
459523 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
460524 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
461525 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
@@ -479,6 +543,15 @@ final class DomainViewModel {
479543 }
480544 }
481545
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
482555 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
483556 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
484557 saveHistoryEntry(replaceLatest: false)
@@ -501,6 +574,14 @@ final class DomainViewModel {
501574 dnsLoading = false
502575 }
503576
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
504585 private func runSSL(domain: String, lookupID: UUID) async {
505586 let result = await SSLCheckService.check(domain: domain)
506587 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
@@ -670,6 +751,13 @@ final class DomainViewModel {
670751 ipGeolocationError = "No A record available"
671752 }
672753
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
673761 private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) {
674762 switch result {
675763 case let .success(results):
@@ -727,6 +815,8 @@ final class DomainViewModel {
727815 redirectChain: redirectChain,
728816 portScanResults: allPortScanResults,
729817 hstsPreloaded: hstsPreloaded,
818 availabilityResult: availabilityResult,
819 suggestions: suggestions,
730820 resolverDisplayName: resolverDisplayName,
731821 resolverURLString: resolverURLString,
732822 totalLookupDurationMs: lastLookupDurationMs,
@@ -757,6 +847,20 @@ final class DomainViewModel {
757847 }
758848 }
759849
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
760864 private func addRecentSearch(_ domain: String) {
761865 recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
762866 recentSearches.insert(domain, at: 0)
@@ -770,6 +874,10 @@ final class DomainViewModel {
770874 dnsSections = []
771875 dnsError = nil
772876 dnsLoading = false
877 availabilityResult = nil
878 availabilityLoading = false
879 suggestions = []
880 suggestionsLoading = false
773881 sslInfo = nil
774882 sslError = nil
775883 sslLoading = false
@@ -808,6 +916,8 @@ final class DomainViewModel {
808916
809917 private func setAllLoadingStates(_ loading: Bool) {
810918 dnsLoading = loading
919 availabilityLoading = loading
920 suggestionsLoading = loading
811921 sslLoading = loading
812922 hstsLoading = loading
813923 httpHeadersLoading = loading
@@ -838,12 +948,32 @@ final class DomainViewModel {
838948 }
839949
840950 static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
841 [
951 var rows = [
842952 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
843953 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
844954 InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
845955 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
846956 ]
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 }
847977 }
848978
849979 static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] {
@@ -879,8 +1009,8 @@ final class DomainViewModel {
8791009 var rows = [
8801010 InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary),
8811011 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),
8841014 InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)),
8851015 InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary)
8861016 ]
@@ -1010,6 +1140,14 @@ final class DomainViewModel {
10101140 for row in domainRows(from: snapshot) {
10111141 lines.append(" \(row.label): \(row.value)")
10121142 }
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 }
10131151 }
10141152
10151153 appendSection("DNS") {
@@ -1183,6 +1321,28 @@ final class DomainViewModel {
11831321 return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")"
11841322 }
11851323
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
11861346 private static func securityGradeTone(_ grade: String) -> ResultTone {
11871347 switch grade {
11881348 case "A", "B":
@@ -1199,6 +1359,13 @@ final class DomainViewModel {
11991359 private static func durationLabel(_ durationMs: Int?) -> String {
12001360 durationMs.map { "\($0) ms" } ?? "Unavailable"
12011361 }
1362
1363 private static let certificateDateFormatter: DateFormatter = {
1364 let formatter = DateFormatter()
1365 formatter.dateStyle = .medium
1366 formatter.timeStyle = .short
1367 return formatter
1368 }()
12021369}
12031370
12041371private extension String {
DomainDig/HistoryView.swift +11 −1
@@ -78,7 +78,17 @@ struct HistoryDetailView: View {
7878 snapshotBanner
7979 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
8080 .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 )
8292 .padding(.top, 16)
8393 DNSSectionView(
8494 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
DomainDig/Models.swift +50 −1
@@ -6,6 +6,48 @@ enum ServiceResult<Value> {
66 case error(String)
77}
88
9enum DomainAvailabilityStatus: String, Codable {
10 case available
11 case registered
12 case unknown
13}
14
15struct DomainAvailabilityResult: Codable {
16 let domain: String
17 let status: DomainAvailabilityStatus
18}
19
20struct 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
32struct 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
951// MARK: - DNS Models
1052
1153enum DNSRecordType: String, CaseIterable, Codable {
@@ -290,6 +332,8 @@ struct HistoryEntry: Identifiable, Codable {
290332 var redirectChain: [RedirectHop]
291333 var portScanResults: [PortScanResult]
292334 var hstsPreloaded: Bool?
335 var availabilityResult: DomainAvailabilityResult?
336 var suggestions: [DomainSuggestionResult]
293337 var resolverDisplayName: String
294338 var resolverURLString: String
295339 var totalLookupDurationMs: Int?
@@ -307,7 +351,8 @@ struct HistoryEntry: Identifiable, Codable {
307351 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
308352 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
309353 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,
311356 totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
312357 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
313358 emailSecurityError: String? = nil, ptrError: String? = nil,
@@ -325,6 +370,8 @@ struct HistoryEntry: Identifiable, Codable {
325370 self.redirectChain = redirectChain
326371 self.portScanResults = portScanResults
327372 self.hstsPreloaded = hstsPreloaded
373 self.availabilityResult = availabilityResult
374 self.suggestions = suggestions
328375 self.resolverDisplayName = resolverDisplayName
329376 self.resolverURLString = resolverURLString
330377 self.totalLookupDurationMs = totalLookupDurationMs
@@ -354,6 +401,8 @@ struct HistoryEntry: Identifiable, Codable {
354401 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
355402 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
356403 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) ?? []
357406 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
358407 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
359408 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
DomainDig/WatchlistView.swift added +69
@@ -0,0 +1,69 @@
1import SwiftUI
2
3struct 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}