Commit 37b02e5ee3

37b02e5ee360d61751951b77b7b556165a3af1fc

parent: ceac99c05f

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 16:46 UTC

feat(v3.0.0): unify platform architecture and introduce feature tiers

* consolidate DomainReport as canonical data model
* add feature tier system (free/pro/data+ scaffolding)
* apply clean feature gating across workflows and tracking
* refactor app structure for maintainability
* standardize navigation and settings
* add data lifecycle controls
* ensure consistency across UI, export, and CLI
* stabilize internal inspection API

Layout: unified · split

Docs/ARCHITECTURE.md added +48
@@ -0,0 +1,48 @@
1# DomainDig v3.0.0 Architecture
2
3## Overview
4
5DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`.
6
7Inspection flow:
8
91. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`.
102. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`.
113. UI, exports, and CLI rendering derive from `DomainReport`.
12
13`LookupSnapshot` remains an internal collection and persistence shape. `DomainReport` is the stable presentation and export contract.
14
15## Canonical Report Lifecycle
16
17- `LookupRuntime` coordinates section services.
18- `DomainInspectionService` normalizes failures, provenance, cache state, and section metadata.
19- `DomainReportBuilder` adds summaries, insights, risk scoring, change analysis, workflow context, and report metadata.
20- `DomainReportExporter` renders TXT, CSV, and JSON from the same report payload.
21- `DomainDigCLI` prints exporter output directly so CLI output matches the app.
22
23## Feature Tiers
24
25The app now uses `FeatureAccessService` as the single feature gating surface.
26
27- `Free`: single lookup, basic history, limited tracking
28- `Pro`: workflows, batch operations, advanced exports
29- `Data+`: future historical datasets and extended enrichment
30
31Current release behavior is static scaffolding only. There are no purchases, backend checks, or remote entitlements.
32
33## Data Boundaries
34
35- Inspection services: network collection only
36- `DomainReportBuilder`: canonical model assembly
37- `FeatureAccessService`: tier and capability checks
38- `DomainViewModel`: UI orchestration, persistence, batch coordination
39- Views: rendering and interaction only
40
41## Adding a New Data Source
42
431. Add the raw collection call to `LookupRuntime`.
442. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures.
453. Extend `LookupSnapshot` only if the raw result must persist.
464. Add the summarized representation to `DomainReportBuilder`.
475. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI.
486. Render the new summary in SwiftUI using `DomainReport` fields.
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 static var current: String { 4 static var current: String {
5 "2.9.0" 5 "3.0.0"
6 } 6 }
7} 7}
DomainDig/ContentView.swift +146 −79
@@ -24,7 +24,7 @@ private struct WorkflowNavigationTarget: Hashable {
24 24
25struct ContentView: View { 25struct ContentView: View {
26 @Environment(\.appDensity) private var appDensity 26 @Environment(\.appDensity) private var appDensity
27 @State private var viewModel = DomainViewModel() 27 @Bindable var viewModel: DomainViewModel
28 @State private var navigationPath = NavigationPath() 28 @State private var navigationPath = NavigationPath()
29 @FocusState private var domainFieldFocused: Bool 29 @FocusState private var domainFieldFocused: Bool
30 @State private var customPortInput = "" 30 @State private var customPortInput = ""
@@ -32,6 +32,7 @@ struct ContentView: View {
32 @State private var trackingNoteDraft = "" 32 @State private var trackingNoteDraft = ""
33 @State private var editingTrackedDomain: TrackedDomain? 33 @State private var editingTrackedDomain: TrackedDomain?
34 @State private var showTrackLimitAlert = false 34 @State private var showTrackLimitAlert = false
35 @State private var featureGateMessage: String?
35 @State private var inputMode: LookupInputMode = .single 36 @State private var inputMode: LookupInputMode = .single
36 @State private var collapsedSections: Set<ResultSection> = [.network] 37 @State private var collapsedSections: Set<ResultSection> = [.network]
37 @State private var showingCurrentDomainWorkflowSheet = false 38 @State private var showingCurrentDomainWorkflowSheet = false
@@ -222,7 +223,7 @@ struct ContentView: View {
222 .toolbarColorScheme(.dark, for: .navigationBar) 223 .toolbarColorScheme(.dark, for: .navigationBar)
223 .preferredColorScheme(.dark) 224 .preferredColorScheme(.dark)
224 .toolbar { 225 .toolbar {
225 ToolbarItemGroup(placement: .topBarTrailing) { 226 ToolbarItem(placement: .topBarTrailing) {
226 if viewModel.hasRun { 227 if viewModel.hasRun {
227 Button { 228 Button {
228 viewModel.reset() 229 viewModel.reset()
@@ -231,40 +232,6 @@ struct ContentView: View {
231 .foregroundStyle(.secondary) 232 .foregroundStyle(.secondary)
232 } 233 }
233 } 234 }
234 NavigationLink {
235 WatchlistView(viewModel: viewModel)
236 } label: {
237 Image(systemName: "eye")
238 .foregroundStyle(.secondary)
239 }
240 Menu {
241 NavigationLink {
242 HistoryView(viewModel: viewModel)
243 } label: {
244 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
245 }
246
247 NavigationLink {
248 SavedDomainsView(viewModel: viewModel)
249 } label: {
250 Label("Saved Domains", systemImage: "bookmark")
251 }
252
253 NavigationLink {
254 WorkflowsView(viewModel: viewModel)
255 } label: {
256 Label("Workflows", systemImage: "square.stack.3d.down.right")
257 }
258
259 NavigationLink {
260 SettingsView(viewModel: viewModel)
261 } label: {
262 Label("Settings", systemImage: "gearshape")
263 }
264 } label: {
265 Image(systemName: "ellipsis.circle")
266 .foregroundStyle(.secondary)
267 }
268 } 235 }
269 } 236 }
270 .navigationDestination(for: WorkflowNavigationTarget.self) { target in 237 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
@@ -284,7 +251,19 @@ struct ContentView: View {
284 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { 251 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
285 Button("OK", role: .cancel) {} 252 Button("OK", role: .cancel) {}
286 } message: { 253 } message: {
287 Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.") 254 Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.")
255 }
256 .alert("Feature unavailable", isPresented: Binding(
257 get: { featureGateMessage != nil },
258 set: { newValue in
259 if !newValue {
260 featureGateMessage = nil
261 }
262 }
263 )) {
264 Button("OK", role: .cancel) {}
265 } message: {
266 Text(featureGateMessage ?? "")
288 } 267 }
289 .sheet(item: $editingTrackedDomain) { trackedDomain in 268 .sheet(item: $editingTrackedDomain) { trackedDomain in
290 NavigationStack { 269 NavigationStack {
@@ -361,37 +340,44 @@ struct ContentView: View {
361 .buttonStyle(.borderedProminent) 340 .buttonStyle(.borderedProminent)
362 .disabled(viewModel.trimmedDomain.isEmpty) 341 .disabled(viewModel.trimmedDomain.isEmpty)
363 } else { 342 } else {
364 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { 343 if FeatureAccessService.hasAccess(to: .batchOperations) {
365 Text("Paste domains separated by new lines or commas.") 344 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
366 .font(appDensity.font(.caption)) 345 Text("Paste domains separated by new lines or commas.")
367 .foregroundStyle(.secondary) 346 .font(appDensity.font(.caption))
347 .foregroundStyle(.secondary)
368 348
369 TextField( 349 TextField(
370 "example.com\napple.com, openai.com", 350 "example.com\napple.com, openai.com",
371 text: $viewModel.bulkInput, 351 text: $viewModel.bulkInput,
372 axis: .vertical 352 axis: .vertical
373 ) 353 )
374 .font(appDensity.font(.body)) 354 .font(appDensity.font(.body))
375 .textInputAutocapitalization(.never) 355 .textInputAutocapitalization(.never)
376 .autocorrectionDisabled() 356 .autocorrectionDisabled()
377 .keyboardType(.URL) 357 .keyboardType(.URL)
378 .lineLimit(4...10) 358 .lineLimit(4...10)
379 .padding(.horizontal, 12) 359 .padding(.horizontal, 12)
380 .padding(.vertical, appDensity.metrics.controlVerticalPadding) 360 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
381 .background(Color(.systemGray6)) 361 .background(Color(.systemGray6))
382 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) 362 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
383 363
384 Button { 364 Button {
385 domainFieldFocused = false 365 domainFieldFocused = false
386 viewModel.runBulkLookup() 366 viewModel.runBulkLookup()
387 } label: { 367 } label: {
388 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") 368 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
389 .font(appDensity.font(.headline, design: .default, weight: .semibold)) 369 .font(appDensity.font(.headline, design: .default, weight: .semibold))
390 .frame(maxWidth: .infinity) 370 .frame(maxWidth: .infinity)
391 .frame(minHeight: appDensity.metrics.controlMinHeight) 371 .frame(minHeight: appDensity.metrics.controlMinHeight)
372 }
373 .buttonStyle(.borderedProminent)
374 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
392 } 375 }
393 .buttonStyle(.borderedProminent) 376 } else {
394 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) 377 lockedFeatureCard(
378 title: "Batch Operations",
379 message: FeatureAccessService.upgradeMessage(for: .batchOperations)
380 )
395 } 381 }
396 } 382 }
397 } 383 }
@@ -411,7 +397,11 @@ struct ContentView: View {
411 } 397 }
412 } 398 }
413 Button("Add to workflow") { 399 Button("Add to workflow") {
414 showingCurrentDomainWorkflowSheet = true 400 if FeatureAccessService.hasAccess(to: .workflows) {
401 showingCurrentDomainWorkflowSheet = true
402 } else {
403 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
404 }
415 } 405 }
416 Button("Copy report JSON") { 406 Button("Copy report JSON") {
417 guard let json = viewModel.exportJSONString() else { return } 407 guard let json = viewModel.exportJSONString() else { return }
@@ -437,11 +427,18 @@ struct ContentView: View {
437 Button("Export TXT") { 427 Button("Export TXT") {
438 shareSingleResults(format: .text) 428 shareSingleResults(format: .text)
439 } 429 }
440 Button("Export CSV") { 430 if FeatureAccessService.hasAccess(to: .advancedExports) {
441 shareSingleResults(format: .csv) 431 Button("Export CSV") {
442 } 432 shareSingleResults(format: .csv)
443 Button("Export JSON") { 433 }
444 shareSingleResults(format: .json) 434 Button("Export JSON") {
435 shareSingleResults(format: .json)
436 }
437 } else {
438 Button("CSV Export • Available in Pro") {}
439 .disabled(true)
440 Button("JSON Export • Available in Pro") {}
441 .disabled(true)
445 } 442 }
446 } label: { 443 } label: {
447 Image(systemName: "square.and.arrow.up") 444 Image(systemName: "square.and.arrow.up")
@@ -466,17 +463,28 @@ struct ContentView: View {
466 if !viewModel.currentBatchResultEntries.isEmpty { 463 if !viewModel.currentBatchResultEntries.isEmpty {
467 Menu { 464 Menu {
468 Button("Add to Workflow") { 465 Button("Add to Workflow") {
469 showingBatchWorkflowSheet = true 466 if FeatureAccessService.hasAccess(to: .workflows) {
467 showingBatchWorkflowSheet = true
468 } else {
469 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
470 }
470 } 471 }
471 Divider() 472 Divider()
472 Button("Export Batch TXT") { 473 Button("Export Batch TXT") {
473 shareBatchResults(format: .text) 474 shareBatchResults(format: .text)
474 } 475 }
475 Button("Export Batch CSV") { 476 if FeatureAccessService.hasAccess(to: .advancedExports) {
476 shareBatchResults(format: .csv) 477 Button("Export Batch CSV") {
477 } 478 shareBatchResults(format: .csv)
478 Button("Export Batch JSON") { 479 }
479 shareBatchResults(format: .json) 480 Button("Export Batch JSON") {
481 shareBatchResults(format: .json)
482 }
483 } else {
484 Button("Batch CSV • Available in Pro") {}
485 .disabled(true)
486 Button("Batch JSON • Available in Pro") {}
487 .disabled(true)
480 } 488 }
481 } label: { 489 } label: {
482 Label("Export", systemImage: "square.and.arrow.up") 490 Label("Export", systemImage: "square.and.arrow.up")
@@ -534,6 +542,20 @@ struct ContentView: View {
534 } 542 }
535 } 543 }
536 544
545 private func lockedFeatureCard(title: String, message: String) -> some View {
546 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
547 Text(title)
548 .font(appDensity.font(.headline, design: .default, weight: .semibold))
549 Text(message)
550 .font(appDensity.font(.callout, design: .default))
551 .foregroundStyle(.secondary)
552 }
553 .frame(maxWidth: .infinity, alignment: .leading)
554 .padding(appDensity.metrics.cardPadding)
555 .background(Color(.systemGray6))
556 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
557 }
558
537 private func parsedCustomPorts(from input: String) -> [UInt16] { 559 private func parsedCustomPorts(from input: String) -> [UInt16] {
538 let parts = input.split(separator: ",", omittingEmptySubsequences: true) 560 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
539 var seen = Set<UInt16>() 561 var seen = Set<UInt16>()
@@ -2140,7 +2162,7 @@ private extension String {
2140 } 2162 }
2141} 2163}
2142 2164
2143private struct SettingsView: View { 2165struct SettingsView: View {
2144 @Environment(\.appDensity) private var appDensity 2166 @Environment(\.appDensity) private var appDensity
2145 @Bindable var viewModel: DomainViewModel 2167 @Bindable var viewModel: DomainViewModel
2146 @AppStorage(DNSResolverOption.userDefaultsKey) 2168 @AppStorage(DNSResolverOption.userDefaultsKey)
@@ -2152,6 +2174,8 @@ private struct SettingsView: View {
2152 @State private var customResolverURL = DNSResolverOption.defaultURLString 2174 @State private var customResolverURL = DNSResolverOption.defaultURLString
2153 @State private var showClearHistoryConfirmation = false 2175 @State private var showClearHistoryConfirmation = false
2154 @State private var showClearCacheConfirmation = false 2176 @State private var showClearCacheConfirmation = false
2177 @State private var showClearWorkflowsConfirmation = false
2178 @State private var showClearTrackedDomainsConfirmation = false
2155 2179
2156 private var customResolverError: String? { 2180 private var customResolverError: String? {
2157 guard resolverOption == .custom else { 2181 guard resolverOption == .custom else {
@@ -2199,11 +2223,38 @@ private struct SettingsView: View {
2199 Button("Clear Cache", role: .destructive) { 2223 Button("Clear Cache", role: .destructive) {
2200 showClearCacheConfirmation = true 2224 showClearCacheConfirmation = true
2201 } 2225 }
2226
2227 Button("Clear Workflows", role: .destructive) {
2228 showClearWorkflowsConfirmation = true
2229 }
2230
2231 Button("Clear Tracked Domains", role: .destructive) {
2232 showClearTrackedDomainsConfirmation = true
2233 }
2234 }
2235
2236 Section("Features") {
2237 LabeledContent("Tier", value: FeatureAccessService.currentTier.title)
2238
2239 if FeatureAccessService.enabledFeatureLabels().isEmpty {
2240 Text("No features enabled.")
2241 .font(appDensity.font(.caption, design: .default))
2242 .foregroundStyle(.secondary)
2243 } else {
2244 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
2245 Text(label)
2246 }
2247 }
2248
2249 Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.")
2250 .font(appDensity.font(.caption, design: .default))
2251 .foregroundStyle(.secondary)
2202 } 2252 }
2203 2253
2204 Section("About") { 2254 Section("About") {
2205 LabeledContent("Version", value: appVersion) 2255 LabeledContent("Version", value: appVersion)
2206 LabeledContent("Storage", value: "Local-only") 2256 LabeledContent("Storage", value: "Local-only")
2257 LabeledContent("Report Schema", value: "3.0.0")
2207 } 2258 }
2208 } 2259 }
2209 .navigationTitle("Settings") 2260 .navigationTitle("Settings")
@@ -2223,6 +2274,22 @@ private struct SettingsView: View {
2223 } message: { 2274 } message: {
2224 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") 2275 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
2225 } 2276 }
2277 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
2278 Button("Clear", role: .destructive) {
2279 viewModel.clearWorkflows()
2280 }
2281 Button("Cancel", role: .cancel) {}
2282 } message: {
2283 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
2284 }
2285 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
2286 Button("Clear", role: .destructive) {
2287 viewModel.clearTrackedDomains()
2288 }
2289 Button("Cancel", role: .cancel) {}
2290 } message: {
2291 Text("This removes the watchlist only. History and workflows stay intact.")
2292 }
2226 .onAppear { 2293 .onAppear {
2227 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) 2294 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
2228 resolverOption = DNSResolverOption.option(for: currentResolverURL) 2295 resolverOption = DNSResolverOption.option(for: currentResolverURL)
@@ -2247,5 +2314,5 @@ private struct SettingsView: View {
2247} 2314}
2248 2315
2249#Preview { 2316#Preview {
2250 ContentView() 2317 ContentView(viewModel: DomainViewModel())
2251} 2318}
DomainDig/DataAccessService.swift +10 −1
@@ -2,6 +2,15 @@ import Foundation
2 2
3enum DataAccessService { 3enum DataAccessService {
4 static func hasAccess(to capability: DataCapability) -> Bool { 4 static func hasAccess(to capability: DataCapability) -> Bool {
5 false 5 switch capability {
6 case .ownershipHistory:
7 return FeatureAccessService.hasAccess(to: .ownershipHistory)
8 case .dnsHistory:
9 return FeatureAccessService.hasAccess(to: .dnsHistory)
10 case .extendedSubdomains:
11 return FeatureAccessService.hasAccess(to: .extendedSubdomains)
12 case .domainPricing:
13 return false
14 }
6 } 15 }
7} 16}
DomainDig/DomainDigApp.swift +2 −1
@@ -10,6 +10,7 @@ import SwiftUI
10@main 10@main
11struct DomainDigApp: App { 11struct DomainDigApp: App {
12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue 12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13 @State private var viewModel = DomainViewModel()
13 14
14 init() { 15 init() {
15 LocalNotificationService.shared.configureForegroundPresentation() 16 LocalNotificationService.shared.configureForegroundPresentation()
@@ -17,7 +18,7 @@ struct DomainDigApp: App {
17 18
18 var body: some Scene { 19 var body: some Scene {
19 WindowGroup { 20 WindowGroup {
20 ContentView() 21 RootTabView(viewModel: viewModel)
21 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) 22 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
22 } 23 }
23 } 24 }
DomainDig/DomainViewModel.swift +42 −6
@@ -369,11 +369,11 @@ final class DomainViewModel {
369 } 369 }
370 370
371 var trackingLimitMessage: String? { 371 var trackingLimitMessage: String? {
372 nil 372 FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count)
373 } 373 }
374 374
375 var canTrackCurrentDomain: Bool { 375 var canTrackCurrentDomain: Bool {
376 true 376 FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
377 } 377 }
378 378
379 var resolverDisplayName: String { 379 var resolverDisplayName: String {
@@ -665,6 +665,18 @@ final class DomainViewModel {
665 } 665 }
666 } 666 }
667 667
668 func clearWorkflows() {
669 workflows.removeAll()
670 latestWorkflowRunSummary = nil
671 persistWorkflows()
672 }
673
674 func clearTrackedDomains() {
675 trackedDomains.removeAll()
676 refreshingTrackedDomainID = nil
677 persistTrackedDomains()
678 }
679
668 func clearRecentSearches() { 680 func clearRecentSearches() {
669 recentSearches.removeAll() 681 recentSearches.removeAll()
670 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) 682 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -725,10 +737,12 @@ final class DomainViewModel {
725 func runBulkLookup() { 737 func runBulkLookup() {
726 let domains = parsedDomains(from: bulkInput) 738 let domains = parsedDomains(from: bulkInput)
727 guard !domains.isEmpty else { return } 739 guard !domains.isEmpty else { return }
740 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
728 startBatchLookup(domains: domains, source: .manual) 741 startBatchLookup(domains: domains, source: .manual)
729 } 742 }
730 743
731 func refreshAllTrackedDomains() { 744 func refreshAllTrackedDomains() {
745 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
732 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) 746 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
733 } 747 }
734 748
@@ -777,6 +791,7 @@ final class DomainViewModel {
777 let normalizedDomains = normalizedDomains(domains) 791 let normalizedDomains = normalizedDomains(domains)
778 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) 792 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
779 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } 793 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
794 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
780 795
781 let workflow = DomainWorkflow( 796 let workflow = DomainWorkflow(
782 name: trimmedName, 797 name: trimmedName,
@@ -840,6 +855,7 @@ final class DomainViewModel {
840 855
841 func runWorkflow(_ workflow: DomainWorkflow) { 856 func runWorkflow(_ workflow: DomainWorkflow) {
842 guard !workflow.domains.isEmpty else { return } 857 guard !workflow.domains.isEmpty else { return }
858 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
843 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) 859 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
844 } 860 }
845 861
@@ -2124,14 +2140,23 @@ final class DomainViewModel {
2124 } 2140 }
2125 2141
2126 private func currentBatchReports() -> [DomainReport] { 2142 private func currentBatchReports() -> [DomainReport] {
2127 currentBatchResultEntries.map(report(for:)) 2143 currentBatchResultEntries.map { entry in
2144 report(for: entry, workflowContext: activeWorkflowContext)
2145 }
2128 } 2146 }
2129 2147
2130 private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { 2148 private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
2131 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results 2149 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
2132 return filteredResults.compactMap { result in 2150 return filteredResults.compactMap { result in
2133 guard let entry = historyEntry(for: result) else { return nil } 2151 guard let entry = historyEntry(for: result) else { return nil }
2134 return report(for: entry) 2152 return report(
2153 for: entry,
2154 workflowContext: DomainWorkflowContext(
2155 workflowID: summary.workflowID,
2156 workflowName: summary.workflowName,
2157 source: "workflow"
2158 )
2159 )
2135 } 2160 }
2136 } 2161 }
2137 2162
@@ -2150,8 +2175,19 @@ final class DomainViewModel {
2150 } 2175 }
2151 } 2176 }
2152 2177
2153 private func report(for entry: HistoryEntry) -> DomainReport { 2178 private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
2154 reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) 2179 reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext)
2180 }
2181
2182 private var activeWorkflowContext: DomainWorkflowContext? {
2183 guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else {
2184 return nil
2185 }
2186 return DomainWorkflowContext(
2187 workflowID: activeWorkflowRunID,
2188 workflowName: activeWorkflowRunName,
2189 source: "workflow"
2190 )
2155 } 2191 }
2156 2192
2157 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { 2193 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
DomainDig/FeatureAccessService.swift added +160
@@ -0,0 +1,160 @@
1import Foundation
2
3enum FeatureTier: String, Codable, CaseIterable, Identifiable {
4 case free
5 case pro
6 case dataPlus
7
8 var id: String { rawValue }
9
10 var title: String {
11 switch self {
12 case .free:
13 return "Free"
14 case .pro:
15 return "Pro"
16 case .dataPlus:
17 return "Data+"
18 }
19 }
20}
21
22enum FeatureCapability: String, CaseIterable, Identifiable {
23 case singleLookup
24 case basicHistory
25 case limitedTracking
26 case workflows
27 case batchOperations
28 case advancedExports
29 case ownershipHistory
30 case dnsHistory
31 case extendedSubdomains
32
33 var id: String { rawValue }
34
35 var title: String {
36 switch self {
37 case .singleLookup:
38 return "Single lookup"
39 case .basicHistory:
40 return "Basic history"
41 case .limitedTracking:
42 return "Limited tracking"
43 case .workflows:
44 return "Workflows"
45 case .batchOperations:
46 return "Batch operations"
47 case .advancedExports:
48 return "Advanced exports"
49 case .ownershipHistory:
50 return "Ownership history"
51 case .dnsHistory:
52 return "DNS history"
53 case .extendedSubdomains:
54 return "Extended subdomains"
55 }
56 }
57}
58
59struct FeatureEntitlements: Equatable {
60 let tier: FeatureTier
61 let capabilities: Set<FeatureCapability>
62 let trackedDomainLimit: Int
63 let workflowLimit: Int?
64 let batchSizeLimit: Int?
65}
66
67enum FeatureAccessService {
68 static let currentTier: FeatureTier = .free
69
70 static var entitlements: FeatureEntitlements {
71 switch currentTier {
72 case .free:
73 return FeatureEntitlements(
74 tier: .free,
75 capabilities: [.singleLookup, .basicHistory, .limitedTracking],
76 trackedDomainLimit: 3,
77 workflowLimit: 0,
78 batchSizeLimit: 0
79 )
80 case .pro:
81 return FeatureEntitlements(
82 tier: .pro,
83 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
84 trackedDomainLimit: 250,
85 workflowLimit: 50,
86 batchSizeLimit: 100
87 )
88 case .dataPlus:
89 return FeatureEntitlements(
90 tier: .dataPlus,
91 capabilities: Set(FeatureCapability.allCases),
92 trackedDomainLimit: 1_000,
93 workflowLimit: 200,
94 batchSizeLimit: 250
95 )
96 }
97 }
98
99 static func hasAccess(to capability: FeatureCapability) -> Bool {
100 entitlements.capabilities.contains(capability)
101 }
102
103 static func canAddTrackedDomain(currentCount: Int) -> Bool {
104 currentCount < entitlements.trackedDomainLimit
105 }
106
107 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
108 guard currentTier == .free else { return nil }
109 return currentCount >= entitlements.trackedDomainLimit
110 ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
111 : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
112 }
113
114 static func canCreateWorkflow(currentCount: Int) -> Bool {
115 guard hasAccess(to: .workflows) else { return false }
116 guard let limit = entitlements.workflowLimit else { return true }
117 return currentCount < limit
118 }
119
120 static func canRunBatch(domainCount: Int) -> Bool {
121 guard hasAccess(to: .batchOperations) else { return false }
122 guard let limit = entitlements.batchSizeLimit else { return true }
123 return domainCount <= limit
124 }
125
126 static func upgradeMessage(for capability: FeatureCapability) -> String {
127 switch capability {
128 case .workflows, .batchOperations, .advancedExports:
129 return "Available in Pro"
130 case .ownershipHistory, .dnsHistory, .extendedSubdomains:
131 return "Available in Data+"
132 case .limitedTracking:
133 return "Tracking is limited on Free"
134 case .singleLookup, .basicHistory:
135 return "Included in Free"
136 }
137 }
138
139 static func workflowLimitMessage(currentCount: Int) -> String? {
140 guard hasAccess(to: .workflows) else {
141 return upgradeMessage(for: .workflows)
142 }
143 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
144 return "Workflow limit reached."
145 }
146
147 static func batchLimitMessage(domainCount: Int) -> String? {
148 guard hasAccess(to: .batchOperations) else {
149 return upgradeMessage(for: .batchOperations)
150 }
151 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
152 return "Batch limit is \(limit) domains on \(currentTier.title)."
153 }
154
155 static func enabledFeatureLabels() -> [String] {
156 FeatureCapability.allCases
157 .filter { hasAccess(to: $0) }
158 .map(\.title)
159 }
160}
DomainDig/PremiumAccessService.swift +12 −3
@@ -2,14 +2,23 @@ import Foundation
2 2
3enum PremiumAccessService { 3enum PremiumAccessService {
4 static func hasAccess(to capability: PremiumCapability) -> Bool { 4 static func hasAccess(to capability: PremiumCapability) -> Bool {
5 true 5 switch capability {
6 case .advancedExports:
7 return FeatureAccessService.hasAccess(to: .advancedExports)
8 case .batchTracking:
9 return FeatureAccessService.hasAccess(to: .batchOperations)
10 case .unlimitedTrackedDomains:
11 return FeatureAccessService.currentTier != .free
12 case .automatedMonitoring, .pushAlerts:
13 return false
14 }
6 } 15 }
7 16
8 static func trackedDomainLimitMessage(currentCount: Int) -> String? { 17 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
9 nil 18 FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount)
10 } 19 }
11 20
12 static func canAddTrackedDomain(currentCount: Int) -> Bool { 21 static func canAddTrackedDomain(currentCount: Int) -> Bool {
13 true 22 FeatureAccessService.canAddTrackedDomain(currentCount: currentCount)
14 } 23 }
15} 24}
DomainDig/RootTabView.swift added +42
@@ -0,0 +1,42 @@
1import SwiftUI
2
3struct RootTabView: View {
4 @Bindable var viewModel: DomainViewModel
5
6 var body: some View {
7 TabView {
8 ContentView(viewModel: viewModel)
9 .tabItem {
10 Label("Inspect", systemImage: "magnifyingglass")
11 }
12
13 NavigationStack {
14 WatchlistView(viewModel: viewModel)
15 }
16 .tabItem {
17 Label("Watchlist", systemImage: "eye")
18 }
19
20 NavigationStack {
21 HistoryView(viewModel: viewModel)
22 }
23 .tabItem {
24 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
25 }
26
27 NavigationStack {
28 WorkflowsView(viewModel: viewModel)
29 }
30 .tabItem {
31 Label("Workflows", systemImage: "square.stack.3d.down.right")
32 }
33
34 NavigationStack {
35 SettingsView(viewModel: viewModel)
36 }
37 .tabItem {
38 Label("Settings", systemImage: "gearshape")
39 }
40 }
41 }
42}
DomainDig/WatchlistView.swift +13 −6
@@ -55,7 +55,7 @@ struct WatchlistView: View {
55 } 55 }
56 .listRowBackground(Color(.systemGray6).opacity(0.5)) 56 .listRowBackground(Color(.systemGray6).opacity(0.5))
57 } else { 57 } else {
58 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { 58 if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
59 Section { 59 Section {
60 Text(limitMessage) 60 Text(limitMessage)
61 .font(appDensity.font(.caption)) 61 .font(appDensity.font(.caption))
@@ -108,12 +108,19 @@ struct WatchlistView: View {
108 shareTrackedDomains(format: .text) 108 shareTrackedDomains(format: .text)
109 } 109 }
110 110
111 Button("Export CSV") { 111 if FeatureAccessService.hasAccess(to: .advancedExports) {
112 shareTrackedDomains(format: .csv) 112 Button("Export CSV") {
113 } 113 shareTrackedDomains(format: .csv)
114 }
114 115
115 Button("Export JSON") { 116 Button("Export JSON") {
116 shareTrackedDomains(format: .json) 117 shareTrackedDomains(format: .json)
118 }
119 } else {
120 Button("CSV Export • Available in Pro") {}
121 .disabled(true)
122 Button("JSON Export • Available in Pro") {}
123 .disabled(true)
117 } 124 }
118 } label: { 125 } label: {
119 Image(systemName: "line.3.horizontal.decrease.circle") 126 Image(systemName: "line.3.horizontal.decrease.circle")
DomainDig/WorkflowsView.swift +96 −63
@@ -15,57 +15,18 @@ struct WorkflowsView: View {
15 15
16 var body: some View { 16 var body: some View {
17 List { 17 List {
18 if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { 18 if FeatureAccessService.hasAccess(to: .workflows) {
19 Section("Workflow Run") { 19 workflowContent
20 VStack(alignment: .leading, spacing: 8) { 20 } else {
21 ProgressView(
22 value: Double(viewModel.batchCompletedCount),
23 total: Double(max(viewModel.batchTotalCount, 1))
24 )
25 .tint(.cyan)
26
27 HStack {
28 Text(viewModel.batchProgressLabel)
29 .font(appDensity.font(.caption))
30 .foregroundStyle(.secondary)
31 Spacer()
32 if viewModel.batchLookupRunning {
33 Button("Cancel") {
34 viewModel.cancelBatchLookup()
35 }
36 .buttonStyle(.bordered)
37 .font(appDensity.font(.caption2))
38 }
39 }
40 }
41 .padding(.vertical, 4)
42 }
43 .listRowBackground(Color(.systemGray6).opacity(0.5))
44 }
45
46 if viewModel.workflows.isEmpty {
47 Section { 21 Section {
48 EmptyStateCardView( 22 EmptyStateCardView(
49 title: "No Workflows Yet", 23 title: "Workflows",
50 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", 24 message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
51 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", 25 suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
52 systemImage: "square.stack.3d.down.right" 26 systemImage: "square.stack.3d.down.right"
53 ) 27 )
54 } 28 }
55 .listRowBackground(Color(.systemGray6).opacity(0.5)) 29 .listRowBackground(Color(.systemGray6).opacity(0.5))
56 } else {
57 ForEach(viewModel.workflows) { workflow in
58 NavigationLink {
59 WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
60 } label: {
61 WorkflowRowView(workflow: workflow)
62 }
63 .listRowBackground(Color(.systemGray6).opacity(0.5))
64 }
65 .onDelete { offsets in
66 let workflows = offsets.map { viewModel.workflows[$0] }
67 workflows.forEach(viewModel.deleteWorkflow)
68 }
69 } 30 }
70 } 31 }
71 .scrollContentBackground(.hidden) 32 .scrollContentBackground(.hidden)
@@ -75,15 +36,17 @@ struct WorkflowsView: View {
75 } 36 }
76 .navigationTitle("Workflows") 37 .navigationTitle("Workflows")
77 .toolbar { 38 .toolbar {
78 ToolbarItemGroup(placement: .topBarTrailing) { 39 if FeatureAccessService.hasAccess(to: .workflows) {
79 Button { 40 ToolbarItemGroup(placement: .topBarTrailing) {
80 showingCreateWorkflow = true 41 Button {
81 } label: { 42 showingCreateWorkflow = true
82 Image(systemName: "plus.circle") 43 } label: {
83 } 44 Image(systemName: "plus.circle")
45 }
84 46
85 if !viewModel.workflows.isEmpty { 47 if !viewModel.workflows.isEmpty {
86 EditButton() 48 EditButton()
49 }
87 } 50 }
88 } 51 }
89 } 52 }
@@ -102,6 +65,62 @@ struct WorkflowsView: View {
102 set: { viewModel.latestWorkflowRunSummary = $0 } 65 set: { viewModel.latestWorkflowRunSummary = $0 }
103 ) 66 )
104 } 67 }
68
69 @ViewBuilder
70 private var workflowContent: some View {
71 if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
72 Section("Workflow Run") {
73 VStack(alignment: .leading, spacing: 8) {
74 ProgressView(
75 value: Double(viewModel.batchCompletedCount),
76 total: Double(max(viewModel.batchTotalCount, 1))
77 )
78 .tint(.cyan)
79
80 HStack {
81 Text(viewModel.batchProgressLabel)
82 .font(appDensity.font(.caption))
83 .foregroundStyle(.secondary)
84 Spacer()
85 if viewModel.batchLookupRunning {
86 Button("Cancel") {
87 viewModel.cancelBatchLookup()
88 }
89 .buttonStyle(.bordered)
90 .font(appDensity.font(.caption2))
91 }
92 }
93 }
94 .padding(.vertical, 4)
95 }
96 .listRowBackground(Color(.systemGray6).opacity(0.5))
97 }
98
99 if viewModel.workflows.isEmpty {
100 Section {
101 EmptyStateCardView(
102 title: "No Workflows Yet",
103 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
104 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
105 systemImage: "square.stack.3d.down.right"
106 )
107 }
108 .listRowBackground(Color(.systemGray6).opacity(0.5))
109 } else {
110 ForEach(viewModel.workflows) { workflow in
111 NavigationLink {
112 WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
113 } label: {
114 WorkflowRowView(workflow: workflow)
115 }
116 .listRowBackground(Color(.systemGray6).opacity(0.5))
117 }
118 .onDelete { offsets in
119 let workflows = offsets.map { viewModel.workflows[$0] }
120 workflows.forEach(viewModel.deleteWorkflow)
121 }
122 }
123 }
105} 124}
106 125
107private struct WorkflowRowView: View { 126private struct WorkflowRowView: View {
@@ -197,11 +216,18 @@ struct WorkflowDetailView: View {
197 Button("Export TXT") { 216 Button("Export TXT") {
198 shareWorkflowResults(format: .text) 217 shareWorkflowResults(format: .text)
199 } 218 }
200 Button("Export CSV") { 219 if FeatureAccessService.hasAccess(to: .advancedExports) {
201 shareWorkflowResults(format: .csv) 220 Button("Export CSV") {
202 } 221 shareWorkflowResults(format: .csv)
203 Button("Export JSON") { 222 }
204 shareWorkflowResults(format: .json) 223 Button("Export JSON") {
224 shareWorkflowResults(format: .json)
225 }
226 } else {
227 Button("CSV Export • Available in Pro") {}
228 .disabled(true)
229 Button("JSON Export • Available in Pro") {}
230 .disabled(true)
205 } 231 }
206 } label: { 232 } label: {
207 Label("Export Results", systemImage: "square.and.arrow.up") 233 Label("Export Results", systemImage: "square.and.arrow.up")
@@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View {
478 Button("Export TXT") { 504 Button("Export TXT") {
479 share(format: .text) 505 share(format: .text)
480 } 506 }
481 Button("Export CSV") { 507 if FeatureAccessService.hasAccess(to: .advancedExports) {
482 share(format: .csv) 508 Button("Export CSV") {
483 } 509 share(format: .csv)
484 Button("Export JSON") { 510 }
485 share(format: .json) 511 Button("Export JSON") {
512 share(format: .json)
513 }
514 } else {
515 Button("CSV Export • Available in Pro") {}
516 .disabled(true)
517 Button("JSON Export • Available in Pro") {}
518 .disabled(true)
486 } 519 }
487 } label: { 520 } label: {
488 Image(systemName: "square.and.arrow.up") 521 Image(systemName: "square.and.arrow.up")
DomainReportBuilder.swift +60 −4
@@ -3,6 +3,7 @@ import Foundation
3struct DomainReport: Codable { 3struct DomainReport: Codable {
4 let domain: String 4 let domain: String
5 let timestamp: Date 5 let timestamp: Date
6 let provenance: DomainReportProvenance
6 let appVersion: String 7 let appVersion: String
7 let resolverDisplayName: String 8 let resolverDisplayName: String
8 let resolverURLString: String 9 let resolverURLString: String
@@ -29,6 +30,34 @@ struct DomainReport: Codable {
29 let riskAssessment: DomainRiskAssessment 30 let riskAssessment: DomainRiskAssessment
30 let insights: [String] 31 let insights: [String]
31 let changeSummary: DomainChangeSummary? 32 let changeSummary: DomainChangeSummary?
33 let workflowContext: DomainWorkflowContext?
34 let metadata: DomainReportMetadata
35}
36
37struct DomainReportProvenance: Codable {
38 let collectedAt: Date
39 let source: LookupResultSource
40 let sections: [LookupSectionKind: SectionProvenance]
41 let dataSources: [String]
42}
43
44struct DomainWorkflowContext: Codable {
45 let workflowID: UUID?
46 let workflowName: String?
47 let source: String
48}
49
50struct DomainReportMetadata: Codable {
51 let schemaVersion: String
52 let resolverDisplayName: String
53 let resolverURLString: String
54 let appVersion: String
55 let cachedSections: [LookupSectionKind]
56 let auditNote: String?
57 let validationIssues: [String]
58 let isPartialSnapshot: Bool
59 let errorDetails: [LookupSectionKind: InspectionFailure]
60 let statusMessage: String?
32} 61}
33 62
34struct DNSResultSummary: Codable { 63struct DNSResultSummary: Codable {
@@ -87,7 +116,11 @@ struct NetworkSummary: Codable {
87} 116}
88 117
89struct DomainReportBuilder { 118struct DomainReportBuilder {
90 func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { 119 func build(
120 from snapshot: LookupSnapshot,
121 previousSnapshot: LookupSnapshot? = nil,
122 workflowContext: DomainWorkflowContext? = nil
123 ) -> DomainReport {
91 let primaryIP = primaryIPAddress(from: snapshot) 124 let primaryIP = primaryIPAddress(from: snapshot)
92 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) 125 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
93 let changeSummary: DomainChangeSummary? 126 let changeSummary: DomainChangeSummary?
@@ -108,6 +141,12 @@ struct DomainReportBuilder {
108 return DomainReport( 141 return DomainReport(
109 domain: snapshot.domain, 142 domain: snapshot.domain,
110 timestamp: snapshot.timestamp, 143 timestamp: snapshot.timestamp,
144 provenance: DomainReportProvenance(
145 collectedAt: snapshot.timestamp,
146 source: snapshot.resultSource,
147 sections: snapshot.provenanceBySection,
148 dataSources: snapshot.dataSources
149 ),
111 appVersion: snapshot.appVersion, 150 appVersion: snapshot.appVersion,
112 resolverDisplayName: snapshot.resolverDisplayName, 151 resolverDisplayName: snapshot.resolverDisplayName,
113 resolverURLString: snapshot.resolverURLString, 152 resolverURLString: snapshot.resolverURLString,
@@ -180,12 +219,29 @@ struct DomainReportBuilder {
180 subdomainGroups: analysis.subdomainGroups, 219 subdomainGroups: analysis.subdomainGroups,
181 riskAssessment: analysis.riskAssessment, 220 riskAssessment: analysis.riskAssessment,
182 insights: analysis.insights, 221 insights: analysis.insights,
183 changeSummary: changeSummary 222 changeSummary: changeSummary,
223 workflowContext: workflowContext,
224 metadata: DomainReportMetadata(
225 schemaVersion: "3.0.0",
226 resolverDisplayName: snapshot.resolverDisplayName,
227 resolverURLString: snapshot.resolverURLString,
228 appVersion: snapshot.appVersion,
229 cachedSections: snapshot.cachedSections,
230 auditNote: snapshot.note,
231 validationIssues: snapshot.validationIssues,
232 isPartialSnapshot: snapshot.isPartialSnapshot,
233 errorDetails: snapshot.errorDetails,
234 statusMessage: snapshot.statusMessage
235 )
184 ) 236 )
185 } 237 }
186 238
187 func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { 239 func build(
188 build(from: entry.snapshot, previousSnapshot: previousSnapshot) 240 from entry: HistoryEntry,
241 previousSnapshot: LookupSnapshot? = nil,
242 workflowContext: DomainWorkflowContext? = nil
243 ) -> DomainReport {
244 build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext)
189 } 245 }
190 246
191 private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { 247 private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
DomainReportExporter.swift +22 −13
@@ -36,25 +36,28 @@ enum DomainReportExporter {
36 "DomainDig Report", 36 "DomainDig Report",
37 "Domain: \(report.domain)", 37 "Domain: \(report.domain)",
38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))", 38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "App Version: \(report.appVersion)", 39 "App Version: \(report.metadata.appVersion)",
40 "Resolver: \(report.resolverDisplayName)", 40 "Resolver: \(report.metadata.resolverDisplayName)",
41 "Resolver URL: \(report.resolverURLString)", 41 "Resolver URL: \(report.metadata.resolverURLString)",
42 "Source: \(report.resultSource.label)", 42 "Source: \(report.provenance.source.label)",
43 "Availability: \(availabilityLabel(report.availability))", 43 "Availability: \(availabilityLabel(report.availability))",
44 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")" 44 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
45 ] 45 ]
46 46
47 if report.isPartialSnapshot { 47 if report.metadata.isPartialSnapshot {
48 lines.append("Snapshot Integrity: Partial snapshot") 48 lines.append("Snapshot Integrity: Partial snapshot")
49 } 49 }
50 if let auditNote = report.auditNote, !auditNote.isEmpty { 50 if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
51 lines.append("Audit Note: \(auditNote)") 51 lines.append("Audit Note: \(auditNote)")
52 } 52 }
53 if !report.dataSources.isEmpty { 53 if !report.provenance.dataSources.isEmpty {
54 lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))") 54 lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
55 } 55 }
56 if !report.validationIssues.isEmpty { 56 if !report.metadata.validationIssues.isEmpty {
57 lines.append("Validation: \(report.validationIssues.joined(separator: " | "))") 57 lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
58 }
59 if let workflowContext = report.workflowContext {
60 lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
58 } 61 }
59 62
60 appendSection("Summary", to: &lines) { 63 appendSection("Summary", to: &lines) {
@@ -349,6 +352,9 @@ enum DomainReportExporter {
349 "data_sources", 352 "data_sources",
350 "audit_note", 353 "audit_note",
351 "partial_snapshot", 354 "partial_snapshot",
355 "workflow_name",
356 "workflow_source",
357 "cached_sections",
352 "change_summary", 358 "change_summary",
353 "change_impact", 359 "change_impact",
354 "workflow_insights" 360 "workflow_insights"
@@ -408,9 +414,12 @@ enum DomainReportExporter {
408 report.network.reachabilitySummary, 414 report.network.reachabilitySummary,
409 report.network.geolocationSummary, 415 report.network.geolocationSummary,
410 report.geolocationConfidence?.rawValue ?? "", 416 report.geolocationConfidence?.rawValue ?? "",
411 report.dataSources.joined(separator: " | "), 417 report.provenance.dataSources.joined(separator: " | "),
412 report.auditNote ?? "", 418 report.metadata.auditNote ?? "",
413 report.isPartialSnapshot ? "true" : "false", 419 report.metadata.isPartialSnapshot ? "true" : "false",
420 report.workflowContext?.workflowName ?? "",
421 report.workflowContext?.source ?? "",
422 report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
414 report.changeSummary?.message ?? "", 423 report.changeSummary?.message ?? "",
415 report.changeSummary?.impactClassification.rawValue ?? "", 424 report.changeSummary?.impactClassification.rawValue ?? "",
416 workflowInsightSummary 425 workflowInsightSummary