diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 11:46:52 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 11:46:52 -0500 |
| commit | 37b02e5ee360d61751951b77b7b556165a3af1fc (patch) | |
| tree | c2e30f32c4e8d2fcdf7035f97e9e353f09859114 | |
| parent | ceac99c05f180d9008b75c9329822466ff60255b (diff) | |
| download | domain-dig-37b02e5ee360d61751951b77b7b556165a3af1fc.tar.gz domain-dig-37b02e5ee360d61751951b77b7b556165a3af1fc.tar.bz2 domain-dig-37b02e5ee360d61751951b77b7b556165a3af1fc.zip | |
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
| -rw-r--r-- | Docs/ARCHITECTURE.md | 48 | ||||
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 225 | ||||
| -rw-r--r-- | DomainDig/DataAccessService.swift | 11 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 48 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 160 | ||||
| -rw-r--r-- | DomainDig/PremiumAccessService.swift | 15 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 42 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 19 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 159 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 64 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 35 |
13 files changed, 654 insertions, 177 deletions
diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md new file mode 100644 index 0000000..0d364d6 --- /dev/null +++ b/Docs/ARCHITECTURE.md @@ -0,0 +1,48 @@ +# DomainDig v3.0.0 Architecture + +## Overview + +DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`. + +Inspection flow: + +1. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`. +2. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`. +3. UI, exports, and CLI rendering derive from `DomainReport`. + +`LookupSnapshot` remains an internal collection and persistence shape. `DomainReport` is the stable presentation and export contract. + +## Canonical Report Lifecycle + +- `LookupRuntime` coordinates section services. +- `DomainInspectionService` normalizes failures, provenance, cache state, and section metadata. +- `DomainReportBuilder` adds summaries, insights, risk scoring, change analysis, workflow context, and report metadata. +- `DomainReportExporter` renders TXT, CSV, and JSON from the same report payload. +- `DomainDigCLI` prints exporter output directly so CLI output matches the app. + +## Feature Tiers + +The app now uses `FeatureAccessService` as the single feature gating surface. + +- `Free`: single lookup, basic history, limited tracking +- `Pro`: workflows, batch operations, advanced exports +- `Data+`: future historical datasets and extended enrichment + +Current release behavior is static scaffolding only. There are no purchases, backend checks, or remote entitlements. + +## Data Boundaries + +- Inspection services: network collection only +- `DomainReportBuilder`: canonical model assembly +- `FeatureAccessService`: tier and capability checks +- `DomainViewModel`: UI orchestration, persistence, batch coordination +- Views: rendering and interaction only + +## Adding a New Data Source + +1. Add the raw collection call to `LookupRuntime`. +2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. +3. Extend `LookupSnapshot` only if the raw result must persist. +4. Add the summarized representation to `DomainReportBuilder`. +5. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI. +6. Render the new summary in SwiftUI using `DomainReport` fields. diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 9342324..1a6861a 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { static var current: String { - "2.9.0" + "3.0.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 79ecf64..9802f77 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -24,7 +24,7 @@ private struct WorkflowNavigationTarget: Hashable { struct ContentView: View { @Environment(\.appDensity) private var appDensity - @State private var viewModel = DomainViewModel() + @Bindable var viewModel: DomainViewModel @State private var navigationPath = NavigationPath() @FocusState private var domainFieldFocused: Bool @State private var customPortInput = "" @@ -32,6 +32,7 @@ struct ContentView: View { @State private var trackingNoteDraft = "" @State private var editingTrackedDomain: TrackedDomain? @State private var showTrackLimitAlert = false + @State private var featureGateMessage: String? @State private var inputMode: LookupInputMode = .single @State private var collapsedSections: Set<ResultSection> = [.network] @State private var showingCurrentDomainWorkflowSheet = false @@ -222,7 +223,7 @@ struct ContentView: View { .toolbarColorScheme(.dark, for: .navigationBar) .preferredColorScheme(.dark) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.hasRun { Button { viewModel.reset() @@ -231,40 +232,6 @@ struct ContentView: View { .foregroundStyle(.secondary) } } - NavigationLink { - WatchlistView(viewModel: viewModel) - } label: { - Image(systemName: "eye") - .foregroundStyle(.secondary) - } - Menu { - NavigationLink { - HistoryView(viewModel: viewModel) - } label: { - Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - } - - NavigationLink { - SavedDomainsView(viewModel: viewModel) - } label: { - Label("Saved Domains", systemImage: "bookmark") - } - - NavigationLink { - WorkflowsView(viewModel: viewModel) - } label: { - Label("Workflows", systemImage: "square.stack.3d.down.right") - } - - NavigationLink { - SettingsView(viewModel: viewModel) - } label: { - Label("Settings", systemImage: "gearshape") - } - } label: { - Image(systemName: "ellipsis.circle") - .foregroundStyle(.secondary) - } } } .navigationDestination(for: WorkflowNavigationTarget.self) { target in @@ -284,7 +251,19 @@ struct ContentView: View { .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { Button("OK", role: .cancel) {} } message: { - Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.") + Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.") + } + .alert("Feature unavailable", isPresented: Binding( + get: { featureGateMessage != nil }, + set: { newValue in + if !newValue { + featureGateMessage = nil + } + } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(featureGateMessage ?? "") } .sheet(item: $editingTrackedDomain) { trackedDomain in NavigationStack { @@ -361,37 +340,44 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .disabled(viewModel.trimmedDomain.isEmpty) } else { - VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { - Text("Paste domains separated by new lines or commas.") - .font(appDensity.font(.caption)) - .foregroundStyle(.secondary) + if FeatureAccessService.hasAccess(to: .batchOperations) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Text("Paste domains separated by new lines or commas.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) - TextField( - "example.com\napple.com, openai.com", - text: $viewModel.bulkInput, - axis: .vertical - ) - .font(appDensity.font(.body)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .lineLimit(4...10) - .padding(.horizontal, 12) - .padding(.vertical, appDensity.metrics.controlVerticalPadding) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + TextField( + "example.com\napple.com, openai.com", + text: $viewModel.bulkInput, + axis: .vertical + ) + .font(appDensity.font(.body)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(4...10) + .padding(.horizontal, 12) + .padding(.vertical, appDensity.metrics.controlVerticalPadding) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) - Button { - domainFieldFocused = false - viewModel.runBulkLookup() - } label: { - Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") - .font(appDensity.font(.headline, design: .default, weight: .semibold)) - .frame(maxWidth: .infinity) - .frame(minHeight: appDensity.metrics.controlMinHeight) + Button { + domainFieldFocused = false + viewModel.runBulkLookup() + } label: { + Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(minHeight: appDensity.metrics.controlMinHeight) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) } - .buttonStyle(.borderedProminent) - .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) + } else { + lockedFeatureCard( + title: "Batch Operations", + message: FeatureAccessService.upgradeMessage(for: .batchOperations) + ) } } } @@ -411,7 +397,11 @@ struct ContentView: View { } } Button("Add to workflow") { - showingCurrentDomainWorkflowSheet = true + if FeatureAccessService.hasAccess(to: .workflows) { + showingCurrentDomainWorkflowSheet = true + } else { + featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) + } } Button("Copy report JSON") { guard let json = viewModel.exportJSONString() else { return } @@ -437,11 +427,18 @@ struct ContentView: View { Button("Export TXT") { shareSingleResults(format: .text) } - Button("Export CSV") { - shareSingleResults(format: .csv) - } - Button("Export JSON") { - shareSingleResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareSingleResults(format: .csv) + } + Button("Export JSON") { + shareSingleResults(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "square.and.arrow.up") @@ -466,17 +463,28 @@ struct ContentView: View { if !viewModel.currentBatchResultEntries.isEmpty { Menu { Button("Add to Workflow") { - showingBatchWorkflowSheet = true + if FeatureAccessService.hasAccess(to: .workflows) { + showingBatchWorkflowSheet = true + } else { + featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) + } } Divider() Button("Export Batch TXT") { shareBatchResults(format: .text) } - Button("Export Batch CSV") { - shareBatchResults(format: .csv) - } - Button("Export Batch JSON") { - shareBatchResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export Batch CSV") { + shareBatchResults(format: .csv) + } + Button("Export Batch JSON") { + shareBatchResults(format: .json) + } + } else { + Button("Batch CSV • Available in Pro") {} + .disabled(true) + Button("Batch JSON • Available in Pro") {} + .disabled(true) } } label: { Label("Export", systemImage: "square.and.arrow.up") @@ -534,6 +542,20 @@ struct ContentView: View { } } + private func lockedFeatureCard(title: String, message: String) -> some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Text(title) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + Text(message) + .font(appDensity.font(.callout, design: .default)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(appDensity.metrics.cardPadding) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + } + private func parsedCustomPorts(from input: String) -> [UInt16] { let parts = input.split(separator: ",", omittingEmptySubsequences: true) var seen = Set<UInt16>() @@ -2140,7 +2162,7 @@ private extension String { } } -private struct SettingsView: View { +struct SettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @AppStorage(DNSResolverOption.userDefaultsKey) @@ -2152,6 +2174,8 @@ private struct SettingsView: View { @State private var customResolverURL = DNSResolverOption.defaultURLString @State private var showClearHistoryConfirmation = false @State private var showClearCacheConfirmation = false + @State private var showClearWorkflowsConfirmation = false + @State private var showClearTrackedDomainsConfirmation = false private var customResolverError: String? { guard resolverOption == .custom else { @@ -2199,11 +2223,38 @@ private struct SettingsView: View { Button("Clear Cache", role: .destructive) { showClearCacheConfirmation = true } + + Button("Clear Workflows", role: .destructive) { + showClearWorkflowsConfirmation = true + } + + Button("Clear Tracked Domains", role: .destructive) { + showClearTrackedDomainsConfirmation = true + } + } + + Section("Features") { + LabeledContent("Tier", value: FeatureAccessService.currentTier.title) + + if FeatureAccessService.enabledFeatureLabels().isEmpty { + Text("No features enabled.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } else { + ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in + Text(label) + } + } + + Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) } Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") + LabeledContent("Report Schema", value: "3.0.0") } } .navigationTitle("Settings") @@ -2223,6 +2274,22 @@ private struct SettingsView: View { } message: { Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") } + .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearWorkflows() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") + } + .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearTrackedDomains() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the watchlist only. History and workflows stay intact.") + } .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) @@ -2247,5 +2314,5 @@ private struct SettingsView: View { } #Preview { - ContentView() + ContentView(viewModel: DomainViewModel()) } diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift index fed4fde..d0254ac 100644 --- a/DomainDig/DataAccessService.swift +++ b/DomainDig/DataAccessService.swift @@ -2,6 +2,15 @@ import Foundation enum DataAccessService { static func hasAccess(to capability: DataCapability) -> Bool { - false + switch capability { + case .ownershipHistory: + return FeatureAccessService.hasAccess(to: .ownershipHistory) + case .dnsHistory: + return FeatureAccessService.hasAccess(to: .dnsHistory) + case .extendedSubdomains: + return FeatureAccessService.hasAccess(to: .extendedSubdomains) + case .domainPricing: + return false + } } } diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index 29e0117..5eeed32 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -10,6 +10,7 @@ import SwiftUI @main struct DomainDigApp: App { @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue + @State private var viewModel = DomainViewModel() init() { LocalNotificationService.shared.configureForegroundPresentation() @@ -17,7 +18,7 @@ struct DomainDigApp: App { var body: some Scene { WindowGroup { - ContentView() + RootTabView(viewModel: viewModel) .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 18ce4c2..5288eaa 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -369,11 +369,11 @@ final class DomainViewModel { } var trackingLimitMessage: String? { - nil + FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count) } var canTrackCurrentDomain: Bool { - true + FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) } var resolverDisplayName: String { @@ -665,6 +665,18 @@ final class DomainViewModel { } } + func clearWorkflows() { + workflows.removeAll() + latestWorkflowRunSummary = nil + persistWorkflows() + } + + func clearTrackedDomains() { + trackedDomains.removeAll() + refreshingTrackedDomainID = nil + persistTrackedDomains() + } + func clearRecentSearches() { recentSearches.removeAll() UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) @@ -725,10 +737,12 @@ final class DomainViewModel { func runBulkLookup() { let domains = parsedDomains(from: bulkInput) guard !domains.isEmpty else { return } + guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } startBatchLookup(domains: domains, source: .manual) } func refreshAllTrackedDomains() { + guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) } @@ -777,6 +791,7 @@ final class DomainViewModel { let normalizedDomains = normalizedDomains(domains) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } + guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil } let workflow = DomainWorkflow( name: trimmedName, @@ -840,6 +855,7 @@ final class DomainViewModel { func runWorkflow(_ workflow: DomainWorkflow) { guard !workflow.domains.isEmpty else { return } + guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) } @@ -2124,14 +2140,23 @@ final class DomainViewModel { } private func currentBatchReports() -> [DomainReport] { - currentBatchResultEntries.map(report(for:)) + currentBatchResultEntries.map { entry in + report(for: entry, workflowContext: activeWorkflowContext) + } } private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results return filteredResults.compactMap { result in guard let entry = historyEntry(for: result) else { return nil } - return report(for: entry) + return report( + for: entry, + workflowContext: DomainWorkflowContext( + workflowID: summary.workflowID, + workflowName: summary.workflowName, + source: "workflow" + ) + ) } } @@ -2150,8 +2175,19 @@ final class DomainViewModel { } } - private func report(for entry: HistoryEntry) -> DomainReport { - reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) + private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { + reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) + } + + private var activeWorkflowContext: DomainWorkflowContext? { + guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else { + return nil + } + return DomainWorkflowContext( + workflowID: activeWorkflowRunID, + workflowName: activeWorkflowRunName, + source: "workflow" + ) } private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift new file mode 100644 index 0000000..5f380bb --- /dev/null +++ b/DomainDig/FeatureAccessService.swift @@ -0,0 +1,160 @@ +import Foundation + +enum FeatureTier: String, Codable, CaseIterable, Identifiable { + case free + case pro + case dataPlus + + var id: String { rawValue } + + var title: String { + switch self { + case .free: + return "Free" + case .pro: + return "Pro" + case .dataPlus: + return "Data+" + } + } +} + +enum FeatureCapability: String, CaseIterable, Identifiable { + case singleLookup + case basicHistory + case limitedTracking + case workflows + case batchOperations + case advancedExports + case ownershipHistory + case dnsHistory + case extendedSubdomains + + var id: String { rawValue } + + var title: String { + switch self { + case .singleLookup: + return "Single lookup" + case .basicHistory: + return "Basic history" + case .limitedTracking: + return "Limited tracking" + case .workflows: + return "Workflows" + case .batchOperations: + return "Batch operations" + case .advancedExports: + return "Advanced exports" + case .ownershipHistory: + return "Ownership history" + case .dnsHistory: + return "DNS history" + case .extendedSubdomains: + return "Extended subdomains" + } + } +} + +struct FeatureEntitlements: Equatable { + let tier: FeatureTier + let capabilities: Set<FeatureCapability> + let trackedDomainLimit: Int + let workflowLimit: Int? + let batchSizeLimit: Int? +} + +enum FeatureAccessService { + static let currentTier: FeatureTier = .free + + static var entitlements: FeatureEntitlements { + switch currentTier { + case .free: + return FeatureEntitlements( + tier: .free, + capabilities: [.singleLookup, .basicHistory, .limitedTracking], + trackedDomainLimit: 3, + workflowLimit: 0, + batchSizeLimit: 0 + ) + case .pro: + return FeatureEntitlements( + tier: .pro, + capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], + trackedDomainLimit: 250, + workflowLimit: 50, + batchSizeLimit: 100 + ) + case .dataPlus: + return FeatureEntitlements( + tier: .dataPlus, + capabilities: Set(FeatureCapability.allCases), + trackedDomainLimit: 1_000, + workflowLimit: 200, + batchSizeLimit: 250 + ) + } + } + + static func hasAccess(to capability: FeatureCapability) -> Bool { + entitlements.capabilities.contains(capability) + } + + static func canAddTrackedDomain(currentCount: Int) -> Bool { + currentCount < entitlements.trackedDomainLimit + } + + static func trackedDomainLimitMessage(currentCount: Int) -> String? { + guard currentTier == .free else { return nil } + return currentCount >= entitlements.trackedDomainLimit + ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." + : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." + } + + static func canCreateWorkflow(currentCount: Int) -> Bool { + guard hasAccess(to: .workflows) else { return false } + guard let limit = entitlements.workflowLimit else { return true } + return currentCount < limit + } + + static func canRunBatch(domainCount: Int) -> Bool { + guard hasAccess(to: .batchOperations) else { return false } + guard let limit = entitlements.batchSizeLimit else { return true } + return domainCount <= limit + } + + static func upgradeMessage(for capability: FeatureCapability) -> String { + switch capability { + case .workflows, .batchOperations, .advancedExports: + return "Available in Pro" + case .ownershipHistory, .dnsHistory, .extendedSubdomains: + return "Available in Data+" + case .limitedTracking: + return "Tracking is limited on Free" + case .singleLookup, .basicHistory: + return "Included in Free" + } + } + + static func workflowLimitMessage(currentCount: Int) -> String? { + guard hasAccess(to: .workflows) else { + return upgradeMessage(for: .workflows) + } + guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } + return "Workflow limit reached." + } + + static func batchLimitMessage(domainCount: Int) -> String? { + guard hasAccess(to: .batchOperations) else { + return upgradeMessage(for: .batchOperations) + } + guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } + return "Batch limit is \(limit) domains on \(currentTier.title)." + } + + static func enabledFeatureLabels() -> [String] { + FeatureCapability.allCases + .filter { hasAccess(to: $0) } + .map(\.title) + } +} diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift index 0987dae..8de2626 100644 --- a/DomainDig/PremiumAccessService.swift +++ b/DomainDig/PremiumAccessService.swift @@ -2,14 +2,23 @@ import Foundation enum PremiumAccessService { static func hasAccess(to capability: PremiumCapability) -> Bool { - true + switch capability { + case .advancedExports: + return FeatureAccessService.hasAccess(to: .advancedExports) + case .batchTracking: + return FeatureAccessService.hasAccess(to: .batchOperations) + case .unlimitedTrackedDomains: + return FeatureAccessService.currentTier != .free + case .automatedMonitoring, .pushAlerts: + return false + } } static func trackedDomainLimitMessage(currentCount: Int) -> String? { - nil + FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount) } static func canAddTrackedDomain(currentCount: Int) -> Bool { - true + FeatureAccessService.canAddTrackedDomain(currentCount: currentCount) } } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift new file mode 100644 index 0000000..19ea444 --- /dev/null +++ b/DomainDig/RootTabView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct RootTabView: View { + @Bindable var viewModel: DomainViewModel + + var body: some View { + TabView { + ContentView(viewModel: viewModel) + .tabItem { + Label("Inspect", systemImage: "magnifyingglass") + } + + NavigationStack { + WatchlistView(viewModel: viewModel) + } + .tabItem { + Label("Watchlist", systemImage: "eye") + } + + NavigationStack { + HistoryView(viewModel: viewModel) + } + .tabItem { + Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + NavigationStack { + WorkflowsView(viewModel: viewModel) + } + .tabItem { + Label("Workflows", systemImage: "square.stack.3d.down.right") + } + + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gearshape") + } + } + } +} diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index a434705..fcb5a16 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -55,7 +55,7 @@ struct WatchlistView: View { } .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { + if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { Section { Text(limitMessage) .font(appDensity.font(.caption)) @@ -108,12 +108,19 @@ struct WatchlistView: View { shareTrackedDomains(format: .text) } - Button("Export CSV") { - shareTrackedDomains(format: .csv) - } + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareTrackedDomains(format: .csv) + } - Button("Export JSON") { - shareTrackedDomains(format: .json) + Button("Export JSON") { + shareTrackedDomains(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "line.3.horizontal.decrease.circle") diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index b870409..9d99b02 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -15,57 +15,18 @@ struct WorkflowsView: View { var body: some View { List { - if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { - Section("Workflow Run") { - VStack(alignment: .leading, spacing: 8) { - ProgressView( - value: Double(viewModel.batchCompletedCount), - total: Double(max(viewModel.batchTotalCount, 1)) - ) - .tint(.cyan) - - HStack { - Text(viewModel.batchProgressLabel) - .font(appDensity.font(.caption)) - .foregroundStyle(.secondary) - Spacer() - if viewModel.batchLookupRunning { - Button("Cancel") { - viewModel.cancelBatchLookup() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - } - } - .padding(.vertical, 4) - } - .listRowBackground(Color(.systemGray6).opacity(0.5)) - } - - if viewModel.workflows.isEmpty { + if FeatureAccessService.hasAccess(to: .workflows) { + workflowContent + } else { Section { EmptyStateCardView( - title: "No Workflows Yet", - message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", - suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", + title: "Workflows", + message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.", + suggestion: FeatureAccessService.upgradeMessage(for: .workflows), systemImage: "square.stack.3d.down.right" ) } .listRowBackground(Color(.systemGray6).opacity(0.5)) - } else { - ForEach(viewModel.workflows) { workflow in - NavigationLink { - WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id) - } label: { - WorkflowRowView(workflow: workflow) - } - .listRowBackground(Color(.systemGray6).opacity(0.5)) - } - .onDelete { offsets in - let workflows = offsets.map { viewModel.workflows[$0] } - workflows.forEach(viewModel.deleteWorkflow) - } } } .scrollContentBackground(.hidden) @@ -75,15 +36,17 @@ struct WorkflowsView: View { } .navigationTitle("Workflows") .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showingCreateWorkflow = true - } label: { - Image(systemName: "plus.circle") - } + if FeatureAccessService.hasAccess(to: .workflows) { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showingCreateWorkflow = true + } label: { + Image(systemName: "plus.circle") + } - if !viewModel.workflows.isEmpty { - EditButton() + if !viewModel.workflows.isEmpty { + EditButton() + } } } } @@ -102,6 +65,62 @@ struct WorkflowsView: View { set: { viewModel.latestWorkflowRunSummary = $0 } ) } + + @ViewBuilder + private var workflowContent: some View { + if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { + Section("Workflow Run") { + VStack(alignment: .leading, spacing: 8) { + ProgressView( + value: Double(viewModel.batchCompletedCount), + total: Double(max(viewModel.batchTotalCount, 1)) + ) + .tint(.cyan) + + HStack { + Text(viewModel.batchProgressLabel) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + Spacer() + if viewModel.batchLookupRunning { + Button("Cancel") { + viewModel.cancelBatchLookup() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + + if viewModel.workflows.isEmpty { + Section { + EmptyStateCardView( + title: "No Workflows Yet", + message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", + suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", + systemImage: "square.stack.3d.down.right" + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.workflows) { workflow in + NavigationLink { + WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id) + } label: { + WorkflowRowView(workflow: workflow) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + let workflows = offsets.map { viewModel.workflows[$0] } + workflows.forEach(viewModel.deleteWorkflow) + } + } + } } private struct WorkflowRowView: View { @@ -197,11 +216,18 @@ struct WorkflowDetailView: View { Button("Export TXT") { shareWorkflowResults(format: .text) } - Button("Export CSV") { - shareWorkflowResults(format: .csv) - } - Button("Export JSON") { - shareWorkflowResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareWorkflowResults(format: .csv) + } + Button("Export JSON") { + shareWorkflowResults(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Label("Export Results", systemImage: "square.and.arrow.up") @@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View { Button("Export TXT") { share(format: .text) } - Button("Export CSV") { - share(format: .csv) - } - Button("Export JSON") { - share(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + share(format: .csv) + } + Button("Export JSON") { + share(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "square.and.arrow.up") diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index ceda05e..0f42519 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -3,6 +3,7 @@ import Foundation struct DomainReport: Codable { let domain: String let timestamp: Date + let provenance: DomainReportProvenance let appVersion: String let resolverDisplayName: String let resolverURLString: String @@ -29,6 +30,34 @@ struct DomainReport: Codable { let riskAssessment: DomainRiskAssessment let insights: [String] let changeSummary: DomainChangeSummary? + let workflowContext: DomainWorkflowContext? + let metadata: DomainReportMetadata +} + +struct DomainReportProvenance: Codable { + let collectedAt: Date + let source: LookupResultSource + let sections: [LookupSectionKind: SectionProvenance] + let dataSources: [String] +} + +struct DomainWorkflowContext: Codable { + let workflowID: UUID? + let workflowName: String? + let source: String +} + +struct DomainReportMetadata: Codable { + let schemaVersion: String + let resolverDisplayName: String + let resolverURLString: String + let appVersion: String + let cachedSections: [LookupSectionKind] + let auditNote: String? + let validationIssues: [String] + let isPartialSnapshot: Bool + let errorDetails: [LookupSectionKind: InspectionFailure] + let statusMessage: String? } struct DNSResultSummary: Codable { @@ -87,7 +116,11 @@ struct NetworkSummary: Codable { } struct DomainReportBuilder { - func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { + func build( + from snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot? = nil, + workflowContext: DomainWorkflowContext? = nil + ) -> DomainReport { let primaryIP = primaryIPAddress(from: snapshot) let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) let changeSummary: DomainChangeSummary? @@ -108,6 +141,12 @@ struct DomainReportBuilder { return DomainReport( domain: snapshot.domain, timestamp: snapshot.timestamp, + provenance: DomainReportProvenance( + collectedAt: snapshot.timestamp, + source: snapshot.resultSource, + sections: snapshot.provenanceBySection, + dataSources: snapshot.dataSources + ), appVersion: snapshot.appVersion, resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, @@ -180,12 +219,29 @@ struct DomainReportBuilder { subdomainGroups: analysis.subdomainGroups, riskAssessment: analysis.riskAssessment, insights: analysis.insights, - changeSummary: changeSummary + changeSummary: changeSummary, + workflowContext: workflowContext, + metadata: DomainReportMetadata( + schemaVersion: "3.0.0", + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + appVersion: snapshot.appVersion, + cachedSections: snapshot.cachedSections, + auditNote: snapshot.note, + validationIssues: snapshot.validationIssues, + isPartialSnapshot: snapshot.isPartialSnapshot, + errorDetails: snapshot.errorDetails, + statusMessage: snapshot.statusMessage + ) ) } - func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { - build(from: entry.snapshot, previousSnapshot: previousSnapshot) + func build( + from entry: HistoryEntry, + previousSnapshot: LookupSnapshot? = nil, + workflowContext: DomainWorkflowContext? = nil + ) -> DomainReport { + build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext) } private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index 9b20066..b7d4606 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -36,25 +36,28 @@ enum DomainReportExporter { "DomainDig Report", "Domain: \(report.domain)", "Timestamp: \(textDateFormatter.string(from: report.timestamp))", - "App Version: \(report.appVersion)", - "Resolver: \(report.resolverDisplayName)", - "Resolver URL: \(report.resolverURLString)", - "Source: \(report.resultSource.label)", + "App Version: \(report.metadata.appVersion)", + "Resolver: \(report.metadata.resolverDisplayName)", + "Resolver URL: \(report.metadata.resolverURLString)", + "Source: \(report.provenance.source.label)", "Availability: \(availabilityLabel(report.availability))", "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")" ] - if report.isPartialSnapshot { + if report.metadata.isPartialSnapshot { lines.append("Snapshot Integrity: Partial snapshot") } - if let auditNote = report.auditNote, !auditNote.isEmpty { + if let auditNote = report.metadata.auditNote, !auditNote.isEmpty { lines.append("Audit Note: \(auditNote)") } - if !report.dataSources.isEmpty { - lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))") + if !report.provenance.dataSources.isEmpty { + lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))") } - if !report.validationIssues.isEmpty { - lines.append("Validation: \(report.validationIssues.joined(separator: " | "))") + if !report.metadata.validationIssues.isEmpty { + lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))") + } + if let workflowContext = report.workflowContext { + lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)") } appendSection("Summary", to: &lines) { @@ -349,6 +352,9 @@ enum DomainReportExporter { "data_sources", "audit_note", "partial_snapshot", + "workflow_name", + "workflow_source", + "cached_sections", "change_summary", "change_impact", "workflow_insights" @@ -408,9 +414,12 @@ enum DomainReportExporter { report.network.reachabilitySummary, report.network.geolocationSummary, report.geolocationConfidence?.rawValue ?? "", - report.dataSources.joined(separator: " | "), - report.auditNote ?? "", - report.isPartialSnapshot ? "true" : "false", + report.provenance.dataSources.joined(separator: " | "), + report.metadata.auditNote ?? "", + report.metadata.isPartialSnapshot ? "true" : "false", + report.workflowContext?.workflowName ?? "", + report.workflowContext?.source ?? "", + report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "), report.changeSummary?.message ?? "", report.changeSummary?.impactClassification.rawValue ?? "", workflowInsightSummary |
