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
22
33enum AppVersion {
44 static var current: String {
5 "2.9.0"
5 "3.0.0"
66 }
77}
DomainDig/ContentView.swift +146 −79
@@ -24,7 +24,7 @@ private struct WorkflowNavigationTarget: Hashable {
2424
2525struct ContentView: View {
2626 @Environment(\.appDensity) private var appDensity
27 @State private var viewModel = DomainViewModel()
27 @Bindable var viewModel: DomainViewModel
2828 @State private var navigationPath = NavigationPath()
2929 @FocusState private var domainFieldFocused: Bool
3030 @State private var customPortInput = ""
@@ -32,6 +32,7 @@ struct ContentView: View {
3232 @State private var trackingNoteDraft = ""
3333 @State private var editingTrackedDomain: TrackedDomain?
3434 @State private var showTrackLimitAlert = false
35 @State private var featureGateMessage: String?
3536 @State private var inputMode: LookupInputMode = .single
3637 @State private var collapsedSections: Set<ResultSection> = [.network]
3738 @State private var showingCurrentDomainWorkflowSheet = false
@@ -222,7 +223,7 @@ struct ContentView: View {
222223 .toolbarColorScheme(.dark, for: .navigationBar)
223224 .preferredColorScheme(.dark)
224225 .toolbar {
225 ToolbarItemGroup(placement: .topBarTrailing) {
226 ToolbarItem(placement: .topBarTrailing) {
226227 if viewModel.hasRun {
227228 Button {
228229 viewModel.reset()
@@ -231,40 +232,6 @@ struct ContentView: View {
231232 .foregroundStyle(.secondary)
232233 }
233234 }
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 }
268235 }
269236 }
270237 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
@@ -284,7 +251,19 @@ struct ContentView: View {
284251 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
285252 Button("OK", role: .cancel) {}
286253 } 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 ?? "")
288267 }
289268 .sheet(item: $editingTrackedDomain) { trackedDomain in
290269 NavigationStack {
@@ -361,37 +340,44 @@ struct ContentView: View {
361340 .buttonStyle(.borderedProminent)
362341 .disabled(viewModel.trimmedDomain.isEmpty)
363342 } else {
364 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
365 Text("Paste domains separated by new lines or commas.")
366 .font(appDensity.font(.caption))
367 .foregroundStyle(.secondary)
343 if FeatureAccessService.hasAccess(to: .batchOperations) {
344 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
345 Text("Paste domains separated by new lines or commas.")
346 .font(appDensity.font(.caption))
347 .foregroundStyle(.secondary)
368348
369 TextField(
370 "example.com\napple.com, openai.com",
371 text: $viewModel.bulkInput,
372 axis: .vertical
373 )
374 .font(appDensity.font(.body))
375 .textInputAutocapitalization(.never)
376 .autocorrectionDisabled()
377 .keyboardType(.URL)
378 .lineLimit(4...10)
379 .padding(.horizontal, 12)
380 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
381 .background(Color(.systemGray6))
382 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
349 TextField(
350 "example.com\napple.com, openai.com",
351 text: $viewModel.bulkInput,
352 axis: .vertical
353 )
354 .font(appDensity.font(.body))
355 .textInputAutocapitalization(.never)
356 .autocorrectionDisabled()
357 .keyboardType(.URL)
358 .lineLimit(4...10)
359 .padding(.horizontal, 12)
360 .padding(.vertical, appDensity.metrics.controlVerticalPadding)
361 .background(Color(.systemGray6))
362 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
383363
384 Button {
385 domainFieldFocused = false
386 viewModel.runBulkLookup()
387 } label: {
388 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
389 .font(appDensity.font(.headline, design: .default, weight: .semibold))
390 .frame(maxWidth: .infinity)
391 .frame(minHeight: appDensity.metrics.controlMinHeight)
364 Button {
365 domainFieldFocused = false
366 viewModel.runBulkLookup()
367 } label: {
368 Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch")
369 .font(appDensity.font(.headline, design: .default, weight: .semibold))
370 .frame(maxWidth: .infinity)
371 .frame(minHeight: appDensity.metrics.controlMinHeight)
372 }
373 .buttonStyle(.borderedProminent)
374 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
392375 }
393 .buttonStyle(.borderedProminent)
394 .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning)
376 } else {
377 lockedFeatureCard(
378 title: "Batch Operations",
379 message: FeatureAccessService.upgradeMessage(for: .batchOperations)
380 )
395381 }
396382 }
397383 }
@@ -411,7 +397,11 @@ struct ContentView: View {
411397 }
412398 }
413399 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 }
415405 }
416406 Button("Copy report JSON") {
417407 guard let json = viewModel.exportJSONString() else { return }
@@ -437,11 +427,18 @@ struct ContentView: View {
437427 Button("Export TXT") {
438428 shareSingleResults(format: .text)
439429 }
440 Button("Export CSV") {
441 shareSingleResults(format: .csv)
442 }
443 Button("Export JSON") {
444 shareSingleResults(format: .json)
430 if FeatureAccessService.hasAccess(to: .advancedExports) {
431 Button("Export CSV") {
432 shareSingleResults(format: .csv)
433 }
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)
445442 }
446443 } label: {
447444 Image(systemName: "square.and.arrow.up")
@@ -466,17 +463,28 @@ struct ContentView: View {
466463 if !viewModel.currentBatchResultEntries.isEmpty {
467464 Menu {
468465 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 }
470471 }
471472 Divider()
472473 Button("Export Batch TXT") {
473474 shareBatchResults(format: .text)
474475 }
475 Button("Export Batch CSV") {
476 shareBatchResults(format: .csv)
477 }
478 Button("Export Batch JSON") {
479 shareBatchResults(format: .json)
476 if FeatureAccessService.hasAccess(to: .advancedExports) {
477 Button("Export Batch CSV") {
478 shareBatchResults(format: .csv)
479 }
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)
480488 }
481489 } label: {
482490 Label("Export", systemImage: "square.and.arrow.up")
@@ -534,6 +542,20 @@ struct ContentView: View {
534542 }
535543 }
536544
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
537559 private func parsedCustomPorts(from input: String) -> [UInt16] {
538560 let parts = input.split(separator: ",", omittingEmptySubsequences: true)
539561 var seen = Set<UInt16>()
@@ -2140,7 +2162,7 @@ private extension String {
21402162 }
21412163}
21422164
2143private struct SettingsView: View {
2165struct SettingsView: View {
21442166 @Environment(\.appDensity) private var appDensity
21452167 @Bindable var viewModel: DomainViewModel
21462168 @AppStorage(DNSResolverOption.userDefaultsKey)
@@ -2152,6 +2174,8 @@ private struct SettingsView: View {
21522174 @State private var customResolverURL = DNSResolverOption.defaultURLString
21532175 @State private var showClearHistoryConfirmation = false
21542176 @State private var showClearCacheConfirmation = false
2177 @State private var showClearWorkflowsConfirmation = false
2178 @State private var showClearTrackedDomainsConfirmation = false
21552179
21562180 private var customResolverError: String? {
21572181 guard resolverOption == .custom else {
@@ -2199,11 +2223,38 @@ private struct SettingsView: View {
21992223 Button("Clear Cache", role: .destructive) {
22002224 showClearCacheConfirmation = true
22012225 }
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)
22022252 }
22032253
22042254 Section("About") {
22052255 LabeledContent("Version", value: appVersion)
22062256 LabeledContent("Storage", value: "Local-only")
2257 LabeledContent("Report Schema", value: "3.0.0")
22072258 }
22082259 }
22092260 .navigationTitle("Settings")
@@ -2223,6 +2274,22 @@ private struct SettingsView: View {
22232274 } message: {
22242275 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
22252276 }
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 }
22262293 .onAppear {
22272294 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
22282295 resolverOption = DNSResolverOption.option(for: currentResolverURL)
@@ -2247,5 +2314,5 @@ private struct SettingsView: View {
22472314}
22482315
22492316#Preview {
2250 ContentView()
2317 ContentView(viewModel: DomainViewModel())
22512318}
DomainDig/DataAccessService.swift +10 −1
@@ -2,6 +2,15 @@ import Foundation
22
33enum DataAccessService {
44 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 }
615 }
716}
DomainDig/DomainDigApp.swift +2 −1
@@ -10,6 +10,7 @@ import SwiftUI
1010@main
1111struct DomainDigApp: App {
1212 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13 @State private var viewModel = DomainViewModel()
1314
1415 init() {
1516 LocalNotificationService.shared.configureForegroundPresentation()
@@ -17,7 +18,7 @@ struct DomainDigApp: App {
1718
1819 var body: some Scene {
1920 WindowGroup {
20 ContentView()
21 RootTabView(viewModel: viewModel)
2122 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
2223 }
2324 }
DomainDig/DomainViewModel.swift +42 −6
@@ -369,11 +369,11 @@ final class DomainViewModel {
369369 }
370370
371371 var trackingLimitMessage: String? {
372 nil
372 FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count)
373373 }
374374
375375 var canTrackCurrentDomain: Bool {
376 true
376 FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count)
377377 }
378378
379379 var resolverDisplayName: String {
@@ -665,6 +665,18 @@ final class DomainViewModel {
665665 }
666666 }
667667
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
668680 func clearRecentSearches() {
669681 recentSearches.removeAll()
670682 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
@@ -725,10 +737,12 @@ final class DomainViewModel {
725737 func runBulkLookup() {
726738 let domains = parsedDomains(from: bulkInput)
727739 guard !domains.isEmpty else { return }
740 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
728741 startBatchLookup(domains: domains, source: .manual)
729742 }
730743
731744 func refreshAllTrackedDomains() {
745 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
732746 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
733747 }
734748
@@ -777,6 +791,7 @@ final class DomainViewModel {
777791 let normalizedDomains = normalizedDomains(domains)
778792 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
779793 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
794 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
780795
781796 let workflow = DomainWorkflow(
782797 name: trimmedName,
@@ -840,6 +855,7 @@ final class DomainViewModel {
840855
841856 func runWorkflow(_ workflow: DomainWorkflow) {
842857 guard !workflow.domains.isEmpty else { return }
858 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
843859 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
844860 }
845861
@@ -2124,14 +2140,23 @@ final class DomainViewModel {
21242140 }
21252141
21262142 private func currentBatchReports() -> [DomainReport] {
2127 currentBatchResultEntries.map(report(for:))
2143 currentBatchResultEntries.map { entry in
2144 report(for: entry, workflowContext: activeWorkflowContext)
2145 }
21282146 }
21292147
21302148 private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
21312149 let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
21322150 return filteredResults.compactMap { result in
21332151 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 )
21352160 }
21362161 }
21372162
@@ -2150,8 +2175,19 @@ final class DomainViewModel {
21502175 }
21512176 }
21522177
2153 private func report(for entry: HistoryEntry) -> DomainReport {
2154 reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry))
2178 private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport {
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 )
21552191 }
21562192
21572193 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
22
33enum PremiumAccessService {
44 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 }
615 }
716
817 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
9 nil
18 FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount)
1019 }
1120
1221 static func canAddTrackedDomain(currentCount: Int) -> Bool {
13 true
22 FeatureAccessService.canAddTrackedDomain(currentCount: currentCount)
1423 }
1524}
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 {
5555 }
5656 .listRowBackground(Color(.systemGray6).opacity(0.5))
5757 } else {
58 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
58 if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
5959 Section {
6060 Text(limitMessage)
6161 .font(appDensity.font(.caption))
@@ -108,12 +108,19 @@ struct WatchlistView: View {
108108 shareTrackedDomains(format: .text)
109109 }
110110
111 Button("Export CSV") {
112 shareTrackedDomains(format: .csv)
113 }
111 if FeatureAccessService.hasAccess(to: .advancedExports) {
112 Button("Export CSV") {
113 shareTrackedDomains(format: .csv)
114 }
114115
115 Button("Export JSON") {
116 shareTrackedDomains(format: .json)
116 Button("Export 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)
117124 }
118125 } label: {
119126 Image(systemName: "line.3.horizontal.decrease.circle")
DomainDig/WorkflowsView.swift +96 −63
@@ -15,57 +15,18 @@ struct WorkflowsView: View {
1515
1616 var body: some View {
1717 List {
18 if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
19 Section("Workflow Run") {
20 VStack(alignment: .leading, spacing: 8) {
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 {
18 if FeatureAccessService.hasAccess(to: .workflows) {
19 workflowContent
20 } else {
4721 Section {
4822 EmptyStateCardView(
49 title: "No Workflows Yet",
50 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
51 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
23 title: "Workflows",
24 message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
25 suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
5226 systemImage: "square.stack.3d.down.right"
5327 )
5428 }
5529 .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 }
6930 }
7031 }
7132 .scrollContentBackground(.hidden)
@@ -75,15 +36,17 @@ struct WorkflowsView: View {
7536 }
7637 .navigationTitle("Workflows")
7738 .toolbar {
78 ToolbarItemGroup(placement: .topBarTrailing) {
79 Button {
80 showingCreateWorkflow = true
81 } label: {
82 Image(systemName: "plus.circle")
83 }
39 if FeatureAccessService.hasAccess(to: .workflows) {
40 ToolbarItemGroup(placement: .topBarTrailing) {
41 Button {
42 showingCreateWorkflow = true
43 } label: {
44 Image(systemName: "plus.circle")
45 }
8446
85 if !viewModel.workflows.isEmpty {
86 EditButton()
47 if !viewModel.workflows.isEmpty {
48 EditButton()
49 }
8750 }
8851 }
8952 }
@@ -102,6 +65,62 @@ struct WorkflowsView: View {
10265 set: { viewModel.latestWorkflowRunSummary = $0 }
10366 )
10467 }
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 }
105124}
106125
107126private struct WorkflowRowView: View {
@@ -197,11 +216,18 @@ struct WorkflowDetailView: View {
197216 Button("Export TXT") {
198217 shareWorkflowResults(format: .text)
199218 }
200 Button("Export CSV") {
201 shareWorkflowResults(format: .csv)
202 }
203 Button("Export JSON") {
204 shareWorkflowResults(format: .json)
219 if FeatureAccessService.hasAccess(to: .advancedExports) {
220 Button("Export CSV") {
221 shareWorkflowResults(format: .csv)
222 }
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)
205231 }
206232 } label: {
207233 Label("Export Results", systemImage: "square.and.arrow.up")
@@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View {
478504 Button("Export TXT") {
479505 share(format: .text)
480506 }
481 Button("Export CSV") {
482 share(format: .csv)
483 }
484 Button("Export JSON") {
485 share(format: .json)
507 if FeatureAccessService.hasAccess(to: .advancedExports) {
508 Button("Export CSV") {
509 share(format: .csv)
510 }
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)
486519 }
487520 } label: {
488521 Image(systemName: "square.and.arrow.up")
DomainReportBuilder.swift +60 −4
@@ -3,6 +3,7 @@ import Foundation
33struct DomainReport: Codable {
44 let domain: String
55 let timestamp: Date
6 let provenance: DomainReportProvenance
67 let appVersion: String
78 let resolverDisplayName: String
89 let resolverURLString: String
@@ -29,6 +30,34 @@ struct DomainReport: Codable {
2930 let riskAssessment: DomainRiskAssessment
3031 let insights: [String]
3132 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?
3261}
3362
3463struct DNSResultSummary: Codable {
@@ -87,7 +116,11 @@ struct NetworkSummary: Codable {
87116}
88117
89118struct 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 {
91124 let primaryIP = primaryIPAddress(from: snapshot)
92125 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
93126 let changeSummary: DomainChangeSummary?
@@ -108,6 +141,12 @@ struct DomainReportBuilder {
108141 return DomainReport(
109142 domain: snapshot.domain,
110143 timestamp: snapshot.timestamp,
144 provenance: DomainReportProvenance(
145 collectedAt: snapshot.timestamp,
146 source: snapshot.resultSource,
147 sections: snapshot.provenanceBySection,
148 dataSources: snapshot.dataSources
149 ),
111150 appVersion: snapshot.appVersion,
112151 resolverDisplayName: snapshot.resolverDisplayName,
113152 resolverURLString: snapshot.resolverURLString,
@@ -180,12 +219,29 @@ struct DomainReportBuilder {
180219 subdomainGroups: analysis.subdomainGroups,
181220 riskAssessment: analysis.riskAssessment,
182221 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 )
184236 )
185237 }
186238
187 func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
188 build(from: entry.snapshot, previousSnapshot: previousSnapshot)
239 func build(
240 from entry: HistoryEntry,
241 previousSnapshot: LookupSnapshot? = nil,
242 workflowContext: DomainWorkflowContext? = nil
243 ) -> DomainReport {
244 build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext)
189245 }
190246
191247 private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
DomainReportExporter.swift +22 −13
@@ -36,25 +36,28 @@ enum DomainReportExporter {
3636 "DomainDig Report",
3737 "Domain: \(report.domain)",
3838 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "App Version: \(report.appVersion)",
40 "Resolver: \(report.resolverDisplayName)",
41 "Resolver URL: \(report.resolverURLString)",
42 "Source: \(report.resultSource.label)",
39 "App Version: \(report.metadata.appVersion)",
40 "Resolver: \(report.metadata.resolverDisplayName)",
41 "Resolver URL: \(report.metadata.resolverURLString)",
42 "Source: \(report.provenance.source.label)",
4343 "Availability: \(availabilityLabel(report.availability))",
4444 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
4545 ]
4646
47 if report.isPartialSnapshot {
47 if report.metadata.isPartialSnapshot {
4848 lines.append("Snapshot Integrity: Partial snapshot")
4949 }
50 if let auditNote = report.auditNote, !auditNote.isEmpty {
50 if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
5151 lines.append("Audit Note: \(auditNote)")
5252 }
53 if !report.dataSources.isEmpty {
54 lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))")
53 if !report.provenance.dataSources.isEmpty {
54 lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
5555 }
56 if !report.validationIssues.isEmpty {
57 lines.append("Validation: \(report.validationIssues.joined(separator: " | "))")
56 if !report.metadata.validationIssues.isEmpty {
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)")
5861 }
5962
6063 appendSection("Summary", to: &lines) {
@@ -349,6 +352,9 @@ enum DomainReportExporter {
349352 "data_sources",
350353 "audit_note",
351354 "partial_snapshot",
355 "workflow_name",
356 "workflow_source",
357 "cached_sections",
352358 "change_summary",
353359 "change_impact",
354360 "workflow_insights"
@@ -408,9 +414,12 @@ enum DomainReportExporter {
408414 report.network.reachabilitySummary,
409415 report.network.geolocationSummary,
410416 report.geolocationConfidence?.rawValue ?? "",
411 report.dataSources.joined(separator: " | "),
412 report.auditNote ?? "",
413 report.isPartialSnapshot ? "true" : "false",
417 report.provenance.dataSources.joined(separator: " | "),
418 report.metadata.auditNote ?? "",
419 report.metadata.isPartialSnapshot ? "true" : "false",
420 report.workflowContext?.workflowName ?? "",
421 report.workflowContext?.source ?? "",
422 report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
414423 report.changeSummary?.message ?? "",
415424 report.changeSummary?.impactClassification.rawValue ?? "",
416425 workflowInsightSummary