summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/BatchResultsView.swift114
-rw-r--r--DomainDig/ContentView.swift293
-rw-r--r--DomainDig/DomainDiffService.swift18
-rw-r--r--DomainDig/DomainViewModel.swift541
-rw-r--r--DomainDig/ExportPresenter.swift28
-rw-r--r--DomainDig/HistoryView.swift39
-rw-r--r--DomainDig/Models.swift141
-rw-r--r--DomainDig/PremiumAccessService.swift18
-rw-r--r--DomainDig/WatchlistView.swift78
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 {