krz/domain-dig

an ios app for DNS & SSL analysis

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

2e90ee4ceca73d5126118920b486149cb227c4e9

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-20T21:57:40Z

feat(v2.1.0): add bulk workflows, CSV export, and filtering
 DomainDig.xcodeproj/project.pbxproj  |   8 +-
 DomainDig/BatchResultsView.swift     | 114 ++++++++
 DomainDig/ContentView.swift          | 293 +++++++++++++------
 DomainDig/DomainDiffService.swift    |  18 +-
 DomainDig/DomainViewModel.swift      | 541 ++++++++++++++++++++++++++++++++---
 DomainDig/ExportPresenter.swift      |  28 ++
 DomainDig/HistoryView.swift          |  39 ++-
 DomainDig/Models.swift               | 141 +++++++++
 DomainDig/PremiumAccessService.swift |  18 +-
 DomainDig/WatchlistView.swift        |  78 ++++-
 10 files changed, 1131 insertions(+), 147 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 58f2c35..8958217 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 = 13;
+				CURRENT_PROJECT_VERSION = 14;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.0.0;
+				MARKETING_VERSION = 2.1.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 = 13;
+				CURRENT_PROJECT_VERSION = 14;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.0.0;
+				MARKETING_VERSION = 2.1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
new file mode 100644
index 0000000..e741830
--- /dev/null
+++ b/DomainDig/BatchResultsView.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+struct BatchResultsView: View {
+    @Bindable var viewModel: DomainViewModel
+    let title: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack(alignment: .top) {
+                SectionTitleView(title: title)
+                Spacer()
+                if viewModel.batchLookupRunning {
+                    VStack(alignment: .trailing, spacing: 4) {
+                        ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
+                            .tint(.cyan)
+                            .frame(width: 120)
+                        Text(viewModel.batchProgressLabel)
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    }
+                } else if !viewModel.batchResults.isEmpty {
+                    Text("\(viewModel.batchResults.count) domains")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                }
+            }
+
+            if viewModel.batchResults.isEmpty {
+                MessageCardView(text: "No batch results yet", isError: false)
+            } else {
+                CardView(allowsHorizontalScroll: false) {
+                    ForEach(viewModel.batchResults) { result in
+                        if let entry = viewModel.historyEntry(for: result) {
+                            NavigationLink {
+                                HistoryDetailView(viewModel: viewModel, entry: entry)
+                            } label: {
+                                BatchResultRowView(result: result)
+                            }
+                            .buttonStyle(.plain)
+                        } else {
+                            BatchResultRowView(result: result)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+struct BatchResultRowView: View {
+    let result: BatchLookupResult
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .firstTextBaseline, spacing: 8) {
+                Text(result.domain)
+                    .font(.system(.callout, design: .monospaced))
+                    .foregroundStyle(.primary)
+                    .lineLimit(1)
+                Spacer(minLength: 8)
+                Text(result.quickStatus)
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(quickStatusColor)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(quickStatusColor.opacity(0.16))
+                    .clipShape(Capsule())
+            }
+
+            HStack(spacing: 10) {
+                Text(availabilityText)
+                Text(result.primaryIP ?? "No IP")
+                Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
+            }
+            .font(.system(.caption2, design: .monospaced))
+            .foregroundStyle(.secondary)
+
+            if let errorMessage = result.errorMessage {
+                Text(errorMessage)
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.red)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.vertical, 4)
+    }
+
+    private var availabilityText: String {
+        switch result.availability {
+        case .available:
+            return "Available"
+        case .registered:
+            return "Registered"
+        case .unknown, .none:
+            return "Unknown"
+        }
+    }
+
+    private var quickStatusColor: Color {
+        switch result.status {
+        case .pending:
+            return .secondary
+        case .running:
+            return .cyan
+        case .completed:
+            if result.quickStatus == "Changed" {
+                return .yellow
+            }
+            return .green
+        case .failed:
+            return .red
+        }
+    }
+}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 69f3af9..cda9f9e 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -1,6 +1,13 @@
 import MapKit
 import SwiftUI
 
+enum LookupInputMode: String, CaseIterable, Identifiable {
+    case single
+    case bulk
+
+    var id: String { rawValue }
+}
+
 struct ContentView: View {
     @State private var viewModel = DomainViewModel()
     @State private var navigationPath = NavigationPath()
@@ -10,12 +17,17 @@ struct ContentView: View {
     @State private var trackingNoteDraft = ""
     @State private var editingTrackedDomain: TrackedDomain?
     @State private var showTrackLimitAlert = false
+    @State private var inputMode: LookupInputMode = .single
 
     var body: some View {
         NavigationStack(path: $navigationPath) {
             ScrollView(.vertical) {
                 VStack(spacing: 0) {
                     inputSection
+                    if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
+                        batchSection
+                            .padding(.top, 8)
+                    }
                     if viewModel.hasRun {
                         actionButtons
                         SummaryView(fields: viewModel.summaryFields)
@@ -199,28 +211,68 @@ struct ContentView: View {
 
     private var inputSection: some View {
         VStack(spacing: 12) {
-            TextField("e.g. cleberg.net", text: $viewModel.domain)
-                .font(.system(.title3, design: .monospaced))
-                .textInputAutocapitalization(.never)
-                .autocorrectionDisabled()
-                .keyboardType(.URL)
-                .padding(12)
-                .background(Color(.systemGray6))
-                .cornerRadius(8)
-                .focused($domainFieldFocused)
-                .onSubmit { viewModel.run() }
-
-            Button {
-                domainFieldFocused = false
-                viewModel.run()
-            } label: {
-                Text("Run")
-                    .font(.headline)
-                    .frame(maxWidth: .infinity)
-                    .padding(.vertical, 12)
+            Picker("Mode", selection: $inputMode) {
+                Text("Single").tag(LookupInputMode.single)
+                Text("Bulk").tag(LookupInputMode.bulk)
+            }
+            .pickerStyle(.segmented)
+
+            if inputMode == .single {
+                TextField("e.g. cleberg.net", text: $viewModel.domain)
+                    .font(.system(.title3, design: .monospaced))
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .keyboardType(.URL)
+                    .padding(12)
+                    .background(Color(.systemGray6))
+                    .cornerRadius(8)
+                    .focused($domainFieldFocused)
+                    .onSubmit { viewModel.run() }
+
+                Button {
+                    domainFieldFocused = false
+                    viewModel.run()
+                } label: {
+                    Text("Run")
+                        .font(.headline)
+                        .frame(maxWidth: .infinity)
+                        .padding(.vertical, 12)
+                }
+                .buttonStyle(.borderedProminent)
+                .disabled(viewModel.trimmedDomain.isEmpty)
+            } else {
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Paste domains separated by new lines or commas.")
+                        .font(.system(.caption, design: .monospaced))
+                        .foregroundStyle(.secondary)
+
+                    TextField(
+                        "example.com\napple.com, openai.com",
+                        text: $viewModel.bulkInput,
+                        axis: .vertical
+                    )
+                    .font(.system(.body, design: .monospaced))
+                    .textInputAutocapitalization(.never)
+                    .autocorrectionDisabled()
+                    .keyboardType(.URL)
+                    .lineLimit(4...10)
+                    .padding(12)
+                    .background(Color(.systemGray6))
+                    .cornerRadius(8)
+
+                    Button {
+                        domainFieldFocused = false
+                        viewModel.runBulkLookup()
+                    } label: {
+                        Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
+                            .font(.headline)
+                            .frame(maxWidth: .infinity)
+                            .padding(.vertical, 12)
+                    }
+                    .buttonStyle(.borderedProminent)
+                    .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
+                }
             }
-            .buttonStyle(.borderedProminent)
-            .disabled(viewModel.trimmedDomain.isEmpty)
         }
         .padding(.vertical, 16)
     }
@@ -236,8 +288,13 @@ struct ContentView: View {
                         .font(.system(.body))
                         .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
                 }
-                Button {
-                    shareResults()
+                Menu {
+                    Button("Export TXT") {
+                        shareSingleResults(asCSV: false)
+                    }
+                    Button("Export CSV") {
+                        shareSingleResults(asCSV: true)
+                    }
                 } label: {
                     Image(systemName: "square.and.arrow.up")
                         .font(.system(.body))
@@ -247,6 +304,33 @@ struct ContentView: View {
         }
     }
 
+    private var batchSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack {
+                Spacer()
+                if !viewModel.currentBatchResultEntries.isEmpty {
+                    Menu {
+                        Button("Export Batch TXT") {
+                            shareBatchResults(asCSV: false)
+                        }
+                        Button("Export Batch CSV") {
+                            shareBatchResults(asCSV: true)
+                        }
+                    } label: {
+                        Label("Export", systemImage: "square.and.arrow.up")
+                            .font(.system(.caption, design: .monospaced))
+                    }
+                    .buttonStyle(.bordered)
+                }
+            }
+
+            BatchResultsView(
+                viewModel: viewModel,
+                title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
+            )
+        }
+    }
+
     private var recentSearchesSection: some View {
         VStack(alignment: .leading, spacing: 8) {
             HStack {
@@ -307,29 +391,33 @@ struct ContentView: View {
         return ports
     }
 
-    private func shareResults() {
-        let text = viewModel.exportText()
-        let dateFmt = DateFormatter()
-        dateFmt.dateFormat = "yyyyMMdd_HHmmss"
-        let timestamp = dateFmt.string(from: Date())
-        let filename = "\(timestamp)_domaindigresults.txt"
-        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
-
-        do {
-            try text.write(to: tempURL, atomically: true, encoding: .utf8)
-        } catch {
-            return
-        }
+    private func shareSingleResults(asCSV: Bool) {
+        let (filename, contents) = exportPayload(
+            prefix: "domaindig_single",
+            text: viewModel.exportText(),
+            csv: viewModel.exportCSV(),
+            asCSV: asCSV
+        )
+        ExportPresenter.share(filename: filename, contents: contents)
+    }
 
-        let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
-        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
-              let rootVC = windowScene.keyWindow?.rootViewController else { return }
-        var presenter = rootVC
-        while let presented = presenter.presentedViewController {
-            presenter = presented
-        }
-        activityVC.popoverPresentationController?.sourceView = presenter.view
-        presenter.present(activityVC, animated: true)
+    private func shareBatchResults(asCSV: Bool) {
+        let (filename, contents) = exportPayload(
+            prefix: "domaindig_batch",
+            text: viewModel.exportBatchText(),
+            csv: viewModel.exportBatchCSV(),
+            asCSV: asCSV
+        )
+        ExportPresenter.share(filename: filename, contents: contents)
+    }
+
+    private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
+        let timestamp = formatter.string(from: Date())
+        let fileExtension = asCSV ? "csv" : "txt"
+        let filename = "\(timestamp)_\(prefix).\(fileExtension)"
+        return (filename, asCSV ? csv : text)
     }
 }
 
@@ -387,17 +475,20 @@ struct DomainDiffView: View {
     let title: String
     let sections: [DomainDiffSection]
     let showsUnchanged: Bool
+    @State private var collapsedSections = Set<UUID>()
 
     private var filteredSections: [DomainDiffSection] {
-        guard !showsUnchanged else { return sections }
+        guard showsUnchanged else {
+            return sections
+                .map { section in
+                    DomainDiffSection(
+                        title: section.title,
+                        items: section.items.filter(\.hasChanges)
+                    )
+                }
+                .filter { !$0.items.isEmpty }
+        }
         return sections
-            .map { section in
-                DomainDiffSection(
-                    title: section.title,
-                    items: section.items.filter { $0.changeType != .unchanged }
-                )
-            }
-            .filter { !$0.items.isEmpty }
     }
 
     var body: some View {
@@ -408,34 +499,63 @@ struct DomainDiffView: View {
             } else {
                 ForEach(filteredSections) { section in
                     CardView(allowsHorizontalScroll: false) {
-                        Text(section.title)
-                            .font(.system(.subheadline, design: .monospaced))
-                            .fontWeight(.semibold)
-                            .foregroundStyle(.cyan)
-
-                        ForEach(section.items) { item in
-                            VStack(alignment: .leading, spacing: 4) {
-                                HStack {
-                                    Text(item.label)
-                                        .font(.system(.caption, design: .monospaced))
-                                        .foregroundStyle(.secondary)
-                                    Spacer()
-                                    Text(changeLabel(for: item.changeType))
-                                        .font(.system(.caption2, design: .monospaced))
-                                        .foregroundStyle(changeColor(for: item.changeType))
-                                }
-                                if let oldValue = item.oldValue {
-                                    Text("Old: \(oldValue)")
-                                        .font(.system(.caption2, design: .monospaced))
-                                        .foregroundStyle(.secondary)
-                                        .textSelection(.enabled)
-                                }
-                                if let newValue = item.newValue {
-                                    Text("New: \(newValue)")
-                                        .font(.system(.caption, design: .monospaced))
-                                        .foregroundStyle(.primary)
-                                        .textSelection(.enabled)
+                        DisclosureGroup(isExpanded: binding(for: section)) {
+                            let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
+
+                            ForEach(visibleItems) { item in
+                                VStack(alignment: .leading, spacing: 6) {
+                                    HStack {
+                                        Text(item.label)
+                                            .font(.system(.caption, design: .monospaced))
+                                            .foregroundStyle(.secondary)
+                                        Spacer()
+                                        Text(changeLabel(for: item.changeType))
+                                            .font(.system(.caption2, design: .monospaced))
+                                            .foregroundStyle(changeColor(for: item.changeType))
+                                            .padding(.horizontal, 8)
+                                            .padding(.vertical, 4)
+                                            .background(changeColor(for: item.changeType).opacity(0.16))
+                                            .clipShape(Capsule())
+                                    }
+
+                                    if let oldValue = item.oldValue {
+                                        VStack(alignment: .leading, spacing: 2) {
+                                            Text("Old")
+                                                .font(.system(.caption2, design: .monospaced))
+                                                .foregroundStyle(.secondary)
+                                            Text(oldValue)
+                                                .font(.system(.caption2, design: .monospaced))
+                                                .foregroundStyle(.secondary)
+                                                .textSelection(.enabled)
+                                        }
+                                    }
+
+                                    if let newValue = item.newValue {
+                                        VStack(alignment: .leading, spacing: 2) {
+                                            Text("New")
+                                                .font(.system(.caption2, design: .monospaced))
+                                                .foregroundStyle(.secondary)
+                                            Text(newValue)
+                                                .font(.system(.caption, design: .monospaced))
+                                                .foregroundStyle(item.hasChanges ? .primary : .secondary)
+                                                .textSelection(.enabled)
+                                        }
+                                    }
                                 }
+                                .padding(10)
+                                .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25))
+                                .cornerRadius(8)
+                            }
+                        } label: {
+                            HStack {
+                                Text(section.title)
+                                    .font(.system(.subheadline, design: .monospaced))
+                                    .fontWeight(.semibold)
+                                    .foregroundStyle(.cyan)
+                                Spacer()
+                                Text(section.hasChanges ? "Changed" : "Unchanged")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(section.hasChanges ? .yellow : .secondary)
                             }
                         }
                     }
@@ -469,6 +589,19 @@ struct DomainDiffView: View {
             return .secondary
         }
     }
+
+    private func binding(for section: DomainDiffSection) -> Binding<Bool> {
+        Binding(
+            get: { !collapsedSections.contains(section.id) },
+            set: { isExpanded in
+                if isExpanded {
+                    collapsedSections.remove(section.id)
+                } else {
+                    collapsedSections.insert(section.id)
+                }
+            }
+        )
+    }
 }
 
 struct TrackedDomainDetailHeaderView: View {
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index fa3d8b5..65ea5a7 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -13,12 +13,20 @@ struct DomainDiffItem: Identifiable, Equatable {
     let changeType: DiffChangeType
     let oldValue: String?
     let newValue: String?
+
+    var hasChanges: Bool {
+        changeType != .unchanged
+    }
 }
 
 struct DomainDiffSection: Identifiable, Equatable {
     let id = UUID()
     let title: String
     let items: [DomainDiffItem]
+
+    var hasChanges: Bool {
+        items.contains(where: \.hasChanges)
+    }
 }
 
 enum DomainDiffService {
@@ -105,13 +113,15 @@ enum DomainDiffService {
     private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
         let oldValue = normalized(oldValue)
         let newValue = normalized(newValue)
+        let normalizedOldValue = comparisonValue(oldValue)
+        let normalizedNewValue = comparisonValue(newValue)
 
         guard oldValue != nil || newValue != nil else {
             return nil
         }
 
         let changeType: DiffChangeType
-        switch (oldValue, newValue) {
+        switch (normalizedOldValue, normalizedNewValue) {
         case let (old?, new?) where old == new:
             changeType = .unchanged
         case (nil, _?):
@@ -129,7 +139,11 @@ enum DomainDiffService {
         guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
             return nil
         }
-        return value.lowercased()
+        return value
+    }
+
+    private static func comparisonValue(_ value: String?) -> String? {
+        value?.lowercased()
     }
 
     private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 4a20338..82ec540 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -163,6 +163,7 @@ extension HistoryEntry {
 @Observable
 final class DomainViewModel {
     var domain: String = ""
+    var bulkInput: String = ""
 
     var dnsSections: [DNSSection] = []
     var dnsLoading = false
@@ -221,6 +222,12 @@ final class DomainViewModel {
     private(set) var currentChangeSummary: DomainChangeSummary?
     private(set) var refreshingTrackedDomainID: UUID?
     private(set) var rerunNavigationToken = UUID()
+    private(set) var batchResults: [BatchLookupResult] = []
+    private(set) var batchLookupSource: BatchLookupSource = .manual
+    private(set) var batchCurrentDomain: String?
+    private(set) var batchCompletedCount = 0
+    private(set) var batchTotalCount = 0
+    private(set) var batchLookupRunning = false
 
     private var lookupTask: Task<Void, Never>?
     private var customPortScanTask: Task<Void, Never>?
@@ -239,7 +246,7 @@ final class DomainViewModel {
     var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
 
     private static let historyKey = "lookupHistory"
-    private static let maxHistory = 50
+    private static let maxHistory = 250
     var history: [HistoryEntry] = {
         guard let data = UserDefaults.standard.data(forKey: historyKey),
               let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
@@ -247,6 +254,13 @@ final class DomainViewModel {
         }
         return entries
     }()
+    var historySearchText = ""
+    var historyDateFilter: HistoryDateFilter = .all
+    var historyChangeFilter: ChangeFilterOption = .all
+    var historySortOption: HistorySortOption = .newest
+    var watchlistSearchText = ""
+    var watchlistFilter: WatchlistFilterOption = .all
+    var watchlistSortOption: WatchlistSortOption = .pinned
 
     var trimmedDomain: String {
         domain
@@ -282,14 +296,73 @@ final class DomainViewModel {
     }
 
     var sortedTrackedDomains: [TrackedDomain] {
-        trackedDomains.sorted {
-            if $0.isPinned != $1.isPinned {
-                return $0.isPinned && !$1.isPinned
+        sortedTrackedDomains(from: trackedDomains, using: .pinned)
+    }
+
+    var filteredHistory: [HistoryEntry] {
+        let calendar = Calendar.current
+        let now = Date()
+        let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        return history
+            .lazy
+            .filter { entry in
+                query.isEmpty || entry.domain.localizedCaseInsensitiveContains(query)
+            }
+            .filter { entry in
+                switch self.historyDateFilter {
+                case .today:
+                    return calendar.isDate(entry.timestamp, inSameDayAs: now)
+                case .last7Days:
+                    guard let startDate = calendar.date(byAdding: .day, value: -7, to: now) else { return true }
+                    return entry.timestamp >= startDate
+                case .all:
+                    return true
+                }
+            }
+            .filter { entry in
+                switch self.historyChangeFilter {
+                case .all:
+                    return true
+                case .changed:
+                    return entry.changeSummary?.hasChanges == true
+                case .unchanged:
+                    return entry.changeSummary?.hasChanges != true
+                }
+            }
+            .sorted(by: historySortPredicate)
+    }
+
+    var filteredTrackedDomains: [TrackedDomain] {
+        let query = watchlistSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        let filtered = trackedDomains.filter { trackedDomain in
+            if !query.isEmpty, !trackedDomain.domain.localizedCaseInsensitiveContains(query) {
+                return false
             }
-            if $0.updatedAt != $1.updatedAt {
-                return $0.updatedAt > $1.updatedAt
+
+            switch watchlistFilter {
+            case .all:
+                return true
+            case .pinnedOnly:
+                return trackedDomain.isPinned
+            case .changedOnly:
+                return trackedDomain.lastChangeSummary?.hasChanges == true
             }
-            return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+        }
+
+        return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
+    }
+
+    var batchProgressLabel: String {
+        guard batchTotalCount > 0 else { return "No active batch" }
+        let domainLabel = batchCurrentDomain ?? "Preparing"
+        return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)"
+    }
+
+    var currentBatchResultEntries: [HistoryEntry] {
+        batchResults.compactMap { result in
+            guard let historyEntryID = result.historyEntryID else { return nil }
+            return history.first(where: { $0.id == historyEntryID })
         }
     }
 
@@ -303,13 +376,11 @@ final class DomainViewModel {
     }
 
     var trackingLimitMessage: String? {
-        guard currentTrackedDomain == nil else { return nil }
-        guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
-        return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
+        nil
     }
 
     var canTrackCurrentDomain: Bool {
-        currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
+        true
     }
 
     var resolverDisplayName: String {
@@ -528,6 +599,11 @@ final class DomainViewModel {
         persistHistory()
     }
 
+    func removeHistoryEntries(withIDs ids: [UUID]) {
+        history.removeAll { ids.contains($0.id) }
+        persistHistory()
+    }
+
     func clearHistory() {
         history.removeAll()
         persistHistory()
@@ -554,32 +630,77 @@ final class DomainViewModel {
         currentDiffSections = []
         currentChangeSummary = nil
         refreshingTrackedDomainID = nil
+        clearBatchState()
         clearLookupState()
     }
 
     func run() {
         let target = trimmedDomain
         guard !target.isEmpty else { return }
+        clearBatchState()
+        let lookupID = beginLookup(for: target)
+
+        lookupTask = Task { [weak self] in
+            guard let self else { return }
+            _ = await self.performLookup(domain: target, lookupID: lookupID)
+        }
+    }
+
+    func runBulkLookup() {
+        let domains = parsedDomains(from: bulkInput)
+        guard !domains.isEmpty else { return }
+
+        clearBatchState()
+        batchLookupSource = .manual
+        batchTotalCount = domains.count
+        batchLookupRunning = true
+        batchResults = domains.map {
+            BatchLookupResult(
+                domain: $0,
+                historyEntryID: nil,
+                availability: nil,
+                primaryIP: nil,
+                quickStatus: "Pending",
+                timestamp: Date(),
+                status: .pending
+            )
+        }
 
         lookupTask?.cancel()
         customPortScanTask?.cancel()
 
-        let lookupID = UUID()
-        activeLookupID = lookupID
-        lookupStartedAt = Date()
-        lastLookupDurationMs = nil
-        addRecentSearch(target)
-        searchedDomain = target
-        hasRun = true
-        currentDiffSections = []
-        currentChangeSummary = nil
-        clearLookupState()
-        setAllLoadingStates(true)
-        customPortScanLoading = false
+        lookupTask = Task { [weak self] in
+            guard let self else { return }
+            await self.runBatchLookup(domains: domains, source: .manual)
+        }
+    }
+
+    func refreshAllTrackedDomains() {
+        let domains = sortedTrackedDomains.map(\.domain)
+        guard !domains.isEmpty else { return }
+
+        clearBatchState()
+        batchLookupSource = .watchlistRefresh
+        batchTotalCount = domains.count
+        batchLookupRunning = true
+        batchResults = domains.map {
+            BatchLookupResult(
+                domain: $0,
+                historyEntryID: nil,
+                availability: nil,
+                primaryIP: nil,
+                quickStatus: "Pending",
+                timestamp: Date(),
+                status: .pending
+            )
+        }
+
+        lookupTask?.cancel()
+        customPortScanTask?.cancel()
 
         lookupTask = Task { [weak self] in
             guard let self else { return }
-            await self.performLookup(domain: target, lookupID: lookupID)
+            await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
         }
     }
 
@@ -620,19 +741,75 @@ final class DomainViewModel {
         )
     }
 
-    private func performLookup(domain: String, lookupID: UUID) async {
+    func exportCSV() -> String {
+        Self.formatCSV(from: [currentSnapshot])
+    }
+
+    func exportBatchText() -> String {
+        Self.formatBatchExportText(
+            title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export",
+            entries: currentBatchResultEntries.map { entry in
+                (
+                    snapshot: entry.snapshot,
+                    trackedDomain: trackedDomains.first(where: { tracked in
+                        tracked.id == entry.trackedDomainID ||
+                        tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame
+                    }),
+                    changeSummary: entry.changeSummary,
+                    diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
+                )
+            }
+        )
+    }
+
+    func exportBatchCSV() -> String {
+        Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot))
+    }
+
+    func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
+        Self.formatCSV(from: exportSnapshots(for: domains))
+    }
+
+    func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
+        let latestEntries = latestSnapshots(for: domains)
+        return Self.formatBatchExportText(
+            title: "Tracked Domains Export",
+            entries: domains.map { trackedDomain in
+                if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
+                    return (
+                        snapshot: entry.snapshot,
+                        trackedDomain: trackedDomain,
+                        changeSummary: entry.changeSummary,
+                        diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
+                    )
+                }
+
+                return (
+                    snapshot: placeholderSnapshot(for: trackedDomain),
+                    trackedDomain: trackedDomain,
+                    changeSummary: trackedDomain.lastChangeSummary,
+                    diffSections: []
+                )
+            }
+        )
+    }
+
+    private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
         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) }
+        }
+
+        await withTaskGroup(of: Void.self) { group in
             group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
             group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
             group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
             group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
         }
 
-        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
 
         let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
         let primaryIP = primaryIPAddress(from: dnsSections)
@@ -647,7 +824,7 @@ final class DomainViewModel {
             }
         }
 
-        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
 
         if availabilityResult?.status == .registered {
             await runSuggestions(domain: domain, lookupID: lookupID)
@@ -656,10 +833,11 @@ final class DomainViewModel {
             suggestionsLoading = false
         }
 
-        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+        guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
         lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
-        saveHistoryEntry(replaceLatest: false)
+        let entry = saveHistoryEntry(replaceLatest: false)
         refreshingTrackedDomainID = nil
+        return entry
     }
 
     private func runDNS(domain: String, lookupID: UUID) async {
@@ -868,7 +1046,7 @@ final class DomainViewModel {
         case let .success(results):
             customPortResults = results
             customPortScanError = nil
-            saveHistoryEntry(replaceLatest: true)
+            _ = saveHistoryEntry(replaceLatest: true)
         case let .empty(message):
             customPortResults = []
             customPortScanError = message
@@ -904,8 +1082,9 @@ final class DomainViewModel {
         }
     }
 
-    private func saveHistoryEntry(replaceLatest: Bool) {
-        guard !searchedDomain.isEmpty else { return }
+    @discardableResult
+    private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
+        guard !searchedDomain.isEmpty else { return nil }
 
         let trackedDomainID = trackedDomain(for: searchedDomain)?.id
         let timestamp = Date()
@@ -969,6 +1148,7 @@ final class DomainViewModel {
             changeSummary: changeSummary
         )
         persistHistory()
+        return entry
     }
 
     private func persistHistory() {
@@ -1027,7 +1207,13 @@ final class DomainViewModel {
     }
 
     private func normalizedDomain(_ domain: String) -> String {
-        domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        domain
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "https://", with: "")
+            .replacingOccurrences(of: "http://", with: "")
+            .components(separatedBy: "/")
+            .first?
+            .lowercased() ?? ""
     }
 
     private func linkTrackedDomainHistory(for domain: String) {
@@ -1064,6 +1250,110 @@ final class DomainViewModel {
         UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
     }
 
+    private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
+        if cancelExistingTask {
+            lookupTask?.cancel()
+        }
+        customPortScanTask?.cancel()
+
+        let lookupID = UUID()
+        activeLookupID = lookupID
+        lookupStartedAt = Date()
+        lastLookupDurationMs = nil
+        addRecentSearch(target)
+        searchedDomain = target
+        hasRun = true
+        currentDiffSections = []
+        currentChangeSummary = nil
+        clearLookupState()
+        setAllLoadingStates(true)
+        customPortScanLoading = false
+        return lookupID
+    }
+
+    private func clearBatchState() {
+        batchResults = []
+        batchLookupSource = .manual
+        batchCurrentDomain = nil
+        batchCompletedCount = 0
+        batchTotalCount = 0
+        batchLookupRunning = false
+    }
+
+    private func parsedDomains(from input: String) -> [String] {
+        let separators = CharacterSet(charactersIn: ",\n")
+        var seen = Set<String>()
+
+        return input
+            .components(separatedBy: separators)
+            .map(normalizedDomain)
+            .filter { !$0.isEmpty }
+            .filter { seen.insert($0).inserted }
+    }
+
+    private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
+        for (index, domain) in domains.enumerated() {
+            guard !Task.isCancelled else { break }
+
+            batchCurrentDomain = domain
+            if source == .watchlistRefresh {
+                refreshingTrackedDomainID = trackedDomain(for: domain)?.id
+            }
+            updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
+
+            let lookupID = beginLookup(for: domain, cancelExistingTask: false)
+            let entry = await performLookup(domain: domain, lookupID: lookupID)
+
+            if let entry {
+                updateBatchResult(
+                    domain: domain,
+                    status: .completed,
+                    quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged",
+                    entry: entry,
+                    errorMessage: nil
+                )
+            } else {
+                updateBatchResult(
+                    domain: domain,
+                    status: .failed,
+                    quickStatus: "Failed",
+                    entry: nil,
+                    errorMessage: "Lookup cancelled"
+                )
+            }
+
+            batchCompletedCount = index + 1
+        }
+
+        batchLookupRunning = false
+        batchCurrentDomain = nil
+        refreshingTrackedDomainID = nil
+    }
+
+    private func updateBatchResult(
+        domain: String,
+        status: BatchLookupStatus,
+        quickStatus: String,
+        entry: HistoryEntry?,
+        errorMessage: String?
+    ) {
+        guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
+            return
+        }
+
+        batchResults[index] = BatchLookupResult(
+            id: batchResults[index].id,
+            domain: domain,
+            historyEntryID: entry?.id,
+            availability: entry?.availabilityResult?.status,
+            primaryIP: entry?.primaryIP,
+            quickStatus: quickStatus,
+            timestamp: entry?.timestamp ?? Date(),
+            status: status,
+            errorMessage: errorMessage
+        )
+    }
+
     private func clearLookupState() {
         dnsSections = []
         dnsError = nil
@@ -1139,6 +1429,12 @@ final class DomainViewModel {
             .map { $0 }
     }
 
+    func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
+        domains.compactMap { trackedDomain in
+            recentSnapshots(for: trackedDomain, limit: 1).first
+        }
+    }
+
     func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
         let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
         guard snapshots.count == 2 else { return [] }
@@ -1161,6 +1457,62 @@ final class DomainViewModel {
         return siblings.first?.snapshot
     }
 
+    func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
+        guard let historyEntryID = batchResult.historyEntryID else { return nil }
+        return history.first(where: { $0.id == historyEntryID })
+    }
+
+    private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
+        let latestEntries = latestSnapshots(for: domains)
+
+        return domains.map { trackedDomain in
+            if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
+                return entry.snapshot
+            }
+            return placeholderSnapshot(for: trackedDomain)
+        }
+    }
+
+    private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
+        LookupSnapshot(
+            historyEntryID: trackedDomain.lastSnapshotID,
+            domain: trackedDomain.domain,
+            timestamp: trackedDomain.updatedAt,
+            trackedDomainID: trackedDomain.id,
+            resolverDisplayName: resolverDisplayName,
+            resolverURLString: resolverURLString,
+            totalLookupDurationMs: nil,
+            dnsSections: [],
+            dnsError: nil,
+            availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
+            suggestions: [],
+            sslInfo: nil,
+            sslError: nil,
+            hstsPreloaded: nil,
+            httpHeaders: [],
+            httpSecurityGrade: nil,
+            httpStatusCode: nil,
+            httpResponseTimeMs: nil,
+            httpProtocol: nil,
+            http3Advertised: false,
+            httpHeadersError: nil,
+            reachabilityResults: [],
+            reachabilityError: nil,
+            ipGeolocation: nil,
+            ipGeolocationError: nil,
+            emailSecurity: nil,
+            emailSecurityError: nil,
+            ptrRecord: nil,
+            ptrError: nil,
+            redirectChain: [],
+            redirectChainError: nil,
+            portScanResults: [],
+            portScanError: nil,
+            changeSummary: trackedDomain.lastChangeSummary,
+            isLive: false
+        )
+    }
+
     private static func loadTrackedDomains() -> [TrackedDomain] {
         let defaults = UserDefaults.standard
         let decoder = JSONDecoder()
@@ -1196,6 +1548,47 @@ final class DomainViewModel {
         }
     }
 
+    private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
+        switch historySortOption {
+        case .newest:
+            return lhs.timestamp > rhs.timestamp
+        case .oldest:
+            return lhs.timestamp < rhs.timestamp
+        case .domain:
+            let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
+            if domainOrder != .orderedSame {
+                return domainOrder == .orderedAscending
+            }
+            return lhs.timestamp > rhs.timestamp
+        }
+    }
+
+    private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
+        domains.sorted { lhs, rhs in
+            switch sortOption {
+            case .pinned:
+                if lhs.isPinned != rhs.isPinned {
+                    return lhs.isPinned && !rhs.isPinned
+                }
+                if lhs.updatedAt != rhs.updatedAt {
+                    return lhs.updatedAt > rhs.updatedAt
+                }
+                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
+            case .recentlyUpdated:
+                if lhs.updatedAt != rhs.updatedAt {
+                    return lhs.updatedAt > rhs.updatedAt
+                }
+                return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
+            case .alphabetical:
+                let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
+                if domainOrder != .orderedSame {
+                    return domainOrder == .orderedAscending
+                }
+                return lhs.updatedAt > rhs.updatedAt
+            }
+        }
+    }
+
     static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
         [
             SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1369,6 +1762,64 @@ final class DomainViewModel {
             }
     }
 
+    static func formatBatchExportText(
+        title: String,
+        entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
+    ) -> String {
+        guard !entries.isEmpty else {
+            return "\(title)\nNo results available."
+        }
+
+        var lines = [title, String(repeating: "=", count: title.count), ""]
+        for (index, entry) in entries.enumerated() {
+            if index > 0 {
+                lines.append("")
+                lines.append(String(repeating: "=", count: 48))
+                lines.append("")
+            }
+
+            lines.append(
+                formatExportText(
+                    from: entry.snapshot,
+                    trackedDomain: entry.trackedDomain,
+                    changeSummary: entry.changeSummary,
+                    diffSections: entry.diffSections
+                )
+            )
+        }
+        return lines.joined(separator: "\n")
+    }
+
+    static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
+        let headers = [
+            "domain",
+            "availability",
+            "primary_ip",
+            "redirect_target",
+            "tls_status",
+            "http_status_grade",
+            "email_security_summary",
+            "last_updated"
+        ]
+
+        let rows = snapshots.map { snapshot in
+            [
+                snapshot.domain,
+                availabilityLabel(snapshot.availabilityResult?.status),
+                primaryIPAddress(from: snapshot) ?? "",
+                finalRedirectTarget(from: snapshot) ?? "",
+                httpsSummary(from: snapshot),
+                httpStatusGradeSummary(from: snapshot),
+                emailSummary(from: snapshot),
+                csvDateFormatter.string(from: snapshot.timestamp)
+            ]
+        }
+
+        return ([headers] + rows)
+            .map { row in row.map(csvEscaped).joined(separator: ",") }
+            .joined(separator: "\n")
+    }
+
     static func formatExportText(
         from snapshot: LookupSnapshot,
         trackedDomain: TrackedDomain?,
@@ -1404,8 +1855,9 @@ final class DomainViewModel {
                 lines.append("  \(item.label): \(item.value)")
             }
             if let changeSummary {
-                lines.append("  Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
+                lines.append("  Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
                 lines.append("  Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
+                lines.append("  Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
             }
         }
 
@@ -1429,7 +1881,7 @@ final class DomainViewModel {
                 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
                     lines.append("  \(section.title)")
                     for item in section.items where item.changeType != .unchanged {
-                        lines.append("    \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
+                        lines.append("    [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
                     }
                 }
             }
@@ -1596,6 +2048,14 @@ final class DomainViewModel {
         snapshot.redirectChain.last?.url
     }
 
+    private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
+        let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
+        if !parts.isEmpty {
+            return parts.joined(separator: " / ")
+        }
+        return snapshot.httpHeadersError ?? "Unavailable"
+    }
+
     private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
         if snapshot.sslInfo != nil {
             return "Valid"
@@ -1665,6 +2125,17 @@ final class DomainViewModel {
         formatter.timeStyle = .short
         return formatter
     }()
+
+    private static let csvDateFormatter: ISO8601DateFormatter = {
+        let formatter = ISO8601DateFormatter()
+        formatter.formatOptions = [.withInternetDateTime]
+        return formatter
+    }()
+
+    private static func csvEscaped(_ value: String) -> String {
+        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
+        return "\"\(escaped)\""
+    }
 }
 
 private extension String {
diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift
new file mode 100644
index 0000000..82ec7ae
--- /dev/null
+++ b/DomainDig/ExportPresenter.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+import UIKit
+
+enum ExportPresenter {
+    static func share(filename: String, contents: String) {
+        let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
+
+        do {
+            try contents.write(to: url, atomically: true, encoding: .utf8)
+        } catch {
+            return
+        }
+
+        let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
+        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+              let rootViewController = windowScene.keyWindow?.rootViewController else {
+            return
+        }
+
+        var presenter = rootViewController
+        while let presentedViewController = presenter.presentedViewController {
+            presenter = presentedViewController
+        }
+
+        activityController.popoverPresentationController?.sourceView = presenter.view
+        presenter.present(activityController, animated: true)
+    }
+}
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index bb9d255..35ec723 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -14,13 +14,13 @@ struct HistoryView: View {
 
     var body: some View {
         List {
-            if viewModel.history.isEmpty {
+            if viewModel.filteredHistory.isEmpty {
                 Text("No lookup history")
                     .font(.system(.callout, design: .monospaced))
                     .foregroundStyle(.secondary)
                     .listRowBackground(Color(.systemGray6).opacity(0.5))
             } else {
-                ForEach(viewModel.history) { entry in
+                ForEach(viewModel.filteredHistory) { entry in
                     NavigationLink {
                         HistoryDetailView(viewModel: viewModel, entry: entry)
                     } label: {
@@ -28,6 +28,11 @@ struct HistoryView: View {
                             Text(entry.domain)
                                 .font(.system(.callout, design: .monospaced))
                                 .foregroundStyle(.primary)
+                            if let summary = entry.changeSummary {
+                                Text(summary.hasChanges ? "Changed" : "Unchanged")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(summary.hasChanges ? .yellow : .green)
+                            }
                             HStack(spacing: 8) {
                                 Text(dateFormatter.string(from: entry.timestamp))
                                 Text("Snapshot")
@@ -42,23 +47,40 @@ struct HistoryView: View {
                     }
                     .listRowBackground(Color(.systemGray6).opacity(0.5))
                 }
-                .onDelete { offsets in
-                    viewModel.removeHistoryEntries(at: offsets)
-                }
+                .onDelete(perform: deleteFilteredHistoryEntries)
             }
         }
         .scrollContentBackground(.hidden)
         .background(Color.black)
         .navigationTitle("History")
+        .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
         .toolbar {
             if !viewModel.history.isEmpty {
                 ToolbarItemGroup(placement: .topBarTrailing) {
                     Menu {
+                        Picker("Date Range", selection: $viewModel.historyDateFilter) {
+                            ForEach(HistoryDateFilter.allCases) { option in
+                                Text(option.title).tag(option)
+                            }
+                        }
+
+                        Picker("Change Filter", selection: $viewModel.historyChangeFilter) {
+                            ForEach(ChangeFilterOption.allCases) { option in
+                                Text(option.title).tag(option)
+                            }
+                        }
+
+                        Picker("Sort", selection: $viewModel.historySortOption) {
+                            ForEach(HistorySortOption.allCases) { option in
+                                Text(option.title).tag(option)
+                            }
+                        }
+
                         Button("Clear All", role: .destructive) {
                             showClearAllConfirmation = true
                         }
                     } label: {
-                        Image(systemName: "ellipsis.circle")
+                        Image(systemName: "line.3.horizontal.decrease.circle")
                     }
 
                     EditButton()
@@ -78,6 +100,11 @@ struct HistoryView: View {
         }
         .preferredColorScheme(.dark)
     }
+
+    private func deleteFilteredHistoryEntries(at offsets: IndexSet) {
+        let ids = offsets.map { viewModel.filteredHistory[$0].id }
+        viewModel.removeHistoryEntries(withIDs: ids)
+    }
 }
 
 struct HistoryDetailView: View {
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index e571da9..d11e879 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -54,6 +54,147 @@ struct DomainChangeSummary: Codable, Equatable {
     let generatedAt: Date
 }
 
+enum BatchLookupSource: String, Codable {
+    case manual
+    case watchlistRefresh
+}
+
+enum BatchLookupStatus: String, Codable {
+    case pending
+    case running
+    case completed
+    case failed
+}
+
+struct BatchLookupResult: Identifiable, Codable, Equatable {
+    let id: UUID
+    let domain: String
+    let historyEntryID: UUID?
+    let availability: DomainAvailabilityStatus?
+    let primaryIP: String?
+    let quickStatus: String
+    let timestamp: Date
+    let status: BatchLookupStatus
+    let errorMessage: String?
+
+    init(
+        id: UUID = UUID(),
+        domain: String,
+        historyEntryID: UUID?,
+        availability: DomainAvailabilityStatus?,
+        primaryIP: String?,
+        quickStatus: String,
+        timestamp: Date,
+        status: BatchLookupStatus,
+        errorMessage: String? = nil
+    ) {
+        self.id = id
+        self.domain = domain
+        self.historyEntryID = historyEntryID
+        self.availability = availability
+        self.primaryIP = primaryIP
+        self.quickStatus = quickStatus
+        self.timestamp = timestamp
+        self.status = status
+        self.errorMessage = errorMessage
+    }
+}
+
+enum HistoryDateFilter: String, CaseIterable, Identifiable {
+    case today
+    case last7Days
+    case all
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .today:
+            return "Today"
+        case .last7Days:
+            return "Last 7 Days"
+        case .all:
+            return "All"
+        }
+    }
+}
+
+enum ChangeFilterOption: String, CaseIterable, Identifiable {
+    case all
+    case changed
+    case unchanged
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .all:
+            return "All"
+        case .changed:
+            return "Changed"
+        case .unchanged:
+            return "Unchanged"
+        }
+    }
+}
+
+enum HistorySortOption: String, CaseIterable, Identifiable {
+    case newest
+    case oldest
+    case domain
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .newest:
+            return "Newest"
+        case .oldest:
+            return "Oldest"
+        case .domain:
+            return "Domain A-Z"
+        }
+    }
+}
+
+enum WatchlistFilterOption: String, CaseIterable, Identifiable {
+    case all
+    case pinnedOnly
+    case changedOnly
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .all:
+            return "All"
+        case .pinnedOnly:
+            return "Pinned Only"
+        case .changedOnly:
+            return "Changed Only"
+        }
+    }
+}
+
+enum WatchlistSortOption: String, CaseIterable, Identifiable {
+    case pinned
+    case recentlyUpdated
+    case alphabetical
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .pinned:
+            return "Pinned"
+        case .recentlyUpdated:
+            return "Recently Updated"
+        case .alphabetical:
+            return "Alphabetical"
+        }
+    }
+}
+
 enum PremiumCapability: String, Codable {
     case unlimitedTrackedDomains
     case automatedMonitoring
diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift
index 698a2d2..0987dae 100644
--- a/DomainDig/PremiumAccessService.swift
+++ b/DomainDig/PremiumAccessService.swift
@@ -1,27 +1,15 @@
 import Foundation
 
 enum PremiumAccessService {
-    static let freeTrackedDomainLimit = 3
-
     static func hasAccess(to capability: PremiumCapability) -> Bool {
-        switch capability {
-        case .unlimitedTrackedDomains,
-             .automatedMonitoring,
-             .pushAlerts,
-             .batchTracking,
-             .advancedExports:
-            return false
-        }
+        true
     }
 
     static func trackedDomainLimitMessage(currentCount: Int) -> String? {
-        guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else {
-            return nil
-        }
-        return "More tracked domains will be available in a future Pro upgrade."
+        nil
     }
 
     static func canAddTrackedDomain(currentCount: Int) -> Bool {
-        hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit
+        true
     }
 }
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index aea8707..b01b8f9 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -6,7 +6,27 @@ struct WatchlistView: View {
 
     var body: some View {
         List {
-            if viewModel.sortedTrackedDomains.isEmpty {
+            if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
+                Section("Refresh Progress") {
+                    VStack(alignment: .leading, spacing: 8) {
+                        if viewModel.batchLookupRunning {
+                            ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
+                                .tint(.cyan)
+                            Text(viewModel.batchProgressLabel)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.secondary)
+                        }
+
+                        ForEach(viewModel.batchResults.prefix(5)) { result in
+                            BatchResultRowView(result: result)
+                        }
+                    }
+                    .padding(.vertical, 4)
+                }
+                .listRowBackground(Color(.systemGray6).opacity(0.5))
+            }
+
+            if viewModel.filteredTrackedDomains.isEmpty {
                 Section {
                     VStack(alignment: .leading, spacing: 8) {
                         Text("No tracked domains yet")
@@ -30,7 +50,7 @@ struct WatchlistView: View {
                 }
 
                 Section {
-                    ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
+                    ForEach(viewModel.filteredTrackedDomains) { trackedDomain in
                         NavigationLink {
                             TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
                         } label: {
@@ -83,7 +103,7 @@ struct WatchlistView: View {
                         }
                         .listRowBackground(Color(.systemGray6).opacity(0.5))
                     }
-                    .onDelete(perform: viewModel.deleteTrackedDomains)
+                    .onDelete(perform: deleteFilteredTrackedDomains)
                 } header: {
                     Text("Tracked Domains")
                 }
@@ -92,9 +112,40 @@ struct WatchlistView: View {
         .scrollContentBackground(.hidden)
         .background(Color.black)
         .navigationTitle("Watchlist")
+        .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
         .toolbar {
-            if !viewModel.sortedTrackedDomains.isEmpty {
-                EditButton()
+            if !viewModel.filteredTrackedDomains.isEmpty {
+                ToolbarItemGroup(placement: .topBarTrailing) {
+                    Menu {
+                        Picker("Filter", selection: $viewModel.watchlistFilter) {
+                            ForEach(WatchlistFilterOption.allCases) { option in
+                                Text(option.title).tag(option)
+                            }
+                        }
+
+                        Picker("Sort", selection: $viewModel.watchlistSortOption) {
+                            ForEach(WatchlistSortOption.allCases) { option in
+                                Text(option.title).tag(option)
+                            }
+                        }
+
+                        Button("Refresh All") {
+                            viewModel.refreshAllTrackedDomains()
+                        }
+
+                        Button("Export TXT") {
+                            shareTrackedDomains(asCSV: false)
+                        }
+
+                        Button("Export CSV") {
+                            shareTrackedDomains(asCSV: true)
+                        }
+                    } label: {
+                        Image(systemName: "line.3.horizontal.decrease.circle")
+                    }
+
+                    EditButton()
+                }
             }
         }
         .onChange(of: viewModel.rerunNavigationToken) { _, _ in
@@ -102,6 +153,23 @@ struct WatchlistView: View {
         }
         .preferredColorScheme(.dark)
     }
+
+    private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
+        let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
+        domains.forEach(viewModel.deleteTrackedDomain)
+    }
+
+    private func shareTrackedDomains(asCSV: Bool) {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
+        let timestamp = formatter.string(from: Date())
+        let fileExtension = asCSV ? "csv" : "txt"
+        let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)"
+        let contents = asCSV
+            ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains)
+            : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains)
+        ExportPresenter.share(filename: filename, contents: contents)
+    }
 }
 
 struct WatchlistRowView: View {