krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

397c7c420c22fc1c235893bd85b2029da9145730

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-20T20:48:47Z

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(-)

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<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) }
@@ -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<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
+        }
+    }
+}