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 @@
267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269 CODE_SIGN_STYLE = Automatic; 269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 13; 270 CURRENT_PROJECT_VERSION = 14;
271 DEVELOPMENT_TEAM = ZCNAX3VL9D; 271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272 ENABLE_PREVIEWS = YES; 272 ENABLE_PREVIEWS = YES;
273 GENERATE_INFOPLIST_FILE = YES; 273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284 "$(inherited)", 284 "$(inherited)",
285 "@executable_path/Frameworks", 285 "@executable_path/Frameworks",
286 ); 286 );
287 MARKETING_VERSION = 2.0.0; 287 MARKETING_VERSION = 2.1.0;
288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289 PRODUCT_NAME = "$(TARGET_NAME)"; 289 PRODUCT_NAME = "$(TARGET_NAME)";
290 STRING_CATALOG_GENERATE_SYMBOLS = YES; 290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305 CODE_SIGN_STYLE = Automatic; 305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 13; 306 CURRENT_PROJECT_VERSION = 14;
307 DEVELOPMENT_TEAM = ZCNAX3VL9D; 307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308 ENABLE_PREVIEWS = YES; 308 ENABLE_PREVIEWS = YES;
309 GENERATE_INFOPLIST_FILE = YES; 309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320 "$(inherited)", 320 "$(inherited)",
321 "@executable_path/Frameworks", 321 "@executable_path/Frameworks",
322 ); 322 );
323 MARKETING_VERSION = 2.0.0; 323 MARKETING_VERSION = 2.1.0;
324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325 PRODUCT_NAME = "$(TARGET_NAME)"; 325 PRODUCT_NAME = "$(TARGET_NAME)";
326 STRING_CATALOG_GENERATE_SYMBOLS = YES; 326 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 @@
1import MapKit 1import MapKit
2import SwiftUI 2import SwiftUI
3 3
4enum LookupInputMode: String, CaseIterable, Identifiable {
5 case single
6 case bulk
7
8 var id: String { rawValue }
9}
10
4struct ContentView: View { 11struct ContentView: View {
5 @State private var viewModel = DomainViewModel() 12 @State private var viewModel = DomainViewModel()
6 @State private var navigationPath = NavigationPath() 13 @State private var navigationPath = NavigationPath()
@@ -10,12 +17,17 @@ struct ContentView: View {
10 @State private var trackingNoteDraft = "" 17 @State private var trackingNoteDraft = ""
11 @State private var editingTrackedDomain: TrackedDomain? 18 @State private var editingTrackedDomain: TrackedDomain?
12 @State private var showTrackLimitAlert = false 19 @State private var showTrackLimitAlert = false
20 @State private var inputMode: LookupInputMode = .single
13 21
14 var body: some View { 22 var body: some View {
15 NavigationStack(path: $navigationPath) { 23 NavigationStack(path: $navigationPath) {
16 ScrollView(.vertical) { 24 ScrollView(.vertical) {
17 VStack(spacing: 0) { 25 VStack(spacing: 0) {
18 inputSection 26 inputSection
27 if !viewModel.batchResults.isEmpty || viewModel.batchLookupRunning {
28 batchSection
29 .padding(.top, 8)
30 }
19 if viewModel.hasRun { 31 if viewModel.hasRun {
20 actionButtons 32 actionButtons
21 SummaryView(fields: viewModel.summaryFields) 33 SummaryView(fields: viewModel.summaryFields)
@@ -199,28 +211,68 @@ struct ContentView: View {
199 211
200 private var inputSection: some View { 212 private var inputSection: some View {
201 VStack(spacing: 12) { 213 VStack(spacing: 12) {
202 TextField("e.g. cleberg.net", text: $viewModel.domain) 214 Picker("Mode", selection: $inputMode) {
203 .font(.system(.title3, design: .monospaced)) 215 Text("Single").tag(LookupInputMode.single)
204 .textInputAutocapitalization(.never) 216 Text("Bulk").tag(LookupInputMode.bulk)
205 .autocorrectionDisabled() 217 }
206 .keyboardType(.URL) 218 .pickerStyle(.segmented)
207 .padding(12) 219
208 .background(Color(.systemGray6)) 220 if inputMode == .single {
209 .cornerRadius(8) 221 TextField("e.g. cleberg.net", text: $viewModel.domain)
210 .focused($domainFieldFocused) 222 .font(.system(.title3, design: .monospaced))
211 .onSubmit { viewModel.run() } 223 .textInputAutocapitalization(.never)
212 224 .autocorrectionDisabled()
213 Button { 225 .keyboardType(.URL)
214 domainFieldFocused = false 226 .padding(12)
215 viewModel.run() 227 .background(Color(.systemGray6))
216 } label: { 228 .cornerRadius(8)
217 Text("Run") 229 .focused($domainFieldFocused)
218 .font(.headline) 230 .onSubmit { viewModel.run() }
219 .frame(maxWidth: .infinity) 231
220 .padding(.vertical, 12) 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 }
221 } 275 }
222 .buttonStyle(.borderedProminent)
223 .disabled(viewModel.trimmedDomain.isEmpty)
224 } 276 }
225 .padding(.vertical, 16) 277 .padding(.vertical, 16)
226 } 278 }
@@ -236,8 +288,13 @@ struct ContentView: View {
236 .font(.system(.body)) 288 .font(.system(.body))
237 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) 289 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
238 } 290 }
239 Button { 291 Menu {
240 shareResults() 292 Button("Export TXT") {
293 shareSingleResults(asCSV: false)
294 }
295 Button("Export CSV") {
296 shareSingleResults(asCSV: true)
297 }
241 } label: { 298 } label: {
242 Image(systemName: "square.and.arrow.up") 299 Image(systemName: "square.and.arrow.up")
243 .font(.system(.body)) 300 .font(.system(.body))
@@ -247,6 +304,33 @@ struct ContentView: View {
247 } 304 }
248 } 305 }
249 306
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
250 private var recentSearchesSection: some View { 334 private var recentSearchesSection: some View {
251 VStack(alignment: .leading, spacing: 8) { 335 VStack(alignment: .leading, spacing: 8) {
252 HStack { 336 HStack {
@@ -307,29 +391,33 @@ struct ContentView: View {
307 return ports 391 return ports
308 } 392 }
309 393
310 private func shareResults() { 394 private func shareSingleResults(asCSV: Bool) {
311 let text = viewModel.exportText() 395 let (filename, contents) = exportPayload(
312 let dateFmt = DateFormatter() 396 prefix: "domaindig_single",
313 dateFmt.dateFormat = "yyyyMMdd_HHmmss" 397 text: viewModel.exportText(),
314 let timestamp = dateFmt.string(from: Date()) 398 csv: viewModel.exportCSV(),
315 let filename = "\(timestamp)_domaindigresults.txt" 399 asCSV: asCSV
316 let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) 400 )
317 401 ExportPresenter.share(filename: filename, contents: contents)
318 do { 402 }
319 try text.write(to: tempURL, atomically: true, encoding: .utf8)
320 } catch {
321 return
322 }
323 403
324 let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) 404 private func shareBatchResults(asCSV: Bool) {
325 guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 405 let (filename, contents) = exportPayload(
326 let rootVC = windowScene.keyWindow?.rootViewController else { return } 406 prefix: "domaindig_batch",
327 var presenter = rootVC 407 text: viewModel.exportBatchText(),
328 while let presented = presenter.presentedViewController { 408 csv: viewModel.exportBatchCSV(),
329 presenter = presented 409 asCSV: asCSV
330 } 410 )
331 activityVC.popoverPresentationController?.sourceView = presenter.view 411 ExportPresenter.share(filename: filename, contents: contents)
332 presenter.present(activityVC, animated: true) 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)
333 } 421 }
334} 422}
335 423
@@ -387,17 +475,20 @@ struct DomainDiffView: View {
387 let title: String 475 let title: String
388 let sections: [DomainDiffSection] 476 let sections: [DomainDiffSection]
389 let showsUnchanged: Bool 477 let showsUnchanged: Bool
478 @State private var collapsedSections = Set<UUID>()
390 479
391 private var filteredSections: [DomainDiffSection] { 480 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 }
393 return sections 491 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 }
401 } 492 }
402 493
403 var body: some View { 494 var body: some View {
@@ -408,34 +499,63 @@ struct DomainDiffView: View {
408 } else { 499 } else {
409 ForEach(filteredSections) { section in 500 ForEach(filteredSections) { section in
410 CardView(allowsHorizontalScroll: false) { 501 CardView(allowsHorizontalScroll: false) {
411 Text(section.title) 502 DisclosureGroup(isExpanded: binding(for: section)) {
412 .font(.system(.subheadline, design: .monospaced)) 503 let visibleItems = showsUnchanged ? section.items : section.items.filter(\.hasChanges)
413 .fontWeight(.semibold) 504
414 .foregroundStyle(.cyan) 505 ForEach(visibleItems) { item in
415 506 VStack(alignment: .leading, spacing: 6) {
416 ForEach(section.items) { item in 507 HStack {
417 VStack(alignment: .leading, spacing: 4) { 508 Text(item.label)
418 HStack { 509 .font(.system(.caption, design: .monospaced))
419 Text(item.label) 510 .foregroundStyle(.secondary)
420 .font(.system(.caption, design: .monospaced)) 511 Spacer()
421 .foregroundStyle(.secondary) 512 Text(changeLabel(for: item.changeType))
422 Spacer() 513 .font(.system(.caption2, design: .monospaced))
423 Text(changeLabel(for: item.changeType)) 514 .foregroundStyle(changeColor(for: item.changeType))
424 .font(.system(.caption2, design: .monospaced)) 515 .padding(.horizontal, 8)
425 .foregroundStyle(changeColor(for: item.changeType)) 516 .padding(.vertical, 4)
426 } 517 .background(changeColor(for: item.changeType).opacity(0.16))
427 if let oldValue = item.oldValue { 518 .clipShape(Capsule())
428 Text("Old: \(oldValue)") 519 }
429 .font(.system(.caption2, design: .monospaced)) 520
430 .foregroundStyle(.secondary) 521 if let oldValue = item.oldValue {
431 .textSelection(.enabled) 522 VStack(alignment: .leading, spacing: 2) {
432 } 523 Text("Old")
433 if let newValue = item.newValue { 524 .font(.system(.caption2, design: .monospaced))
434 Text("New: \(newValue)") 525 .foregroundStyle(.secondary)
435 .font(.system(.caption, design: .monospaced)) 526 Text(oldValue)
436 .foregroundStyle(.primary) 527 .font(.system(.caption2, design: .monospaced))
437 .textSelection(.enabled) 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 }
438 } 544 }
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)
439 } 559 }
440 } 560 }
441 } 561 }
@@ -469,6 +589,19 @@ struct DomainDiffView: View {
469 return .secondary 589 return .secondary
470 } 590 }
471 } 591 }
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 }
472} 605}
473 606
474struct TrackedDomainDetailHeaderView: View { 607struct TrackedDomainDetailHeaderView: View {
DomainDig/DomainDiffService.swift +16 −2
@@ -13,12 +13,20 @@ struct DomainDiffItem: Identifiable, Equatable {
13 let changeType: DiffChangeType 13 let changeType: DiffChangeType
14 let oldValue: String? 14 let oldValue: String?
15 let newValue: String? 15 let newValue: String?
16
17 var hasChanges: Bool {
18 changeType != .unchanged
19 }
16} 20}
17 21
18struct DomainDiffSection: Identifiable, Equatable { 22struct DomainDiffSection: Identifiable, Equatable {
19 let id = UUID() 23 let id = UUID()
20 let title: String 24 let title: String
21 let items: [DomainDiffItem] 25 let items: [DomainDiffItem]
26
27 var hasChanges: Bool {
28 items.contains(where: \.hasChanges)
29 }
22} 30}
23 31
24enum DomainDiffService { 32enum DomainDiffService {
@@ -105,13 +113,15 @@ enum DomainDiffService {
105 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { 113 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
106 let oldValue = normalized(oldValue) 114 let oldValue = normalized(oldValue)
107 let newValue = normalized(newValue) 115 let newValue = normalized(newValue)
116 let normalizedOldValue = comparisonValue(oldValue)
117 let normalizedNewValue = comparisonValue(newValue)
108 118
109 guard oldValue != nil || newValue != nil else { 119 guard oldValue != nil || newValue != nil else {
110 return nil 120 return nil
111 } 121 }
112 122
113 let changeType: DiffChangeType 123 let changeType: DiffChangeType
114 switch (oldValue, newValue) { 124 switch (normalizedOldValue, normalizedNewValue) {
115 case let (old?, new?) where old == new: 125 case let (old?, new?) where old == new:
116 changeType = .unchanged 126 changeType = .unchanged
117 case (nil, _?): 127 case (nil, _?):
@@ -129,7 +139,11 @@ enum DomainDiffService {
129 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { 139 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
130 return nil 140 return nil
131 } 141 }
132 return value.lowercased() 142 return value
143 }
144
145 private static func comparisonValue(_ value: String?) -> String? {
146 value?.lowercased()
133 } 147 }
134 148
135 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { 149 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
DomainDig/DomainViewModel.swift +506 −35
@@ -163,6 +163,7 @@ extension HistoryEntry {
163@Observable 163@Observable
164final class DomainViewModel { 164final class DomainViewModel {
165 var domain: String = "" 165 var domain: String = ""
166 var bulkInput: String = ""
166 167
167 var dnsSections: [DNSSection] = [] 168 var dnsSections: [DNSSection] = []
168 var dnsLoading = false 169 var dnsLoading = false
@@ -221,6 +222,12 @@ final class DomainViewModel {
221 private(set) var currentChangeSummary: DomainChangeSummary? 222 private(set) var currentChangeSummary: DomainChangeSummary?
222 private(set) var refreshingTrackedDomainID: UUID? 223 private(set) var refreshingTrackedDomainID: UUID?
223 private(set) var rerunNavigationToken = UUID() 224 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
224 231
225 private var lookupTask: Task<Void, Never>? 232 private var lookupTask: Task<Void, Never>?
226 private var customPortScanTask: Task<Void, Never>? 233 private var customPortScanTask: Task<Void, Never>?
@@ -239,7 +246,7 @@ final class DomainViewModel {
239 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains() 246 var trackedDomains: [TrackedDomain] = DomainViewModel.loadTrackedDomains()
240 247
241 private static let historyKey = "lookupHistory" 248 private static let historyKey = "lookupHistory"
242 private static let maxHistory = 50 249 private static let maxHistory = 250
243 var history: [HistoryEntry] = { 250 var history: [HistoryEntry] = {
244 guard let data = UserDefaults.standard.data(forKey: historyKey), 251 guard let data = UserDefaults.standard.data(forKey: historyKey),
245 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { 252 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
@@ -247,6 +254,13 @@ final class DomainViewModel {
247 } 254 }
248 return entries 255 return entries
249 }() 256 }()
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
250 264
251 var trimmedDomain: String { 265 var trimmedDomain: String {
252 domain 266 domain
@@ -282,14 +296,73 @@ final class DomainViewModel {
282 } 296 }
283 297
284 var sortedTrackedDomains: [TrackedDomain] { 298 var sortedTrackedDomains: [TrackedDomain] {
285 trackedDomains.sorted { 299 sortedTrackedDomains(from: trackedDomains, using: .pinned)
286 if $0.isPinned != $1.isPinned { 300 }
287 return $0.isPinned && !$1.isPinned 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
288 } 341 }
289 if $0.updatedAt != $1.updatedAt { 342
290 return $0.updatedAt > $1.updatedAt 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
291 } 350 }
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 })
293 } 366 }
294 } 367 }
295 368
@@ -303,13 +376,11 @@ final class DomainViewModel {
303 } 376 }
304 377
305 var trackingLimitMessage: String? { 378 var trackingLimitMessage: String? {
306 guard currentTrackedDomain == nil else { return nil } 379 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."
309 } 380 }
310 381
311 var canTrackCurrentDomain: Bool { 382 var canTrackCurrentDomain: Bool {
312 currentTrackedDomain != nil || PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) 383 true
313 } 384 }
314 385
315 var resolverDisplayName: String { 386 var resolverDisplayName: String {
@@ -528,6 +599,11 @@ final class DomainViewModel {
528 persistHistory() 599 persistHistory()
529 } 600 }
530 601
602 func removeHistoryEntries(withIDs ids: [UUID]) {
603 history.removeAll { ids.contains($0.id) }
604 persistHistory()
605 }
606
531 func clearHistory() { 607 func clearHistory() {
532 history.removeAll() 608 history.removeAll()
533 persistHistory() 609 persistHistory()
@@ -554,32 +630,77 @@ final class DomainViewModel {
554 currentDiffSections = [] 630 currentDiffSections = []
555 currentChangeSummary = nil 631 currentChangeSummary = nil
556 refreshingTrackedDomainID = nil 632 refreshingTrackedDomainID = nil
633 clearBatchState()
557 clearLookupState() 634 clearLookupState()
558 } 635 }
559 636
560 func run() { 637 func run() {
561 let target = trimmedDomain 638 let target = trimmedDomain
562 guard !target.isEmpty else { return } 639 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 }
563 668
564 lookupTask?.cancel() 669 lookupTask?.cancel()
565 customPortScanTask?.cancel() 670 customPortScanTask?.cancel()
566 671
567 let lookupID = UUID() 672 lookupTask = Task { [weak self] in
568 activeLookupID = lookupID 673 guard let self else { return }
569 lookupStartedAt = Date() 674 await self.runBatchLookup(domains: domains, source: .manual)
570 lastLookupDurationMs = nil 675 }
571 addRecentSearch(target) 676 }
572 searchedDomain = target 677
573 hasRun = true 678 func refreshAllTrackedDomains() {
574 currentDiffSections = [] 679 let domains = sortedTrackedDomains.map(\.domain)
575 currentChangeSummary = nil 680 guard !domains.isEmpty else { return }
576 clearLookupState() 681
577 setAllLoadingStates(true) 682 clearBatchState()
578 customPortScanLoading = false 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()
579 700
580 lookupTask = Task { [weak self] in 701 lookupTask = Task { [weak self] in
581 guard let self else { return } 702 guard let self else { return }
582 await self.performLookup(domain: target, lookupID: lookupID) 703 await self.runBatchLookup(domains: domains, source: .watchlistRefresh)
583 } 704 }
584 } 705 }
585 706
@@ -620,19 +741,75 @@ final class DomainViewModel {
620 ) 741 )
621 } 742 }
622 743
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? {
624 await withTaskGroup(of: Void.self) { group in 798 await withTaskGroup(of: Void.self) { group in
625 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } 799 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
626 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } 800 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
627 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } 801 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
628 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } 802 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
803 }
804
805 await withTaskGroup(of: Void.self) { group in
629 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } 806 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
630 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } 807 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
631 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } 808 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
632 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } 809 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
633 } 810 }
634 811
635 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } 812 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
636 813
637 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] 814 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
638 let primaryIP = primaryIPAddress(from: dnsSections) 815 let primaryIP = primaryIPAddress(from: dnsSections)
@@ -647,7 +824,7 @@ final class DomainViewModel {
647 } 824 }
648 } 825 }
649 826
650 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } 827 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
651 828
652 if availabilityResult?.status == .registered { 829 if availabilityResult?.status == .registered {
653 await runSuggestions(domain: domain, lookupID: lookupID) 830 await runSuggestions(domain: domain, lookupID: lookupID)
@@ -656,10 +833,11 @@ final class DomainViewModel {
656 suggestionsLoading = false 833 suggestionsLoading = false
657 } 834 }
658 835
659 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } 836 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
660 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } 837 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
661 saveHistoryEntry(replaceLatest: false) 838 let entry = saveHistoryEntry(replaceLatest: false)
662 refreshingTrackedDomainID = nil 839 refreshingTrackedDomainID = nil
840 return entry
663 } 841 }
664 842
665 private func runDNS(domain: String, lookupID: UUID) async { 843 private func runDNS(domain: String, lookupID: UUID) async {
@@ -868,7 +1046,7 @@ final class DomainViewModel {
868 case let .success(results): 1046 case let .success(results):
869 customPortResults = results 1047 customPortResults = results
870 customPortScanError = nil 1048 customPortScanError = nil
871 saveHistoryEntry(replaceLatest: true) 1049 _ = saveHistoryEntry(replaceLatest: true)
872 case let .empty(message): 1050 case let .empty(message):
873 customPortResults = [] 1051 customPortResults = []
874 customPortScanError = message 1052 customPortScanError = message
@@ -904,8 +1082,9 @@ final class DomainViewModel {
904 } 1082 }
905 } 1083 }
906 1084
907 private func saveHistoryEntry(replaceLatest: Bool) { 1085 @discardableResult
908 guard !searchedDomain.isEmpty else { return } 1086 private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? {
1087 guard !searchedDomain.isEmpty else { return nil }
909 1088
910 let trackedDomainID = trackedDomain(for: searchedDomain)?.id 1089 let trackedDomainID = trackedDomain(for: searchedDomain)?.id
911 let timestamp = Date() 1090 let timestamp = Date()
@@ -969,6 +1148,7 @@ final class DomainViewModel {
969 changeSummary: changeSummary 1148 changeSummary: changeSummary
970 ) 1149 )
971 persistHistory() 1150 persistHistory()
1151 return entry
972 } 1152 }
973 1153
974 private func persistHistory() { 1154 private func persistHistory() {
@@ -1027,7 +1207,13 @@ final class DomainViewModel {
1027 } 1207 }
1028 1208
1029 private func normalizedDomain(_ domain: String) -> String { 1209 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() ?? ""
1031 } 1217 }
1032 1218
1033 private func linkTrackedDomainHistory(for domain: String) { 1219 private func linkTrackedDomainHistory(for domain: String) {
@@ -1064,6 +1250,110 @@ final class DomainViewModel {
1064 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) 1250 UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
1065 } 1251 }
1066 1252
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
1067 private func clearLookupState() { 1357 private func clearLookupState() {
1068 dnsSections = [] 1358 dnsSections = []
1069 dnsError = nil 1359 dnsError = nil
@@ -1139,6 +1429,12 @@ final class DomainViewModel {
1139 .map { $0 } 1429 .map { $0 }
1140 } 1430 }
1141 1431
1432 func latestSnapshots(for domains: [TrackedDomain]) -> [HistoryEntry] {
1433 domains.compactMap { trackedDomain in
1434 recentSnapshots(for: trackedDomain, limit: 1).first
1435 }
1436 }
1437
1142 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] { 1438 func diffSectionsForLatestSnapshots(of trackedDomain: TrackedDomain) -> [DomainDiffSection] {
1143 let snapshots = recentSnapshots(for: trackedDomain, limit: 2) 1439 let snapshots = recentSnapshots(for: trackedDomain, limit: 2)
1144 guard snapshots.count == 2 else { return [] } 1440 guard snapshots.count == 2 else { return [] }
@@ -1161,6 +1457,62 @@ final class DomainViewModel {
1161 return siblings.first?.snapshot 1457 return siblings.first?.snapshot
1162 } 1458 }
1163 1459
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
1164 private static func loadTrackedDomains() -> [TrackedDomain] { 1516 private static func loadTrackedDomains() -> [TrackedDomain] {
1165 let defaults = UserDefaults.standard 1517 let defaults = UserDefaults.standard
1166 let decoder = JSONDecoder() 1518 let decoder = JSONDecoder()
@@ -1196,6 +1548,47 @@ final class DomainViewModel {
1196 } 1548 }
1197 } 1549 }
1198 1550
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
1199 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { 1592 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
1200 [ 1593 [
1201 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), 1594 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
@@ -1369,6 +1762,64 @@ final class DomainViewModel {
1369 } 1762 }
1370 } 1763 }
1371 1764
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
1372 static func formatExportText( 1823 static func formatExportText(
1373 from snapshot: LookupSnapshot, 1824 from snapshot: LookupSnapshot,
1374 trackedDomain: TrackedDomain?, 1825 trackedDomain: TrackedDomain?,
@@ -1404,8 +1855,9 @@ final class DomainViewModel {
1404 lines.append(" \(item.label): \(item.value)") 1855 lines.append(" \(item.label): \(item.value)")
1405 } 1856 }
1406 if let changeSummary { 1857 if let changeSummary {
1407 lines.append(" Change Status: \(changeSummary.hasChanges ? "Changed" : "Unchanged")") 1858 lines.append(" Change Summary: \(changeSummary.hasChanges ? "Changed" : "Unchanged")")
1408 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))") 1859 lines.append(" Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))")
1860 lines.append(" Compared At: \(exportDateFormatter.string(from: changeSummary.generatedAt))")
1409 } 1861 }
1410 } 1862 }
1411 1863
@@ -1429,7 +1881,7 @@ final class DomainViewModel {
1429 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) { 1881 for section in diffSections where section.items.contains(where: { $0.changeType != .unchanged }) {
1430 lines.append(" \(section.title)") 1882 lines.append(" \(section.title)")
1431 for item in section.items where item.changeType != .unchanged { 1883 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")")
1433 } 1885 }
1434 } 1886 }
1435 } 1887 }
@@ -1596,6 +2048,14 @@ final class DomainViewModel {
1596 snapshot.redirectChain.last?.url 2048 snapshot.redirectChain.last?.url
1597 } 2049 }
1598 2050
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
1599 private static func httpsSummary(from snapshot: LookupSnapshot) -> String { 2059 private static func httpsSummary(from snapshot: LookupSnapshot) -> String {
1600 if snapshot.sslInfo != nil { 2060 if snapshot.sslInfo != nil {
1601 return "Valid" 2061 return "Valid"
@@ -1665,6 +2125,17 @@ final class DomainViewModel {
1665 formatter.timeStyle = .short 2125 formatter.timeStyle = .short
1666 return formatter 2126 return formatter
1667 }() 2127 }()
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 }
1668} 2139}
1669 2140
1670private extension String { 2141private 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 {
14 14
15 var body: some View { 15 var body: some View {
16 List { 16 List {
17 if viewModel.history.isEmpty { 17 if viewModel.filteredHistory.isEmpty {
18 Text("No lookup history") 18 Text("No lookup history")
19 .font(.system(.callout, design: .monospaced)) 19 .font(.system(.callout, design: .monospaced))
20 .foregroundStyle(.secondary) 20 .foregroundStyle(.secondary)
21 .listRowBackground(Color(.systemGray6).opacity(0.5)) 21 .listRowBackground(Color(.systemGray6).opacity(0.5))
22 } else { 22 } else {
23 ForEach(viewModel.history) { entry in 23 ForEach(viewModel.filteredHistory) { entry in
24 NavigationLink { 24 NavigationLink {
25 HistoryDetailView(viewModel: viewModel, entry: entry) 25 HistoryDetailView(viewModel: viewModel, entry: entry)
26 } label: { 26 } label: {
@@ -28,6 +28,11 @@ struct HistoryView: View {
28 Text(entry.domain) 28 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced)) 29 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary) 30 .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 }
31 HStack(spacing: 8) { 36 HStack(spacing: 8) {
32 Text(dateFormatter.string(from: entry.timestamp)) 37 Text(dateFormatter.string(from: entry.timestamp))
33 Text("Snapshot") 38 Text("Snapshot")
@@ -42,23 +47,40 @@ struct HistoryView: View {
42 } 47 }
43 .listRowBackground(Color(.systemGray6).opacity(0.5)) 48 .listRowBackground(Color(.systemGray6).opacity(0.5))
44 } 49 }
45 .onDelete { offsets in 50 .onDelete(perform: deleteFilteredHistoryEntries)
46 viewModel.removeHistoryEntries(at: offsets)
47 }
48 } 51 }
49 } 52 }
50 .scrollContentBackground(.hidden) 53 .scrollContentBackground(.hidden)
51 .background(Color.black) 54 .background(Color.black)
52 .navigationTitle("History") 55 .navigationTitle("History")
56 .searchable(text: $viewModel.historySearchText, prompt: "Search domains")
53 .toolbar { 57 .toolbar {
54 if !viewModel.history.isEmpty { 58 if !viewModel.history.isEmpty {
55 ToolbarItemGroup(placement: .topBarTrailing) { 59 ToolbarItemGroup(placement: .topBarTrailing) {
56 Menu { 60 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
57 Button("Clear All", role: .destructive) { 79 Button("Clear All", role: .destructive) {
58 showClearAllConfirmation = true 80 showClearAllConfirmation = true
59 } 81 }
60 } label: { 82 } label: {
61 Image(systemName: "ellipsis.circle") 83 Image(systemName: "line.3.horizontal.decrease.circle")
62 } 84 }
63 85
64 EditButton() 86 EditButton()
@@ -78,6 +100,11 @@ struct HistoryView: View {
78 } 100 }
79 .preferredColorScheme(.dark) 101 .preferredColorScheme(.dark)
80 } 102 }
103
104 private func deleteFilteredHistoryEntries(at offsets: IndexSet) {
105 let ids = offsets.map { viewModel.filteredHistory[$0].id }
106 viewModel.removeHistoryEntries(withIDs: ids)
107 }
81} 108}
82 109
83struct HistoryDetailView: View { 110struct HistoryDetailView: View {
DomainDig/Models.swift +141
@@ -54,6 +54,147 @@ struct DomainChangeSummary: Codable, Equatable {
54 let generatedAt: Date 54 let generatedAt: Date
55} 55}
56 56
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
57enum PremiumCapability: String, Codable { 198enum PremiumCapability: String, Codable {
58 case unlimitedTrackedDomains 199 case unlimitedTrackedDomains
59 case automatedMonitoring 200 case automatedMonitoring
DomainDig/PremiumAccessService.swift +3 −15
@@ -1,27 +1,15 @@
1import Foundation 1import Foundation
2 2
3enum PremiumAccessService { 3enum PremiumAccessService {
4 static let freeTrackedDomainLimit = 3
5
6 static func hasAccess(to capability: PremiumCapability) -> Bool { 4 static func hasAccess(to capability: PremiumCapability) -> Bool {
7 switch capability { 5 true
8 case .unlimitedTrackedDomains,
9 .automatedMonitoring,
10 .pushAlerts,
11 .batchTracking,
12 .advancedExports:
13 return false
14 }
15 } 6 }
16 7
17 static func trackedDomainLimitMessage(currentCount: Int) -> String? { 8 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
18 guard currentCount >= freeTrackedDomainLimit, !hasAccess(to: .unlimitedTrackedDomains) else { 9 nil
19 return nil
20 }
21 return "More tracked domains will be available in a future Pro upgrade."
22 } 10 }
23 11
24 static func canAddTrackedDomain(currentCount: Int) -> Bool { 12 static func canAddTrackedDomain(currentCount: Int) -> Bool {
25 hasAccess(to: .unlimitedTrackedDomains) || currentCount < freeTrackedDomainLimit 13 true
26 } 14 }
27} 15}
DomainDig/WatchlistView.swift +73 −5
@@ -6,7 +6,27 @@ struct WatchlistView: View {
6 6
7 var body: some View { 7 var body: some View {
8 List { 8 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 {
10 Section { 30 Section {
11 VStack(alignment: .leading, spacing: 8) { 31 VStack(alignment: .leading, spacing: 8) {
12 Text("No tracked domains yet") 32 Text("No tracked domains yet")
@@ -30,7 +50,7 @@ struct WatchlistView: View {
30 } 50 }
31 51
32 Section { 52 Section {
33 ForEach(viewModel.sortedTrackedDomains) { trackedDomain in 53 ForEach(viewModel.filteredTrackedDomains) { trackedDomain in
34 NavigationLink { 54 NavigationLink {
35 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) 55 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
36 } label: { 56 } label: {
@@ -83,7 +103,7 @@ struct WatchlistView: View {
83 } 103 }
84 .listRowBackground(Color(.systemGray6).opacity(0.5)) 104 .listRowBackground(Color(.systemGray6).opacity(0.5))
85 } 105 }
86 .onDelete(perform: viewModel.deleteTrackedDomains) 106 .onDelete(perform: deleteFilteredTrackedDomains)
87 } header: { 107 } header: {
88 Text("Tracked Domains") 108 Text("Tracked Domains")
89 } 109 }
@@ -92,9 +112,40 @@ struct WatchlistView: View {
92 .scrollContentBackground(.hidden) 112 .scrollContentBackground(.hidden)
93 .background(Color.black) 113 .background(Color.black)
94 .navigationTitle("Watchlist") 114 .navigationTitle("Watchlist")
115 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
95 .toolbar { 116 .toolbar {
96 if !viewModel.sortedTrackedDomains.isEmpty { 117 if !viewModel.filteredTrackedDomains.isEmpty {
97 EditButton() 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 }
98 } 149 }
99 } 150 }
100 .onChange(of: viewModel.rerunNavigationToken) { _, _ in 151 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
@@ -102,6 +153,23 @@ struct WatchlistView: View {
102 } 153 }
103 .preferredColorScheme(.dark) 154 .preferredColorScheme(.dark)
104 } 155 }
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 }
105} 173}
106 174
107struct WatchlistRowView: View { 175struct WatchlistRowView: View {