Commit 37b02e5ee3
Verified · cmc
Layout: unified · split
Docs/ARCHITECTURE.md added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | # DomainDig v3.0.0 Architecture | |
| 2 | ||
| 3 | ## Overview | |
| 4 | ||
| 5 | DomainDig is a local-first inspection platform built around one canonical output model: `DomainReport`. | |
| 6 | ||
| 7 | Inspection flow: | |
| 8 | ||
| 9 | 1. `DomainInspectionService.inspect(domain:)` gathers live and cached section data into `LookupSnapshot`. | |
| 10 | 2. `DomainReportBuilder` converts the snapshot into a canonical `DomainReport`. | |
| 11 | 3. 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 | ||
| 25 | The 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 | ||
| 31 | Current 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 | ||
| 43 | 1. Add the raw collection call to `LookupRuntime`. | |
| 44 | 2. Integrate it in `DomainInspectionService` with provenance, cache source, and normalized failures. | |
| 45 | 3. Extend `LookupSnapshot` only if the raw result must persist. | |
| 46 | 4. Add the summarized representation to `DomainReportBuilder`. | |
| 47 | 5. Expose it through `DomainReportExporter` if it should appear in TXT, CSV, JSON, or CLI. | |
| 48 | 6. Render the new summary in SwiftUI using `DomainReport` fields. | |
DomainDig/AppVersion.swift +1 −1
| @@ -2,6 +2,6 @@ import Foundation | ||
| 2 | 2 | |
| 3 | 3 | enum AppVersion { |
| 4 | 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 | |
| 25 | 25 | struct ContentView: View { |
| 26 | 26 | @Environment(\.appDensity) private var appDensity |
| 27 | @State private var viewModel = DomainViewModel() | |
| 27 | @Bindable var viewModel: DomainViewModel | |
| 28 | 28 | @State private var navigationPath = NavigationPath() |
| 29 | 29 | @FocusState private var domainFieldFocused: Bool |
| 30 | 30 | @State private var customPortInput = "" |
| @@ -32,6 +32,7 @@ struct ContentView: View { | ||
| 32 | 32 | @State private var trackingNoteDraft = "" |
| 33 | 33 | @State private var editingTrackedDomain: TrackedDomain? |
| 34 | 34 | @State private var showTrackLimitAlert = false |
| 35 | @State private var featureGateMessage: String? | |
| 35 | 36 | @State private var inputMode: LookupInputMode = .single |
| 36 | 37 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 37 | 38 | @State private var showingCurrentDomainWorkflowSheet = false |
| @@ -222,7 +223,7 @@ struct ContentView: View { | ||
| 222 | 223 | .toolbarColorScheme(.dark, for: .navigationBar) |
| 223 | 224 | .preferredColorScheme(.dark) |
| 224 | 225 | .toolbar { |
| 225 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 226 | ToolbarItem(placement: .topBarTrailing) { | |
| 226 | 227 | if viewModel.hasRun { |
| 227 | 228 | Button { |
| 228 | 229 | viewModel.reset() |
| @@ -231,40 +232,6 @@ struct ContentView: View { | ||
| 231 | 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 | 237 | .navigationDestination(for: WorkflowNavigationTarget.self) { target in |
| @@ -284,7 +251,19 @@ struct ContentView: View { | ||
| 284 | 251 | .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { |
| 285 | 252 | Button("OK", role: .cancel) {} |
| 286 | 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 | 268 | .sheet(item: $editingTrackedDomain) { trackedDomain in |
| 290 | 269 | NavigationStack { |
| @@ -361,37 +340,44 @@ struct ContentView: View { | ||
| 361 | 340 | .buttonStyle(.borderedProminent) |
| 362 | 341 | .disabled(viewModel.trimmedDomain.isEmpty) |
| 363 | 342 | } 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) | |
| 368 | 348 | |
| 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)) | |
| 383 | 363 | |
| 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) | |
| 392 | 375 | } |
| 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 | ) | |
| 395 | 381 | } |
| 396 | 382 | } |
| 397 | 383 | } |
| @@ -411,7 +397,11 @@ struct ContentView: View { | ||
| 411 | 397 | } |
| 412 | 398 | } |
| 413 | 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 | 406 | Button("Copy report JSON") { |
| 417 | 407 | guard let json = viewModel.exportJSONString() else { return } |
| @@ -437,11 +427,18 @@ struct ContentView: View { | ||
| 437 | 427 | Button("Export TXT") { |
| 438 | 428 | shareSingleResults(format: .text) |
| 439 | 429 | } |
| 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) | |
| 445 | 442 | } |
| 446 | 443 | } label: { |
| 447 | 444 | Image(systemName: "square.and.arrow.up") |
| @@ -466,17 +463,28 @@ struct ContentView: View { | ||
| 466 | 463 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 467 | 464 | Menu { |
| 468 | 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 | 472 | Divider() |
| 472 | 473 | Button("Export Batch TXT") { |
| 473 | 474 | shareBatchResults(format: .text) |
| 474 | 475 | } |
| 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) | |
| 480 | 488 | } |
| 481 | 489 | } label: { |
| 482 | 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 | 559 | private func parsedCustomPorts(from input: String) -> [UInt16] { |
| 538 | 560 | let parts = input.split(separator: ",", omittingEmptySubsequences: true) |
| 539 | 561 | var seen = Set<UInt16>() |
| @@ -2140,7 +2162,7 @@ private extension String { | ||
| 2140 | 2162 | } |
| 2141 | 2163 | } |
| 2142 | 2164 | |
| 2143 | private struct SettingsView: View { | |
| 2165 | struct SettingsView: View { | |
| 2144 | 2166 | @Environment(\.appDensity) private var appDensity |
| 2145 | 2167 | @Bindable var viewModel: DomainViewModel |
| 2146 | 2168 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| @@ -2152,6 +2174,8 @@ private struct SettingsView: View { | ||
| 2152 | 2174 | @State private var customResolverURL = DNSResolverOption.defaultURLString |
| 2153 | 2175 | @State private var showClearHistoryConfirmation = false |
| 2154 | 2176 | @State private var showClearCacheConfirmation = false |
| 2177 | @State private var showClearWorkflowsConfirmation = false | |
| 2178 | @State private var showClearTrackedDomainsConfirmation = false | |
| 2155 | 2179 | |
| 2156 | 2180 | private var customResolverError: String? { |
| 2157 | 2181 | guard resolverOption == .custom else { |
| @@ -2199,11 +2223,38 @@ private struct SettingsView: View { | ||
| 2199 | 2223 | Button("Clear Cache", role: .destructive) { |
| 2200 | 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 | 2254 | Section("About") { |
| 2205 | 2255 | LabeledContent("Version", value: appVersion) |
| 2206 | 2256 | LabeledContent("Storage", value: "Local-only") |
| 2257 | LabeledContent("Report Schema", value: "3.0.0") | |
| 2207 | 2258 | } |
| 2208 | 2259 | } |
| 2209 | 2260 | .navigationTitle("Settings") |
| @@ -2223,6 +2274,22 @@ private struct SettingsView: View { | ||
| 2223 | 2274 | } message: { |
| 2224 | 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 | 2293 | .onAppear { |
| 2227 | 2294 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 2228 | 2295 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
| @@ -2247,5 +2314,5 @@ private struct SettingsView: View { | ||
| 2247 | 2314 | } |
| 2248 | 2315 | |
| 2249 | 2316 | #Preview { |
| 2250 | ContentView() | |
| 2317 | ContentView(viewModel: DomainViewModel()) | |
| 2251 | 2318 | } |
DomainDig/DataAccessService.swift +10 −1
| @@ -2,6 +2,15 @@ import Foundation | ||
| 2 | 2 | |
| 3 | 3 | enum DataAccessService { |
| 4 | 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 | 10 | @main |
| 11 | 11 | struct DomainDigApp: App { |
| 12 | 12 | @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue |
| 13 | @State private var viewModel = DomainViewModel() | |
| 13 | 14 | |
| 14 | 15 | init() { |
| 15 | 16 | LocalNotificationService.shared.configureForegroundPresentation() |
| @@ -17,7 +18,7 @@ struct DomainDigApp: App { | ||
| 17 | 18 | |
| 18 | 19 | var body: some Scene { |
| 19 | 20 | WindowGroup { |
| 20 | ContentView() | |
| 21 | RootTabView(viewModel: viewModel) | |
| 21 | 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 | 371 | var trackingLimitMessage: String? { |
| 372 | nil | |
| 372 | FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count) | |
| 373 | 373 | } |
| 374 | 374 | |
| 375 | 375 | var canTrackCurrentDomain: Bool { |
| 376 | true | |
| 376 | FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) | |
| 377 | 377 | } |
| 378 | 378 | |
| 379 | 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 | 680 | func clearRecentSearches() { |
| 669 | 681 | recentSearches.removeAll() |
| 670 | 682 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) |
| @@ -725,10 +737,12 @@ final class DomainViewModel { | ||
| 725 | 737 | func runBulkLookup() { |
| 726 | 738 | let domains = parsedDomains(from: bulkInput) |
| 727 | 739 | guard !domains.isEmpty else { return } |
| 740 | guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } | |
| 728 | 741 | startBatchLookup(domains: domains, source: .manual) |
| 729 | 742 | } |
| 730 | 743 | |
| 731 | 744 | func refreshAllTrackedDomains() { |
| 745 | guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } | |
| 732 | 746 | startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) |
| 733 | 747 | } |
| 734 | 748 | |
| @@ -777,6 +791,7 @@ final class DomainViewModel { | ||
| 777 | 791 | let normalizedDomains = normalizedDomains(domains) |
| 778 | 792 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| 779 | 793 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } |
| 794 | guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil } | |
| 780 | 795 | |
| 781 | 796 | let workflow = DomainWorkflow( |
| 782 | 797 | name: trimmedName, |
| @@ -840,6 +855,7 @@ final class DomainViewModel { | ||
| 840 | 855 | |
| 841 | 856 | func runWorkflow(_ workflow: DomainWorkflow) { |
| 842 | 857 | guard !workflow.domains.isEmpty else { return } |
| 858 | guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } | |
| 843 | 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 | 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 | 2148 | private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { |
| 2131 | 2149 | let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results |
| 2132 | 2150 | return filteredResults.compactMap { result in |
| 2133 | 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 { | |
| 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 | ) | |
| 2155 | 2191 | } |
| 2156 | 2192 | |
| 2157 | 2193 | private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { |
DomainDig/FeatureAccessService.swift added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum 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 | ||
| 22 | enum 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 | ||
| 59 | struct 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 | ||
| 67 | enum 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 | |
| 3 | 3 | enum PremiumAccessService { |
| 4 | 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 | 17 | static func trackedDomainLimitMessage(currentCount: Int) -> String? { |
| 9 | nil | |
| 18 | FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount) | |
| 10 | 19 | } |
| 11 | 20 | |
| 12 | 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | 56 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 57 | 57 | } else { |
| 58 | if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { | |
| 58 | if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { | |
| 59 | 59 | Section { |
| 60 | 60 | Text(limitMessage) |
| 61 | 61 | .font(appDensity.font(.caption)) |
| @@ -108,12 +108,19 @@ struct WatchlistView: View { | ||
| 108 | 108 | shareTrackedDomains(format: .text) |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | Button("Export CSV") { | |
| 112 | shareTrackedDomains(format: .csv) | |
| 113 | } | |
| 111 | if FeatureAccessService.hasAccess(to: .advancedExports) { | |
| 112 | Button("Export CSV") { | |
| 113 | shareTrackedDomains(format: .csv) | |
| 114 | } | |
| 114 | 115 | |
| 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) | |
| 117 | 124 | } |
| 118 | 125 | } label: { |
| 119 | 126 | Image(systemName: "line.3.horizontal.decrease.circle") |
DomainDig/WorkflowsView.swift +96 −63
| @@ -15,57 +15,18 @@ struct WorkflowsView: View { | ||
| 15 | 15 | |
| 16 | 16 | var body: some View { |
| 17 | 17 | 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 { | |
| 47 | 21 | Section { |
| 48 | 22 | 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), | |
| 52 | 26 | systemImage: "square.stack.3d.down.right" |
| 53 | 27 | ) |
| 54 | 28 | } |
| 55 | 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 | 32 | .scrollContentBackground(.hidden) |
| @@ -75,15 +36,17 @@ struct WorkflowsView: View { | ||
| 75 | 36 | } |
| 76 | 37 | .navigationTitle("Workflows") |
| 77 | 38 | .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 | } | |
| 84 | 46 | |
| 85 | if !viewModel.workflows.isEmpty { | |
| 86 | EditButton() | |
| 47 | if !viewModel.workflows.isEmpty { | |
| 48 | EditButton() | |
| 49 | } | |
| 87 | 50 | } |
| 88 | 51 | } |
| 89 | 52 | } |
| @@ -102,6 +65,62 @@ struct WorkflowsView: View { | ||
| 102 | 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 | |
| 107 | 126 | private struct WorkflowRowView: View { |
| @@ -197,11 +216,18 @@ struct WorkflowDetailView: View { | ||
| 197 | 216 | Button("Export TXT") { |
| 198 | 217 | shareWorkflowResults(format: .text) |
| 199 | 218 | } |
| 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) | |
| 205 | 231 | } |
| 206 | 232 | } label: { |
| 207 | 233 | Label("Export Results", systemImage: "square.and.arrow.up") |
| @@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View { | ||
| 478 | 504 | Button("Export TXT") { |
| 479 | 505 | share(format: .text) |
| 480 | 506 | } |
| 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) | |
| 486 | 519 | } |
| 487 | 520 | } label: { |
| 488 | 521 | Image(systemName: "square.and.arrow.up") |
DomainReportBuilder.swift +60 −4
| @@ -3,6 +3,7 @@ import Foundation | ||
| 3 | 3 | struct DomainReport: Codable { |
| 4 | 4 | let domain: String |
| 5 | 5 | let timestamp: Date |
| 6 | let provenance: DomainReportProvenance | |
| 6 | 7 | let appVersion: String |
| 7 | 8 | let resolverDisplayName: String |
| 8 | 9 | let resolverURLString: String |
| @@ -29,6 +30,34 @@ struct DomainReport: Codable { | ||
| 29 | 30 | let riskAssessment: DomainRiskAssessment |
| 30 | 31 | let insights: [String] |
| 31 | 32 | let changeSummary: DomainChangeSummary? |
| 33 | let workflowContext: DomainWorkflowContext? | |
| 34 | let metadata: DomainReportMetadata | |
| 35 | } | |
| 36 | ||
| 37 | struct DomainReportProvenance: Codable { | |
| 38 | let collectedAt: Date | |
| 39 | let source: LookupResultSource | |
| 40 | let sections: [LookupSectionKind: SectionProvenance] | |
| 41 | let dataSources: [String] | |
| 42 | } | |
| 43 | ||
| 44 | struct DomainWorkflowContext: Codable { | |
| 45 | let workflowID: UUID? | |
| 46 | let workflowName: String? | |
| 47 | let source: String | |
| 48 | } | |
| 49 | ||
| 50 | struct 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 | |
| 34 | 63 | struct DNSResultSummary: Codable { |
| @@ -87,7 +116,11 @@ struct NetworkSummary: Codable { | ||
| 87 | 116 | } |
| 88 | 117 | |
| 89 | 118 | struct 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 | 124 | let primaryIP = primaryIPAddress(from: snapshot) |
| 92 | 125 | let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) |
| 93 | 126 | let changeSummary: DomainChangeSummary? |
| @@ -108,6 +141,12 @@ struct DomainReportBuilder { | ||
| 108 | 141 | return DomainReport( |
| 109 | 142 | domain: snapshot.domain, |
| 110 | 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 | 150 | appVersion: snapshot.appVersion, |
| 112 | 151 | resolverDisplayName: snapshot.resolverDisplayName, |
| 113 | 152 | resolverURLString: snapshot.resolverURLString, |
| @@ -180,12 +219,29 @@ struct DomainReportBuilder { | ||
| 180 | 219 | subdomainGroups: analysis.subdomainGroups, |
| 181 | 220 | riskAssessment: analysis.riskAssessment, |
| 182 | 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 { | |
| 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) | |
| 189 | 245 | } |
| 190 | 246 | |
| 191 | 247 | private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { |
DomainReportExporter.swift +22 −13
| @@ -36,25 +36,28 @@ enum DomainReportExporter { | ||
| 36 | 36 | "DomainDig Report", |
| 37 | 37 | "Domain: \(report.domain)", |
| 38 | 38 | "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)", | |
| 43 | 43 | "Availability: \(availabilityLabel(report.availability))", |
| 44 | 44 | "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")" |
| 45 | 45 | ] |
| 46 | 46 | |
| 47 | if report.isPartialSnapshot { | |
| 47 | if report.metadata.isPartialSnapshot { | |
| 48 | 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 | 51 | lines.append("Audit Note: \(auditNote)") |
| 52 | 52 | } |
| 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: ", "))") | |
| 55 | 55 | } |
| 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)") | |
| 58 | 61 | } |
| 59 | 62 | |
| 60 | 63 | appendSection("Summary", to: &lines) { |
| @@ -349,6 +352,9 @@ enum DomainReportExporter { | ||
| 349 | 352 | "data_sources", |
| 350 | 353 | "audit_note", |
| 351 | 354 | "partial_snapshot", |
| 355 | "workflow_name", | |
| 356 | "workflow_source", | |
| 357 | "cached_sections", | |
| 352 | 358 | "change_summary", |
| 353 | 359 | "change_impact", |
| 354 | 360 | "workflow_insights" |
| @@ -408,9 +414,12 @@ enum DomainReportExporter { | ||
| 408 | 414 | report.network.reachabilitySummary, |
| 409 | 415 | report.network.geolocationSummary, |
| 410 | 416 | 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: " | "), | |
| 414 | 423 | report.changeSummary?.message ?? "", |
| 415 | 424 | report.changeSummary?.impactClassification.rawValue ?? "", |
| 416 | 425 | workflowInsightSummary |