Commit 2e90ee4cec

2e90ee4ceca73d5126118920b486149cb227c4e9

parent: 9999f876fe

Verified · cmc

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

feat(v2.1.0): add bulk workflows, CSV export, and filtering

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -267,7 +267,7 @@
267267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 13;
270 CURRENT_PROJECT_VERSION = 14;
271271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272272 ENABLE_PREVIEWS = YES;
273273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284284 "$(inherited)",
285285 "@executable_path/Frameworks",
286286 );
287 MARKETING_VERSION = 2.0.0;
287 MARKETING_VERSION = 2.1.0;
288288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289289 PRODUCT_NAME = "$(TARGET_NAME)";
290290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 13;
306 CURRENT_PROJECT_VERSION = 14;
307307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308308 ENABLE_PREVIEWS = YES;
309309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320320 "$(inherited)",
321321 "@executable_path/Frameworks",
322322 );
323 MARKETING_VERSION = 2.0.0;
323 MARKETING_VERSION = 2.1.0;
324324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325325 PRODUCT_NAME = "$(TARGET_NAME)";
326326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift added +114
@@ -0,0 +1,114 @@
1import SwiftUI
2
3struct BatchResultsView: View {
4 @Bindable var viewModel: DomainViewModel
5 let title: String
6
7 var body: some View {
8 VStack(alignment: .leading, spacing: 12) {
9 HStack(alignment: .top) {
10 SectionTitleView(title: title)
11 Spacer()
12 if viewModel.batchLookupRunning {
13 VStack(alignment: .trailing, spacing: 4) {
14 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
15 .tint(.cyan)
16 .frame(width: 120)
17 Text(viewModel.batchProgressLabel)
18 .font(.system(.caption2, design: .monospaced))
19 .foregroundStyle(.secondary)
20 }
21 } else if !viewModel.batchResults.isEmpty {
22 Text("\(viewModel.batchResults.count) domains")
23 .font(.system(.caption2, design: .monospaced))
24 .foregroundStyle(.secondary)
25 }
26 }
27
28 if viewModel.batchResults.isEmpty {
29 MessageCardView(text: "No batch results yet", isError: false)
30 } else {
31 CardView(allowsHorizontalScroll: false) {
32 ForEach(viewModel.batchResults) { result in
33 if let entry = viewModel.historyEntry(for: result) {
34 NavigationLink {
35 HistoryDetailView(viewModel: viewModel, entry: entry)
36 } label: {
37 BatchResultRowView(result: result)
38 }
39 .buttonStyle(.plain)
40 } else {
41 BatchResultRowView(result: result)
42 }
43 }
44 }
45 }
46 }
47 }
48}
49
50struct BatchResultRowView: View {
51 let result: BatchLookupResult
52
53 var body: some View {
54 VStack(alignment: .leading, spacing: 6) {
55 HStack(alignment: .firstTextBaseline, spacing: 8) {
56 Text(result.domain)
57 .font(.system(.callout, design: .monospaced))
58 .foregroundStyle(.primary)
59 .lineLimit(1)
60 Spacer(minLength: 8)
61 Text(result.quickStatus)
62 .font(.system(.caption2, design: .monospaced))
63 .foregroundStyle(quickStatusColor)
64 .padding(.horizontal, 8)
65 .padding(.vertical, 4)
66 .background(quickStatusColor.opacity(0.16))
67 .clipShape(Capsule())
68 }
69
70 HStack(spacing: 10) {
71 Text(availabilityText)
72 Text(result.primaryIP ?? "No IP")
73 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
74 }
75 .font(.system(.caption2, design: .monospaced))
76 .foregroundStyle(.secondary)
77
78 if let errorMessage = result.errorMessage {
79 Text(errorMessage)
80 .font(.system(.caption2, design: .monospaced))
81 .foregroundStyle(.red)
82 }
83 }
84 .frame(maxWidth: .infinity, alignment: .leading)
85 .padding(.vertical, 4)
86 }
87
88 private var availabilityText: String {
89 switch result.availability {
90 case .available:
91 return "Available"
92 case .registered:
93 return "Registered"
94 case .unknown, .none:
95 return "Unknown"
96 }
97 }
98
99 private var quickStatusColor: Color {
100 switch result.status {
101 case .pending:
102 return .secondary
103 case .running:
104 return .cyan
105 case .completed:
106 if result.quickStatus == "Changed" {
107 return .yellow
108 }
109 return .green
110 case .failed:
111 return .red
112 }
113 }
114}
DomainDig/ContentView.swift +213 −80
@@ -1,6 +1,13 @@
11import MapKit
22import SwiftUI
33
4enum LookupInputMode: String, CaseIterable, Identifiable {
5 case single
6 case bulk
7
8 var id: String { rawValue }
9}
10
411struct ContentView: View {
512 @State private var viewModel = DomainViewModel()
613 @State private var navigationPath = NavigationPath()
@@ -10,12 +17,17 @@ struct ContentView: View {
1017 @State private var trackingNoteDraft = ""
1118 @State private var editingTrackedDomain: TrackedDomain?
1219 @State private var showTrackLimitAlert = false
20 @State private var inputMode: LookupInputMode = .single
1321
1422 var body: some View {
1523 NavigationStack(path: $navigationPath) {
1624 ScrollView(.vertical) {
1725 VStack(spacing: 0) {
1826 inputSection
27 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
28 batchSection
29 .padding(.top, 8)
30 }
1931 if viewModel.hasRun {
2032 actionButtons
2133 SummaryView(fields: viewModel.summaryFields)
@@ -199,28 +211,68 @@ struct ContentView: View {
199211
200212 private var inputSection: some View {
201213 VStack(spacing: 12) {
202 TextField("e.g. cleberg.net", text: $viewModel.domain)
203 .font(.system(.title3, design: .monospaced))
204 .textInputAutocapitalization(.never)
205 .autocorrectionDisabled()
206 .keyboardType(.URL)
207 .padding(12)
208 .background(Color(.systemGray6))
209 .cornerRadius(8)
210 .focused($domainFieldFocused)
211 .onSubmit { viewModel.run() }
212
213 Button {
214 domainFieldFocused = false
215 viewModel.run()
216 } label: {
217 Text("Run")
218 .font(.headline)
219 .frame(maxWidth: .infinity)
220 .padding(.vertical, 12)
214 Picker("Mode", selection: $inputMode) {
215 Text("Single").tag(LookupInputMode.single)
216 Text("Bulk").tag(LookupInputMode.bulk)
217 }
218 .pickerStyle(.segmented)
219
220 if inputMode == .single {
221 TextField("e.g. cleberg.net", text: $viewModel.domain)
222 .font(.system(.title3, design: .monospaced))
223 .textInputAutocapitalization(.never)
224 .autocorrectionDisabled()
225 .keyboardType(.URL)
226 .padding(12)
227 .background(Color(.systemGray6))
228 .cornerRadius(8)
229 .focused($domainFieldFocused)
230 .onSubmit { viewModel.run() }
231
232 Button {
233 domainFieldFocused = false
234 viewModel.run()
235 } label: {
236 Text("Run")
237 .font(.headline)
238 .frame(maxWidth: .infinity)
239 .padding(.vertical, 12)
240 }
241 .buttonStyle(.borderedProminent)
242 .disabled(viewModel.trimmedDomain.isEmpty)
243 } else {
244 VStack(alignment: .leading, spacing: 8) {
245 Text("Paste domains separated by new lines or commas.")
246 .font(.system(.caption, design: .monospaced))
247 .foregroundStyle(.secondary)
248
249 TextField(
250 "example.com\napple.com, openai.com",
251 text: $viewModel.bulkInput,
252 axis: .vertical
253 )
254 .font(.system(.body, design: .monospaced))
255 .textInputAutocapitalization(.never)
256 .autocorrectionDisabled()
257 .keyboardType(.URL)
258 .lineLimit(4...10)
259 .padding(12)
260 .background(Color(.systemGray6))
261 .cornerRadius(8)
262
263 Button {
264 domainFieldFocused = false
265 viewModel.runBulkLookup()
266 } label: {
267 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
268 .font(.headline)
269 .frame(maxWidth: .infinity)
270 .padding(.vertical, 12)
271 }
272 .buttonStyle(.borderedProminent)
273 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
274 }
221275 }
222 .buttonStyle(.borderedProminent)
223 .disabled(viewModel.trimmedDomain.isEmpty)
224276 }
225277 .padding(.vertical, 16)
226278 }
@@ -236,8 +288,13 @@ struct ContentView: View {
236288 .font(.system(.body))
237289 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
238290 }
239 Button {
240 shareResults()
291 Menu {
292 Button("Export TXT") {
293 shareSingleResults(asCSV: false)
294 }
295 Button("Export CSV") {
296 shareSingleResults(asCSV: true)
297 }
241298 } label: {
242299 Image(systemName: "square.and.arrow.up")
243300 .font(.system(.body))
@@ -247,6 +304,33 @@ struct ContentView: View {
247304 }
248305 }
249306
307 private var batchSection: some View {
308 VStack(alignment: .leading, spacing: 12) {
309 HStack {
310 Spacer()
311 if !viewModel.currentBatchResultEntries.isEmpty {
312 Menu {
313 Button("Export Batch TXT") {
314 shareBatchResults(asCSV: false)
315 }
316 Button("Export Batch CSV") {
317 shareBatchResults(asCSV: true)
318 }
319 } label: {
320 Label("Export", systemImage: "square.and.arrow.up")
321 .font(.system(.caption, design: .monospaced))
322 }
323 .buttonStyle(.bordered)
324 }
325 }
326
327 BatchResultsView(
328 viewModel: viewModel,
329 title: viewModel.batchLookupSource == .watchlistRefresh ? "Tracked Domain Refresh" : "Batch Results"
330 )
331 }
332 }
333
250334 private var recentSearchesSection: some View {
251335 VStack(alignment: .leading, spacing: 8) {
252336 HStack {
@@ -307,29 +391,33 @@ struct ContentView: View {
307391 return ports
308392 }
309393
310 private func shareResults() {
311 let text = viewModel.exportText()
312 let dateFmt = DateFormatter()
313 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
314 let timestamp = dateFmt.string(from: Date())
315 let filename = "\(timestamp)_domaindigresults.txt"
316 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
317
318 do {
319 try text.write(to: tempURL, atomically: true, encoding: .utf8)
320 } catch {
321 return
322 }
394 private func shareSingleResults(asCSV: Bool) {
395 let (filename, contents) = exportPayload(
396 prefix: "domaindig_single",
397 text: viewModel.exportText(),
398 csv: viewModel.exportCSV(),
399 asCSV: asCSV
400 )
401 ExportPresenter.share(filename: filename, contents: contents)
402 }
323403
324 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
325 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
326 let rootVC = windowScene.keyWindow?.rootViewController else { return }
327 var presenter = rootVC
328 while let presented = presenter.presentedViewController {
329 presenter = presented
330 }
331 activityVC.popoverPresentationController?.sourceView = presenter.view
332 presenter.present(activityVC, animated: true)
404 private func shareBatchResults(asCSV: Bool) {
405 let (filename, contents) = exportPayload(
406 prefix: "domaindig_batch",
407 text: viewModel.exportBatchText(),
408 csv: viewModel.exportBatchCSV(),
409 asCSV: asCSV
410 )
411 ExportPresenter.share(filename: filename, contents: contents)
412 }
413
414 private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) {
415 let formatter = DateFormatter()
416 formatter.dateFormat = "yyyyMMdd_HHmmss"
417 let timestamp = formatter.string(from: Date())
418 let fileExtension = asCSV ? "csv" : "txt"
419 let filename = "\(timestamp)_\(prefix).\(fileExtension)"
420 return (filename, asCSV ? csv : text)
333421 }
334422}
335423
@@ -387,17 +475,20 @@ struct DomainDiffView: View {
387475 let title: String
388476 let sections: [DomainDiffSection]
389477 let showsUnchanged: Bool
478 @State private var collapsedSections = Set<UUID>()
390479
391480 private var filteredSections: [DomainDiffSection] {
392 guard !showsUnchanged else { return sections }
481 guard showsUnchanged else {
482 return sections
483 .map { section in
484 DomainDiffSection(
485 title: section.title,
486 items: section.items.filter(\.hasChanges)
487 )
488 }
489 .filter { !$0.items.isEmpty }
490 }
393491 return sections
394 .map { section in
395 DomainDiffSection(
396 title: section.title,
397 items: section.items.filter { $0.changeType != .unchanged }
398 )
399 }
400 .filter { !$0.items.isEmpty }
401492 }
402493
403494 var body: some View {
@@ -408,34 +499,63 @@ struct DomainDiffView: View {
408499 } else {
409500 ForEach(filteredSections) { section in
410501 CardView(allowsHorizontalScroll: false) {
411 Text(section.title)
412 .font(.system(.subheadline, design: .monospaced))
413 .fontWeight(.semibold)
414 .foregroundStyle(.cyan)
415
416 ForEach(section.items) { item in
417 VStack(alignment: .leading, spacing: 4) {
418 HStack {
419 Text(item.label)
420 .font(.system(.caption, design: .monospaced))
421 .foregroundStyle(.secondary)
422 Spacer()
423 Text(changeLabel(for: item.changeType))
424 .font(.system(.caption2, design: .monospaced))
425 .foregroundStyle(changeColor(for: item.changeType))
426 }
427 if let oldValue = item.oldValue {
428 Text("Old: \(oldValue)")
429 .font(.system(.caption2, design: .monospaced))
430 .foregroundStyle(.secondary)
431 .textSelection(.enabled)
432 }
433 if let newValue = item.newValue {
434 Text("New: \(newValue)")
435 .font(.system(.caption, design: .monospaced))
436 .foregroundStyle(.primary)
437 .textSelection(.enabled)
502 DisclosureGroup(isExpanded: binding(for: section)) {
503 let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
504
505 ForEach(visibleItems) { item in
506 VStack(alignment: .leading, spacing: 6) {
507 HStack {
508 Text(item.label)
509 .font(.system(.caption, design: .monospaced))
510 .foregroundStyle(.secondary)
511 Spacer()
512 Text(changeLabel(for: item.changeType))
513 .font(.system(.caption2, design: .monospaced))
514 .foregroundStyle(changeColor(for: item.changeType))
515 .padding(.horizontal, 8)
516 .padding(.vertical, 4)
517 .background(changeColor(for: item.changeType).opacity(0.16))
518 .clipShape(Capsule())
519 }
520
521 if let oldValue = item.oldValue {
522 VStack(alignment: .leading, spacing: 2) {
523 Text("Old")
524 .font(.system(.caption2, design: .monospaced))
525 .foregroundStyle(.secondary)
526 Text(oldValue)
527 .font(.system(.caption2, design: .monospaced))
528 .foregroundStyle(.secondary)
529 .textSelection(.enabled)
530 }
531 }
532
533 if let newValue = item.newValue {
534 VStack(alignment: .leading, spacing: 2) {
535 Text("New")
536 .font(.system(.caption2, design: .monospaced))
537 .foregroundStyle(.secondary)
538 Text(newValue)
539 .font(.system(.caption, design: .monospaced))
540 .foregroundStyle(item.hasChanges ? .primary : .secondary)
541 .textSelection(.enabled)
542 }
543 }
438544 }
545 .padding(10)
546 .background(item.hasChanges ? changeColor(for: item.changeType).opacity(0.08) : Color(.systemGray6).opacity(0.25))
547 .cornerRadius(8)
548 }
549 } label: {
550 HStack {
551 Text(section.title)
552 .font(.system(.subheadline, design: .monospaced))
553 .fontWeight(.semibold)
554 .foregroundStyle(.cyan)
555 Spacer()
556 Text(section.hasChanges ? "Changed" : "Unchanged")
557 .font(.system(.caption2, design: .monospaced))
558 .foregroundStyle(section.hasChanges ? .yellow : .secondary)
439559 }
440560 }
441561 }
@@ -469,6 +589,19 @@ struct DomainDiffView: View {
469589 return .secondary
470590 }
471591 }
592
593 private func binding(for section: DomainDiffSection) -> Binding<Bool> {
594 Binding(
595 get: { !collapsedSections.contains(section.id) },
596 set: { isExpanded in
597 if isExpanded {
598 collapsedSections.remove(section.id)
599 } else {
600 collapsedSections.insert(section.id)
601 }
602 }
603 )
604 }
472605}
473606
474607struct TrackedDomainDetailHeaderView: View {
DomainDig/DomainDiffService.swift +16 −2
@@ -13,12 +13,20 @@ struct DomainDiffItem: Identifiable, Equatable {
1313 let changeType: DiffChangeType
1414 let oldValue: String?
1515 let newValue: String?
16
17 var hasChanges: Bool {
18 changeType != .unchanged
19 }
1620}
1721
1822struct DomainDiffSection: Identifiable, Equatable {
1923 let id = UUID()
2024 let title: String
2125 let items: [DomainDiffItem]
26
27 var hasChanges: Bool {
28 items.contains(where: \.hasChanges)
29 }
2230}
2331
2432enum DomainDiffService {
@@ -105,13 +113,15 @@ enum DomainDiffService {
105113 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
106114 let oldValue = normalized(oldValue)
107115 let newValue = normalized(newValue)
116 let normalizedOldValue = comparisonValue(oldValue)
117 let normalizedNewValue = comparisonValue(newValue)
108118
109119 guard oldValue != nil || newValue != nil else {
110120 return nil
111121 }
112122
113123 let changeType: DiffChangeType
114 switch (oldValue, newValue) {
124 switch (normalizedOldValue, normalizedNewValue) {
115125 case let (old?, new?) where old == new:
116126 changeType = .unchanged
117127 case (nil, _?):
@@ -129,7 +139,11 @@ enum DomainDiffService {
129139 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
130140 return nil
131141 }
132 return value.lowercased()
142 return value
143 }
144
145 private static func comparisonValue(_ value: String?) -> String? {
146 value?.lowercased()
133147 }
134148
135149 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
DomainDig/DomainViewModel.swift +506 −35
@@ -163,6 +163,7 @@ extension HistoryEntry {
163163@Observable
164164final class DomainViewModel {
165165 var domain: String = ""
166 var bulkInput: String = ""
166167
167168 var dnsSections: [DNSSection] = []
168169 var dnsLoading = false
@@ -221,6 +222,12 @@ final class DomainViewModel {
221222 private(set) var currentChangeSummary: DomainChangeSummary?
222223 private(set) var refreshingTrackedDomainID: UUID?
223224 private(set) var rerunNavigationToken = UUID()
225 private(set) var batchResults: [BatchLookupResult] = []
226 private(set) var batchLookupSource: BatchLookupSource = .manual
227 private(set) var batchCurrentDomain: String?
228 private(set) var batchCompletedCount = 0
229 private(set) var batchTotalCount = 0
230 private(set) var batchLookupRunning = false
224231
225232 private var lookupTask: Task<Void, Never>?
226233 private var customPortScanTask: Task<Void, Never>?
@@ -239,7 +246,7 @@ final class DomainViewModel {
239246 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
240247
241248 private static let historyKey = "lookupHistory"
242 private static let maxHistory = 50
249 private static let maxHistory = 250
243250 var history: [HistoryEntry] = {
244251 guard let data = UserDefaults.standard.data(forKey: historyKey),
245252 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
@@ -247,6 +254,13 @@ final class DomainViewModel {
247254 }
248255 return entries
249256 }()
257 var historySearchText = ""
258 var historyDateFilter: HistoryDateFilter = .all
259 var historyChangeFilter: ChangeFilterOption = .all
260 var historySortOption: HistorySortOption = .newest
261 var watchlistSearchText = ""
262 var watchlistFilter: WatchlistFilterOption = .all
263 var watchlistSortOption: WatchlistSortOption = .pinned
250264
251265 var trimmedDomain: String {
252266 domain
@@ -282,14 +296,73 @@ final class DomainViewModel {
282296 }
283297
284298 var sortedTrackedDomains: [TrackedDomain] {
285 trackedDomains.sorted {
286 if $0.isPinned != $1.isPinned {
287 return $0.isPinned && !$1.isPinned
299 sortedTrackedDomains(from: trackedDomains, using: .pinned)
300 }
301
302 var filteredHistory: [HistoryEntry] {
303 let calendar = Calendar.current
304 let now = Date()
305 let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
306
307 return history
308 .lazy
309 .filter { entry in
310 query.isEmpty || entry.domain.localizedCaseInsensitiveContains(query)
311 }
312 .filter { entry in
313 switch self.historyDateFilter {
314 case .today:
315 return calendar.isDate(entry.timestamp, inSameDayAs: now)
316 case .last7Days:
317 guard let startDate = calendar.date(byAdding: .day, value: -7, to: now) else { return true }
318 return entry.timestamp >= startDate
319 case .all:
320 return true
321 }
322 }
323 .filter { entry in
324 switch self.historyChangeFilter {
325 case .all:
326 return true
327 case .changed:
328 return entry.changeSummary?.hasChanges == true
329 case .unchanged:
330 return entry.changeSummary?.hasChanges != true
331 }
332 }
333 .sorted(by: historySortPredicate)
334 }
335
336 var filteredTrackedDomains: [TrackedDomain] {
337 let query = watchlistSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
338 let filtered = trackedDomains.filter { trackedDomain in
339 if !query.isEmpty, !trackedDomain.domain.localizedCaseInsensitiveContains(query) {
340 return false
288341 }
289 if $0.updatedAt != $1.updatedAt {
290 return $0.updatedAt > $1.updatedAt
342
343 switch watchlistFilter {
344 case .all:
345 return true
346 case .pinnedOnly:
347 return trackedDomain.isPinned
348 case .changedOnly:
349 return trackedDomain.lastChangeSummary?.hasChanges == true
291350 }
292 return $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
351 }
352
353 return sortedTrackedDomains(from: filtered, using: watchlistSortOption)
354 }
355
356 var batchProgressLabel: String {
357 guard batchTotalCount > 0 else { return "No active batch" }
358 let domainLabel = batchCurrentDomain ?? "Preparing"
359 return "\(batchCompletedCount + (batchLookupRunning ? 1 : 0))/\(batchTotalCount) • \(domainLabel)"
360 }
361
362 var currentBatchResultEntries: [HistoryEntry] {
363 batchResults.compactMap { result in
364 guard let historyEntryID = result.historyEntryID else { return nil }
365 return history.first(where: { $0.id == historyEntryID })
293366 }
294367 }
295368
@@ -303,13 +376,11 @@ final class DomainViewModel {
303376 }
304377
305378 var trackingLimitMessage: String? {
306 guard currentTrackedDomain == nil else { return nil }
307 guard !PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { return nil }
308 return "Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade."
379 nil
309380 }
310381
311382 var canTrackCurrentDomain: Bool {
312 currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
383 true
313384 }
314385
315386 var resolverDisplayName: String {
@@ -528,6 +599,11 @@ final class DomainViewModel {
528599 persistHistory()
529600 }
530601
602 func removeHistoryEntries(withIDs ids: [UUID]) {
603 history.removeAll { ids.contains($0.id) }
604 persistHistory()
605 }
606
531607 func clearHistory() {
532608 history.removeAll()
533609 persistHistory()
@@ -554,32 +630,77 @@ final class DomainViewModel {
554630 currentDiffSections = []
555631 currentChangeSummary = nil
556632 refreshingTrackedDomainID = nil
633 clearBatchState()
557634 clearLookupState()
558635 }
559636
560637 func run() {
561638 let target = trimmedDomain
562639 guard !target.isEmpty else { return }
640 clearBatchState()
641 let lookupID = beginLookup(for: target)
642
643 lookupTask = Task { [weak self] in
644 guard let self else { return }
645 _ = await self.performLookup(domain: target, lookupID: lookupID)
646 }
647 }
648
649 func runBulkLookup() {
650 let domains = parsedDomains(from: bulkInput)
651 guard !domains.isEmpty else { return }
652
653 clearBatchState()
654 batchLookupSource = .manual
655 batchTotalCount = domains.count
656 batchLookupRunning = true
657 batchResults = domains.map {
658 BatchLookupResult(
659 domain: $0,
660 historyEntryID: nil,
661 availability: nil,
662 primaryIP: nil,
663 quickStatus: "Pending",
664 timestamp: Date(),
665 status: .pending
666 )
667 }
563668
564669 lookupTask?.cancel()
565670 customPortScanTask?.cancel()
566671
567 let lookupID = UUID()
568 activeLookupID = lookupID
569 lookupStartedAt = Date()
570 lastLookupDurationMs = nil
571 addRecentSearch(target)
572 searchedDomain = target
573 hasRun = true
574 currentDiffSections = []
575 currentChangeSummary = nil
576 clearLookupState()
577 setAllLoadingStates(true)
578 customPortScanLoading = false
672 lookupTask = Task { [weak self] in
673 guard let self else { return }
674 await self.runBatchLookup(domains: domains, source: .manual)
675 }
676 }
677
678 func refreshAllTrackedDomains() {
679 let domains = sortedTrackedDomains.map(\.domain)
680 guard !domains.isEmpty else { return }
681
682 clearBatchState()
683 batchLookupSource = .watchlistRefresh
684 batchTotalCount = domains.count
685 batchLookupRunning = true
686 batchResults = domains.map {
687 BatchLookupResult(
688 domain: $0,
689 historyEntryID: nil,
690 availability: nil,
691 primaryIP: nil,
692 quickStatus: "Pending",
693 timestamp: Date(),
694 status: .pending
695 )
696 }
697
698 lookupTask?.cancel()
699 customPortScanTask?.cancel()
579700
580701 lookupTask = Task { [weak self] in
581702 guard let self else { return }
582 await self.performLookup(domain: target, lookupID: lookupID)
703 await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
583704 }
584705 }
585706
@@ -620,19 +741,75 @@ final class DomainViewModel {
620741 )
621742 }
622743
623 private func performLookup(domain: String, lookupID: UUID) async {
744 func exportCSV() -> String {
745 Self.formatCSV(from: [currentSnapshot])
746 }
747
748 func exportBatchText() -> String {
749 Self.formatBatchExportText(
750 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export",
751 entries: currentBatchResultEntries.map { entry in
752 (
753 snapshot: entry.snapshot,
754 trackedDomain: trackedDomains.first(where: { tracked in
755 tracked.id == entry.trackedDomainID ||
756 tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame
757 }),
758 changeSummary: entry.changeSummary,
759 diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
760 )
761 }
762 )
763 }
764
765 func exportBatchCSV() -> String {
766 Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot))
767 }
768
769 func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
770 Self.formatCSV(from: exportSnapshots(for: domains))
771 }
772
773 func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
774 let latestEntries = latestSnapshots(for: domains)
775 return Self.formatBatchExportText(
776 title: "Tracked Domains Export",
777 entries: domains.map { trackedDomain in
778 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
779 return (
780 snapshot: entry.snapshot,
781 trackedDomain: trackedDomain,
782 changeSummary: entry.changeSummary,
783 diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
784 )
785 }
786
787 return (
788 snapshot: placeholderSnapshot(for: trackedDomain),
789 trackedDomain: trackedDomain,
790 changeSummary: trackedDomain.lastChangeSummary,
791 diffSections: []
792 )
793 }
794 )
795 }
796
797 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
624798 await withTaskGroup(of: Void.self) { group in
625799 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
626800 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
627801 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
628802 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
803 }
804
805 await withTaskGroup(of: Void.self) { group in
629806 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
630807 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
631808 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
632809 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
633810 }
634811
635 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
812 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
636813
637814 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
638815 let primaryIP = primaryIPAddress(from: dnsSections)
@@ -647,7 +824,7 @@ final class DomainViewModel {
647824 }
648825 }
649826
650 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
827 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
651828
652829 if availabilityResult?.status == .registered {
653830 await runSuggestions(domain: domain, lookupID: lookupID)
@@ -656,10 +833,11 @@ final class DomainViewModel {
656833 suggestionsLoading = false
657834 }
658835
659 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
836 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
660837 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
661 saveHistoryEntry(replaceLatest: false)
838 let entry = saveHistoryEntry(replaceLatest: false)
662839 refreshingTrackedDomainID = nil
840 return entry
663841 }
664842
665843 private func runDNS(domain: String, lookupID: UUID) async {
@@ -868,7 +1046,7 @@ final class DomainViewModel {
8681046 case let .success(results):
8691047 customPortResults = results
8701048 customPortScanError = nil
871 saveHistoryEntry(replaceLatest: true)
1049 _ = saveHistoryEntry(replaceLatest: true)
8721050 case let .empty(message):
8731051 customPortResults = []
8741052 customPortScanError = message
@@ -904,8 +1082,9 @@ final class DomainViewModel {
9041082 }
9051083 }
9061084
907 private func saveHistoryEntry(replaceLatest: Bool) {
908 guard !searchedDomain.isEmpty else { return }
1085 @discardableResult
1086 private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
1087 guard !searchedDomain.isEmpty else { return nil }
9091088
9101089 let trackedDomainID = trackedDomain(for: searchedDomain)?.id
9111090 let timestamp = Date()
@@ -969,6 +1148,7 @@ final class DomainViewModel {
9691148 changeSummary: changeSummary
9701149 )
9711150 persistHistory()
1151 return entry
9721152 }
9731153
9741154 private func persistHistory() {
@@ -1027,7 +1207,13 @@ final class DomainViewModel {
10271207 }
10281208
10291209 private func normalizedDomain(_ domain: String) -> String {
1030 domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1210 domain
1211 .trimmingCharacters(in: .whitespacesAndNewlines)
1212 .replacingOccurrences(of: "https://", with: "")
1213 .replacingOccurrences(of: "http://", with: "")
1214 .components(separatedBy: "/")
1215 .first?
1216 .lowercased() ?? ""
10311217 }
10321218
10331219 private func linkTrackedDomainHistory(for domain: String) {
@@ -1064,6 +1250,110 @@ final class DomainViewModel {
10641250 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
10651251 }
10661252
1253 private func beginLookup(for target: String, cancelExistingTask: Bool = true) -> UUID {
1254 if cancelExistingTask {
1255 lookupTask?.cancel()
1256 }
1257 customPortScanTask?.cancel()
1258
1259 let lookupID = UUID()
1260 activeLookupID = lookupID
1261 lookupStartedAt = Date()
1262 lastLookupDurationMs = nil
1263 addRecentSearch(target)
1264 searchedDomain = target
1265 hasRun = true
1266 currentDiffSections = []
1267 currentChangeSummary = nil
1268 clearLookupState()
1269 setAllLoadingStates(true)
1270 customPortScanLoading = false
1271 return lookupID
1272 }
1273
1274 private func clearBatchState() {
1275 batchResults = []
1276 batchLookupSource = .manual
1277 batchCurrentDomain = nil
1278 batchCompletedCount = 0
1279 batchTotalCount = 0
1280 batchLookupRunning = false
1281 }
1282
1283 private func parsedDomains(from input: String) -> [String] {
1284 let separators = CharacterSet(charactersIn: ",\n")
1285 var seen = Set<String>()
1286
1287 return input
1288 .components(separatedBy: separators)
1289 .map(normalizedDomain)
1290 .filter { !$0.isEmpty }
1291 .filter { seen.insert($0).inserted }
1292 }
1293
1294 private func runBatchLookup(domains: [String], source: BatchLookupSource) async {
1295 for (index, domain) in domains.enumerated() {
1296 guard !Task.isCancelled else { break }
1297
1298 batchCurrentDomain = domain
1299 if source == .watchlistRefresh {
1300 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
1301 }
1302 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
1303
1304 let lookupID = beginLookup(for: domain, cancelExistingTask: false)
1305 let entry = await performLookup(domain: domain, lookupID: lookupID)
1306
1307 if let entry {
1308 updateBatchResult(
1309 domain: domain,
1310 status: .completed,
1311 quickStatus: entry.changeSummary?.hasChanges == true ? "Changed" : "Unchanged",
1312 entry: entry,
1313 errorMessage: nil
1314 )
1315 } else {
1316 updateBatchResult(
1317 domain: domain,
1318 status: .failed,
1319 quickStatus: "Failed",
1320 entry: nil,
1321 errorMessage: "Lookup cancelled"
1322 )
1323 }
1324
1325 batchCompletedCount = index + 1
1326 }
1327
1328 batchLookupRunning = false
1329 batchCurrentDomain = nil
1330 refreshingTrackedDomainID = nil
1331 }
1332
1333 private func updateBatchResult(
1334 domain: String,
1335 status: BatchLookupStatus,
1336 quickStatus: String,
1337 entry: HistoryEntry?,
1338 errorMessage: String?
1339 ) {
1340 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
1341 return
1342 }
1343
1344 batchResults[index] = BatchLookupResult(
1345 id: batchResults[index].id,
1346 domain: domain,
1347 historyEntryID: entry?.id,
1348 availability: entry?.availabilityResult?.status,
1349 primaryIP: entry?.primaryIP,
1350 quickStatus: quickStatus,
1351 timestamp: entry?.timestamp ?? Date(),
1352 status: status,
1353 errorMessage: errorMessage
1354 )
1355 }
1356
10671357 private func clearLookupState() {
10681358 dnsSections = []
10691359 dnsError = nil
@@ -1139,6 +1429,12 @@ final class DomainViewModel {
11391429 .map { $0 }
11401430 }
11411431
1432 func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
1433 domains.compactMap { trackedDomain in
1434 recentSnapshots(for: trackedDomain, limit: 1).first
1435 }
1436 }
1437
11421438 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
11431439 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
11441440 guard snapshots.count == 2 else { return [] }
@@ -1161,6 +1457,62 @@ final class DomainViewModel {
11611457 return siblings.first?.snapshot
11621458 }
11631459
1460 func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? {
1461 guard let historyEntryID = batchResult.historyEntryID else { return nil }
1462 return history.first(where: { $0.id == historyEntryID })
1463 }
1464
1465 private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] {
1466 let latestEntries = latestSnapshots(for: domains)
1467
1468 return domains.map { trackedDomain in
1469 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
1470 return entry.snapshot
1471 }
1472 return placeholderSnapshot(for: trackedDomain)
1473 }
1474 }
1475
1476 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
1477 LookupSnapshot(
1478 historyEntryID: trackedDomain.lastSnapshotID,
1479 domain: trackedDomain.domain,
1480 timestamp: trackedDomain.updatedAt,
1481 trackedDomainID: trackedDomain.id,
1482 resolverDisplayName: resolverDisplayName,
1483 resolverURLString: resolverURLString,
1484 totalLookupDurationMs: nil,
1485 dnsSections: [],
1486 dnsError: nil,
1487 availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown),
1488 suggestions: [],
1489 sslInfo: nil,
1490 sslError: nil,
1491 hstsPreloaded: nil,
1492 httpHeaders: [],
1493 httpSecurityGrade: nil,
1494 httpStatusCode: nil,
1495 httpResponseTimeMs: nil,
1496 httpProtocol: nil,
1497 http3Advertised: false,
1498 httpHeadersError: nil,
1499 reachabilityResults: [],
1500 reachabilityError: nil,
1501 ipGeolocation: nil,
1502 ipGeolocationError: nil,
1503 emailSecurity: nil,
1504 emailSecurityError: nil,
1505 ptrRecord: nil,
1506 ptrError: nil,
1507 redirectChain: [],
1508 redirectChainError: nil,
1509 portScanResults: [],
1510 portScanError: nil,
1511 changeSummary: trackedDomain.lastChangeSummary,
1512 isLive: false
1513 )
1514 }
1515
11641516 private static func loadTrackedDomains() -> [TrackedDomain] {
11651517 let defaults = UserDefaults.standard
11661518 let decoder = JSONDecoder()
@@ -1196,6 +1548,47 @@ final class DomainViewModel {
11961548 }
11971549 }
11981550
1551 private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
1552 switch historySortOption {
1553 case .newest:
1554 return lhs.timestamp > rhs.timestamp
1555 case .oldest:
1556 return lhs.timestamp < rhs.timestamp
1557 case .domain:
1558 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
1559 if domainOrder != .orderedSame {
1560 return domainOrder == .orderedAscending
1561 }
1562 return lhs.timestamp > rhs.timestamp
1563 }
1564 }
1565
1566 private func sortedTrackedDomains(from domains: [TrackedDomain], using sortOption: WatchlistSortOption) -> [TrackedDomain] {
1567 domains.sorted { lhs, rhs in
1568 switch sortOption {
1569 case .pinned:
1570 if lhs.isPinned != rhs.isPinned {
1571 return lhs.isPinned && !rhs.isPinned
1572 }
1573 if lhs.updatedAt != rhs.updatedAt {
1574 return lhs.updatedAt > rhs.updatedAt
1575 }
1576 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1577 case .recentlyUpdated:
1578 if lhs.updatedAt != rhs.updatedAt {
1579 return lhs.updatedAt > rhs.updatedAt
1580 }
1581 return lhs.domain.localizedCaseInsensitiveCompare(rhs.domain) == .orderedAscending
1582 case .alphabetical:
1583 let domainOrder = lhs.domain.localizedCaseInsensitiveCompare(rhs.domain)
1584 if domainOrder != .orderedSame {
1585 return domainOrder == .orderedAscending
1586 }
1587 return lhs.updatedAt > rhs.updatedAt
1588 }
1589 }
1590 }
1591
11991592 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
12001593 [
12011594 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1369,6 +1762,64 @@ final class DomainViewModel {
13691762 }
13701763 }
13711764
1765 static func formatBatchExportText(
1766 title: String,
1767 entries: [(snapshot: LookupSnapshot, trackedDomain: TrackedDomain?, changeSummary: DomainChangeSummary?, diffSections: [DomainDiffSection])]
1768 ) -> String {
1769 guard !entries.isEmpty else {
1770 return "\(title)\nNo results available."
1771 }
1772
1773 var lines = [title, String(repeating: "=", count: title.count), ""]
1774 for (index, entry) in entries.enumerated() {
1775 if index > 0 {
1776 lines.append("")
1777 lines.append(String(repeating: "=", count: 48))
1778 lines.append("")
1779 }
1780
1781 lines.append(
1782 formatExportText(
1783 from: entry.snapshot,
1784 trackedDomain: entry.trackedDomain,
1785 changeSummary: entry.changeSummary,
1786 diffSections: entry.diffSections
1787 )
1788 )
1789 }
1790 return lines.joined(separator: "\n")
1791 }
1792
1793 static func formatCSV(from snapshots: [LookupSnapshot]) -> String {
1794 let headers = [
1795 "domain",
1796 "availability",
1797 "primary_ip",
1798 "redirect_target",
1799 "tls_status",
1800 "http_status_grade",
1801 "email_security_summary",
1802 "last_updated"
1803 ]
1804
1805 let rows = snapshots.map { snapshot in
1806 [
1807 snapshot.domain,
1808 availabilityLabel(snapshot.availabilityResult?.status),
1809 primaryIPAddress(from: snapshot) ?? "",
1810 finalRedirectTarget(from: snapshot) ?? "",
1811 httpsSummary(from: snapshot),
1812 httpStatusGradeSummary(from: snapshot),
1813 emailSummary(from: snapshot),
1814 csvDateFormatter.string(from: snapshot.timestamp)
1815 ]
1816 }
1817
1818 return ([headers] + rows)
1819 .map { row in row.map(csvEscaped).joined(separator: ",") }
1820 .joined(separator: "\n")
1821 }
1822
13721823 static func formatExportText(
13731824 from snapshot: LookupSnapshot,
13741825 trackedDomain: TrackedDomain?,
@@ -1404,8 +1855,9 @@ final class DomainViewModel {
14041855 lines.append(" \(item.label): \(item.value)")
14051856 }
14061857 if let changeSummary {
1407 lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
1858 lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
14081859 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
1860 lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
14091861 }
14101862 }
14111863
@@ -1429,7 +1881,7 @@ final class DomainViewModel {
14291881 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
14301882 lines.append(" \(section.title)")
14311883 for item in section.items where item.changeType != .unchanged {
1432 lines.append(" \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
1884 lines.append(" [\(item.changeType.rawValue.capitalized)] \(item.label): \(item.oldValue ?? "None") -> \(item.newValue ?? "None")")
14331885 }
14341886 }
14351887 }
@@ -1596,6 +2048,14 @@ final class DomainViewModel {
15962048 snapshot.redirectChain.last?.url
15972049 }
15982050
2051 private static func httpStatusGradeSummary(from snapshot: LookupSnapshot) -> String {
2052 let parts = [snapshot.httpStatusCode.map(String.init), snapshot.httpSecurityGrade].compactMap { $0 }
2053 if !parts.isEmpty {
2054 return parts.joined(separator: " / ")
2055 }
2056 return snapshot.httpHeadersError ?? "Unavailable"
2057 }
2058
15992059 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
16002060 if snapshot.sslInfo != nil {
16012061 return "Valid"
@@ -1665,6 +2125,17 @@ final class DomainViewModel {
16652125 formatter.timeStyle = .short
16662126 return formatter
16672127 }()
2128
2129 private static let csvDateFormatter: ISO8601DateFormatter = {
2130 let formatter = ISO8601DateFormatter()
2131 formatter.formatOptions = [.withInternetDateTime]
2132 return formatter
2133 }()
2134
2135 private static func csvEscaped(_ value: String) -> String {
2136 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
2137 return "\"\(escaped)\""
2138 }
16682139}
16692140
16702141private extension String {
DomainDig/ExportPresenter.swift added +28
@@ -0,0 +1,28 @@
1import SwiftUI
2import UIKit
3
4enum ExportPresenter {
5 static func share(filename: String, contents: String) {
6 let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
7
8 do {
9 try contents.write(to: url, atomically: true, encoding: .utf8)
10 } catch {
11 return
12 }
13
14 let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
15 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
16 let rootViewController = windowScene.keyWindow?.rootViewController else {
17 return
18 }
19
20 var presenter = rootViewController
21 while let presentedViewController = presenter.presentedViewController {
22 presenter = presentedViewController
23 }
24
25 activityController.popoverPresentationController?.sourceView = presenter.view
26 presenter.present(activityController, animated: true)
27 }
28}
DomainDig/HistoryView.swift +33 −6
@@ -14,13 +14,13 @@ struct HistoryView: View {
1414
1515 var body: some View {
1616 List {
17 if viewModel.history.isEmpty {
17 if viewModel.filteredHistory.isEmpty {
1818 Text("No lookup history")
1919 .font(.system(.callout, design: .monospaced))
2020 .foregroundStyle(.secondary)
2121 .listRowBackground(Color(.systemGray6).opacity(0.5))
2222 } else {
23 ForEach(viewModel.history) { entry in
23 ForEach(viewModel.filteredHistory) { entry in
2424 NavigationLink {
2525 HistoryDetailView(viewModel: viewModel, entry: entry)
2626 } label: {
@@ -28,6 +28,11 @@ struct HistoryView: View {
2828 Text(entry.domain)
2929 .font(.system(.callout, design: .monospaced))
3030 .foregroundStyle(.primary)
31 if let summary = entry.changeSummary {
32 Text(summary.hasChanges ? "Changed" : "Unchanged")
33 .font(.system(.caption2, design: .monospaced))
34 .foregroundStyle(summary.hasChanges ? .yellow : .green)
35 }
3136 HStack(spacing: 8) {
3237 Text(dateFormatter.string(from: entry.timestamp))
3338 Text("Snapshot")
@@ -42,23 +47,40 @@ struct HistoryView: View {
4247 }
4348 .listRowBackground(Color(.systemGray6).opacity(0.5))
4449 }
45 .onDelete { offsets in
46 viewModel.removeHistoryEntries(at: offsets)
47 }
50 .onDelete(perform: deleteFilteredHistoryEntries)
4851 }
4952 }
5053 .scrollContentBackground(.hidden)
5154 .background(Color.black)
5255 .navigationTitle("History")
56 .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
5357 .toolbar {
5458 if !viewModel.history.isEmpty {
5559 ToolbarItemGroup(placement: .topBarTrailing) {
5660 Menu {
61 Picker("Date Range", selection: $viewModel.historyDateFilter) {
62 ForEach(HistoryDateFilter.allCases) { option in
63 Text(option.title).tag(option)
64 }
65 }
66
67 Picker("Change Filter", selection: $viewModel.historyChangeFilter) {
68 ForEach(ChangeFilterOption.allCases) { option in
69 Text(option.title).tag(option)
70 }
71 }
72
73 Picker("Sort", selection: $viewModel.historySortOption) {
74 ForEach(HistorySortOption.allCases) { option in
75 Text(option.title).tag(option)
76 }
77 }
78
5779 Button("Clear All", role: .destructive) {
5880 showClearAllConfirmation = true
5981 }
6082 } label: {
61 Image(systemName: "ellipsis.circle")
83 Image(systemName: "line.3.horizontal.decrease.circle")
6284 }
6385
6486 EditButton()
@@ -78,6 +100,11 @@ struct HistoryView: View {
78100 }
79101 .preferredColorScheme(.dark)
80102 }
103
104 private func deleteFilteredHistoryEntries(at offsets: IndexSet) {
105 let ids = offsets.map { viewModel.filteredHistory[$0].id }
106 viewModel.removeHistoryEntries(withIDs: ids)
107 }
81108}
82109
83110struct HistoryDetailView: View {
DomainDig/Models.swift +141
@@ -54,6 +54,147 @@ struct DomainChangeSummary: Codable, Equatable {
5454 let generatedAt: Date
5555}
5656
57enum BatchLookupSource: String, Codable {
58 case manual
59 case watchlistRefresh
60}
61
62enum BatchLookupStatus: String, Codable {
63 case pending
64 case running
65 case completed
66 case failed
67}
68
69struct BatchLookupResult: Identifiable, Codable, Equatable {
70 let id: UUID
71 let domain: String
72 let historyEntryID: UUID?
73 let availability: DomainAvailabilityStatus?
74 let primaryIP: String?
75 let quickStatus: String
76 let timestamp: Date
77 let status: BatchLookupStatus
78 let errorMessage: String?
79
80 init(
81 id: UUID = UUID(),
82 domain: String,
83 historyEntryID: UUID?,
84 availability: DomainAvailabilityStatus?,
85 primaryIP: String?,
86 quickStatus: String,
87 timestamp: Date,
88 status: BatchLookupStatus,
89 errorMessage: String? = nil
90 ) {
91 self.id = id
92 self.domain = domain
93 self.historyEntryID = historyEntryID
94 self.availability = availability
95 self.primaryIP = primaryIP
96 self.quickStatus = quickStatus
97 self.timestamp = timestamp
98 self.status = status
99 self.errorMessage = errorMessage
100 }
101}
102
103enum HistoryDateFilter: String, CaseIterable, Identifiable {
104 case today
105 case last7Days
106 case all
107
108 var id: String { rawValue }
109
110 var title: String {
111 switch self {
112 case .today:
113 return "Today"
114 case .last7Days:
115 return "Last 7 Days"
116 case .all:
117 return "All"
118 }
119 }
120}
121
122enum ChangeFilterOption: String, CaseIterable, Identifiable {
123 case all
124 case changed
125 case unchanged
126
127 var id: String { rawValue }
128
129 var title: String {
130 switch self {
131 case .all:
132 return "All"
133 case .changed:
134 return "Changed"
135 case .unchanged:
136 return "Unchanged"
137 }
138 }
139}
140
141enum HistorySortOption: String, CaseIterable, Identifiable {
142 case newest
143 case oldest
144 case domain
145
146 var id: String { rawValue }
147
148 var title: String {
149 switch self {
150 case .newest:
151 return "Newest"
152 case .oldest:
153 return "Oldest"
154 case .domain:
155 return "Domain A-Z"
156 }
157 }
158}
159
160enum WatchlistFilterOption: String, CaseIterable, Identifiable {
161 case all
162 case pinnedOnly
163 case changedOnly
164
165 var id: String { rawValue }
166
167 var title: String {
168 switch self {
169 case .all:
170 return "All"
171 case .pinnedOnly:
172 return "Pinned Only"
173 case .changedOnly:
174 return "Changed Only"
175 }
176 }
177}
178
179enum WatchlistSortOption: String, CaseIterable, Identifiable {
180 case pinned
181 case recentlyUpdated
182 case alphabetical
183
184 var id: String { rawValue }
185
186 var title: String {
187 switch self {
188 case .pinned:
189 return "Pinned"
190 case .recentlyUpdated:
191 return "Recently Updated"
192 case .alphabetical:
193 return "Alphabetical"
194 }
195 }
196}
197
57198enum PremiumCapability: String, Codable {
58199 case unlimitedTrackedDomains
59200 case automatedMonitoring
DomainDig/PremiumAccessService.swift +3 −15
@@ -1,27 +1,15 @@
11import Foundation
22
33enum PremiumAccessService {
4 static let freeTrackedDomainLimit = 3
5
64 static func hasAccess(to capability: PremiumCapability) -> Bool {
7 switch capability {
8 case .unlimitedTrackedDomains,
9 .automatedMonitoring,
10 .pushAlerts,
11 .batchTracking,
12 .advancedExports:
13 return false
14 }
5 true
156 }
167
178 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
18 guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else {
19 return nil
20 }
21 return "More tracked domains will be available in a future Pro upgrade."
9 nil
2210 }
2311
2412 static func canAddTrackedDomain(currentCount: Int) -> Bool {
25 hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit
13 true
2614 }
2715}
DomainDig/WatchlistView.swift +73 −5
@@ -6,7 +6,27 @@ struct WatchlistView: View {
66
77 var body: some View {
88 List {
9 if viewModel.sortedTrackedDomains.isEmpty {
9 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
10 Section("Refresh Progress") {
11 VStack(alignment: .leading, spacing: 8) {
12 if viewModel.batchLookupRunning {
13 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
14 .tint(.cyan)
15 Text(viewModel.batchProgressLabel)
16 .font(.system(.caption, design: .monospaced))
17 .foregroundStyle(.secondary)
18 }
19
20 ForEach(viewModel.batchResults.prefix(5)) { result in
21 BatchResultRowView(result: result)
22 }
23 }
24 .padding(.vertical, 4)
25 }
26 .listRowBackground(Color(.systemGray6).opacity(0.5))
27 }
28
29 if viewModel.filteredTrackedDomains.isEmpty {
1030 Section {
1131 VStack(alignment: .leading, spacing: 8) {
1232 Text("No tracked domains yet")
@@ -30,7 +50,7 @@ struct WatchlistView: View {
3050 }
3151
3252 Section {
33 ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
53 ForEach(viewModel.filteredTrackedDomains) { trackedDomain in
3454 NavigationLink {
3555 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
3656 } label: {
@@ -83,7 +103,7 @@ struct WatchlistView: View {
83103 }
84104 .listRowBackground(Color(.systemGray6).opacity(0.5))
85105 }
86 .onDelete(perform: viewModel.deleteTrackedDomains)
106 .onDelete(perform: deleteFilteredTrackedDomains)
87107 } header: {
88108 Text("Tracked Domains")
89109 }
@@ -92,9 +112,40 @@ struct WatchlistView: View {
92112 .scrollContentBackground(.hidden)
93113 .background(Color.black)
94114 .navigationTitle("Watchlist")
115 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
95116 .toolbar {
96 if !viewModel.sortedTrackedDomains.isEmpty {
97 EditButton()
117 if !viewModel.filteredTrackedDomains.isEmpty {
118 ToolbarItemGroup(placement: .topBarTrailing) {
119 Menu {
120 Picker("Filter", selection: $viewModel.watchlistFilter) {
121 ForEach(WatchlistFilterOption.allCases) { option in
122 Text(option.title).tag(option)
123 }
124 }
125
126 Picker("Sort", selection: $viewModel.watchlistSortOption) {
127 ForEach(WatchlistSortOption.allCases) { option in
128 Text(option.title).tag(option)
129 }
130 }
131
132 Button("Refresh All") {
133 viewModel.refreshAllTrackedDomains()
134 }
135
136 Button("Export TXT") {
137 shareTrackedDomains(asCSV: false)
138 }
139
140 Button("Export CSV") {
141 shareTrackedDomains(asCSV: true)
142 }
143 } label: {
144 Image(systemName: "line.3.horizontal.decrease.circle")
145 }
146
147 EditButton()
148 }
98149 }
99150 }
100151 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
@@ -102,6 +153,23 @@ struct WatchlistView: View {
102153 }
103154 .preferredColorScheme(.dark)
104155 }
156
157 private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
158 let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
159 domains.forEach(viewModel.deleteTrackedDomain)
160 }
161
162 private func shareTrackedDomains(asCSV: Bool) {
163 let formatter = DateFormatter()
164 formatter.dateFormat = "yyyyMMdd_HHmmss"
165 let timestamp = formatter.string(from: Date())
166 let fileExtension = asCSV ? "csv" : "txt"
167 let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)"
168 let contents = asCSV
169 ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains)
170 : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains)
171 ExportPresenter.share(filename: filename, contents: contents)
172 }
105173}
106174
107175struct WatchlistRowView: View {