Commit dcf4135ec3

dcf4135ec376d357b8fafc0101a75b674e76329a

parent: 37b02e5ee3

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 17:39 UTC

feat(v3​.1​.0): add ​Store​Kit integration and ​Pro tier

• implement Purchase​Service using StoreKit 2
• activate feature tiers based on entitlement
• add minimal paywall and restore flow
• enforce free-tier limits with upgrade paths
• integrate Pro status into settings
• ensure clean and non-intrusive monetization flow

Layout: unified · split

DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 static var current: String {
5 "3.0.0"
5 "3.1.0"
66 }
77}
DomainDig/ContentView.swift +62 −52
@@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable {
2525struct ContentView: View {
2626 @Environment(\.appDensity) private var appDensity
2727 @Bindable var viewModel: DomainViewModel
28 @State private var purchaseService = PurchaseService.shared
2829 @State private var navigationPath = NavigationPath()
2930 @FocusState private var domainFieldFocused: Bool
3031 @State private var customPortInput = ""
3132 @State private var customPortsExpanded = false
3233 @State private var trackingNoteDraft = ""
3334 @State private var editingTrackedDomain: TrackedDomain?
34 @State private var showTrackLimitAlert = false
35 @State private var featureGateMessage: String?
3635 @State private var inputMode: LookupInputMode = .single
3736 @State private var collapsedSections: Set<ResultSection> = [.network]
3837 @State private var showingCurrentDomainWorkflowSheet = false
3938 @State private var showingBatchWorkflowSheet = false
4039
4140 var body: some View {
41 let _ = purchaseService.currentTier
42
4243 NavigationStack(path: $navigationPath) {
4344 ScrollView(.vertical) {
4445 VStack(spacing: 0) {
@@ -84,9 +85,7 @@ struct ContentView: View {
8485 workflows: viewModel.currentDomainWorkflows,
8586 trackingLimitMessage: viewModel.trackingLimitMessage,
8687 onTrack: {
87 if !viewModel.trackCurrentDomain() {
88 showTrackLimitAlert = true
89 }
88 _ = viewModel.trackCurrentDomain()
9089 },
9190 onTogglePinned: {
9291 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
@@ -248,23 +247,6 @@ struct ContentView: View {
248247 navigationPath = NavigationPath()
249248 domainFieldFocused = false
250249 }
251 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
252 Button("OK", role: .cancel) {}
253 } message: {
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 ?? "")
267 }
268250 .sheet(item: $editingTrackedDomain) { trackedDomain in
269251 NavigationStack {
270252 Form {
@@ -346,6 +328,12 @@ struct ContentView: View {
346328 .font(appDensity.font(.caption))
347329 .foregroundStyle(.secondary)
348330
331 if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
332 Text(batchAllowanceSummary)
333 .font(appDensity.font(.caption2))
334 .foregroundStyle(.secondary)
335 }
336
349337 TextField(
350338 "example.com\napple.com, openai.com",
351339 text: $viewModel.bulkInput,
@@ -391,25 +379,25 @@ struct ContentView: View {
391379 Menu {
392380 if !viewModel.isCurrentDomainTracked {
393381 Button("Track this domain") {
394 if !viewModel.trackCurrentDomain() {
395 showTrackLimitAlert = true
396 }
382 _ = viewModel.trackCurrentDomain()
397383 }
398384 }
399385 Button("Add to workflow") {
400 if FeatureAccessService.hasAccess(to: .workflows) {
401 showingCurrentDomainWorkflowSheet = true
402 } else {
403 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
404 }
386 showingCurrentDomainWorkflowSheet = true
405387 }
406 Button("Copy report JSON") {
407 guard let json = viewModel.exportJSONString() else { return }
408 AppClipboard.copy(json)
409 AppHaptics.copy()
388 if FeatureAccessService.hasAccess(to: .advancedExports) {
389 Button("Copy report JSON") {
390 guard let json = viewModel.exportJSONString() else { return }
391 AppClipboard.copy(json)
392 AppHaptics.copy()
393 }
394 } else {
395 Button("Copy report JSON") {
396 viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
397 }
410398 }
411399 Button("Export report") {
412 shareSingleResults(format: .json)
400 shareSingleResults(format: .text)
413401 }
414402 } label: {
415403 Image(systemName: "bolt.circle")
@@ -463,11 +451,7 @@ struct ContentView: View {
463451 if !viewModel.currentBatchResultEntries.isEmpty {
464452 Menu {
465453 Button("Add to Workflow") {
466 if FeatureAccessService.hasAccess(to: .workflows) {
467 showingBatchWorkflowSheet = true
468 } else {
469 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
470 }
454 showingBatchWorkflowSheet = true
471455 }
472456 Divider()
473457 Button("Export Batch TXT") {
@@ -2165,6 +2149,7 @@ private extension String {
21652149struct SettingsView: View {
21662150 @Environment(\.appDensity) private var appDensity
21672151 @Bindable var viewModel: DomainViewModel
2152 @State private var purchaseService = PurchaseService.shared
21682153 @AppStorage(DNSResolverOption.userDefaultsKey)
21692154 private var storedResolverURL = DNSResolverOption.defaultURLString
21702155 @AppStorage(AppDensity.userDefaultsKey)
@@ -2185,6 +2170,8 @@ struct SettingsView: View {
21852170 }
21862171
21872172 var body: some View {
2173 let _ = purchaseService.currentTier
2174
21882175 Form {
21892176 Section("Display") {
21902177 Picker("Density", selection: $storedDensity) {
@@ -2233,28 +2220,51 @@ struct SettingsView: View {
22332220 }
22342221 }
22352222
2236 Section("Features") {
2237 LabeledContent("Tier", value: FeatureAccessService.currentTier.title)
2223 Section("Pro") {
2224 LabeledContent("Status", value: purchaseService.currentTier.title)
22382225
2239 if FeatureAccessService.enabledFeatureLabels().isEmpty {
2240 Text("No features enabled.")
2241 .font(appDensity.font(.caption, design: .default))
2242 .foregroundStyle(.secondary)
2226 if purchaseService.currentTier == .free {
2227 Button("Upgrade to Pro") {
2228 viewModel.isPaywallPresented = true
2229 }
22432230 } else {
2244 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
2245 Text(label)
2231 Button("Manage Subscription") {
2232 Task {
2233 await purchaseService.manageSubscription()
2234 }
22462235 }
22472236 }
22482237
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)
2238 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2239 Task {
2240 await purchaseService.restorePurchases()
2241 }
2242 }
2243 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2244
2245 if let statusMessage = purchaseService.statusMessage {
2246 Text(statusMessage)
2247 .font(appDensity.font(.caption, design: .default))
2248 .foregroundStyle(.secondary)
2249 }
2250
2251 if let errorMessage = purchaseService.errorMessage {
2252 Text(errorMessage)
2253 .font(appDensity.font(.caption, design: .default))
2254 .foregroundStyle(.red)
2255 }
2256 }
2257
2258 Section("Features") {
2259 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
2260 Text(label)
2261 }
22522262 }
22532263
22542264 Section("About") {
22552265 LabeledContent("Version", value: appVersion)
22562266 LabeledContent("Storage", value: "Local-only")
2257 LabeledContent("Report Schema", value: "3.0.0")
2267 LabeledContent("Report Schema", value: "3.1.0")
22582268 }
22592269 }
22602270 .navigationTitle("Settings")
DomainDig/DomainDigApp.swift +5
@@ -11,6 +11,7 @@ import SwiftUI
1111struct DomainDigApp: App {
1212 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
1313 @State private var viewModel = DomainViewModel()
14 @State private var purchaseService = PurchaseService.shared
1415
1516 init() {
1617 LocalNotificationService.shared.configureForegroundPresentation()
@@ -20,6 +21,10 @@ struct DomainDigApp: App {
2021 WindowGroup {
2122 RootTabView(viewModel: viewModel)
2223 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
24 .task {
25 let _ = purchaseService.currentTier
26 await purchaseService.refreshEntitlements()
27 }
2328 }
2429 }
2530}
DomainDig/DomainViewModel.swift +19 −4
@@ -226,6 +226,8 @@ final class DomainViewModel {
226226 var watchlistSearchText = ""
227227 var watchlistFilter: WatchlistFilterOption = .all
228228 var watchlistSortOption: WatchlistSortOption = .pinned
229 var upgradePrompt: UpgradePromptContext?
230 var isPaywallPresented = false
229231
230232 var trimmedDomain: String {
231233 domain
@@ -578,6 +580,7 @@ final class DomainViewModel {
578580 }
579581
580582 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
583 upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
581584 return false
582585 }
583586
@@ -737,12 +740,18 @@ final class DomainViewModel {
737740 func runBulkLookup() {
738741 let domains = parsedDomains(from: bulkInput)
739742 guard !domains.isEmpty else { return }
740 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
743 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
744 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
745 return
746 }
741747 startBatchLookup(domains: domains, source: .manual)
742748 }
743749
744750 func refreshAllTrackedDomains() {
745 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
751 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
752 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
753 return
754 }
746755 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
747756 }
748757
@@ -791,7 +800,10 @@ final class DomainViewModel {
791800 let normalizedDomains = normalizedDomains(domains)
792801 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
793802 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
794 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
803 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
804 upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
805 return nil
806 }
795807
796808 let workflow = DomainWorkflow(
797809 name: trimmedName,
@@ -855,7 +867,10 @@ final class DomainViewModel {
855867
856868 func runWorkflow(_ workflow: DomainWorkflow) {
857869 guard !workflow.domains.isEmpty else { return }
858 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
870 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
871 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
872 return
873 }
859874 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
860875 }
861876
DomainDig/FeatureAccessService.swift +75 −21
@@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable {
6464 let batchSizeLimit: Int?
6565}
6666
67struct UpgradePromptContext: Identifiable, Equatable {
68 let id = UUID()
69 let title: String
70 let message: String
71 let capability: FeatureCapability
72}
73
6774enum FeatureAccessService {
68 static let currentTier: FeatureTier = .free
75 private static let effectivelyUnlimitedTrackedDomains = 5_000
76
77 static var currentTier: FeatureTier {
78 PurchaseService.cachedTier
79 }
6980
7081 static var entitlements: FeatureEntitlements {
7182 switch currentTier {
7283 case .free:
7384 return FeatureEntitlements(
7485 tier: .free,
75 capabilities: [.singleLookup, .basicHistory, .limitedTracking],
76 trackedDomainLimit: 3,
77 workflowLimit: 0,
78 batchSizeLimit: 0
86 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations],
87 trackedDomainLimit: 5,
88 workflowLimit: 1,
89 batchSizeLimit: 10
7990 )
8091 case .pro:
8192 return FeatureEntitlements(
8293 tier: .pro,
8394 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
84 trackedDomainLimit: 250,
85 workflowLimit: 50,
86 batchSizeLimit: 100
95 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
96 workflowLimit: nil,
97 batchSizeLimit: nil
8798 )
8899 case .dataPlus:
89100 return FeatureEntitlements(
90101 tier: .dataPlus,
91102 capabilities: Set(FeatureCapability.allCases),
92 trackedDomainLimit: 1_000,
93 workflowLimit: 200,
94 batchSizeLimit: 250
103 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
104 workflowLimit: nil,
105 batchSizeLimit: nil
95106 )
96107 }
97108 }
@@ -106,9 +117,10 @@ enum FeatureAccessService {
106117
107118 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
108119 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."
120 if currentCount >= entitlements.trackedDomainLimit {
121 return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro."
122 }
123 return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
112124 }
113125
114126 static func canCreateWorkflow(currentCount: Int) -> Bool {
@@ -137,19 +149,61 @@ enum FeatureAccessService {
137149 }
138150
139151 static func workflowLimitMessage(currentCount: Int) -> String? {
140 guard hasAccess(to: .workflows) else {
141 return upgradeMessage(for: .workflows)
142 }
143152 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
144 return "Workflow limit reached."
153 return "Free includes up to \(limit) workflow."
145154 }
146155
147156 static func batchLimitMessage(domainCount: Int) -> String? {
148 guard hasAccess(to: .batchOperations) else {
149 return upgradeMessage(for: .batchOperations)
157 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
158 return "Free runs batches up to \(limit) domains."
159 }
160
161 static func workflowAllowanceSummary(currentCount: Int) -> String? {
162 guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil }
163 if currentCount >= limit {
164 return "Free includes up to \(limit) workflow. Available in Pro."
150165 }
166 return "Free includes up to \(limit) workflow."
167 }
168
169 static func batchAllowanceSummary() -> String? {
170 guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil }
171 return "Free runs batches up to \(limit) domains."
172 }
173
174 static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? {
175 guard currentCount >= entitlements.trackedDomainLimit else { return nil }
176 return UpgradePromptContext(
177 title: "Available in Pro",
178 message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.",
179 capability: .limitedTracking
180 )
181 }
182
183 static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? {
184 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
185 return UpgradePromptContext(
186 title: "Available in Pro",
187 message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.",
188 capability: .workflows
189 )
190 }
191
192 static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? {
151193 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
152 return "Batch limit is \(limit) domains on \(currentTier.title)."
194 return UpgradePromptContext(
195 title: "Available in Pro",
196 message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.",
197 capability: .batchOperations
198 )
199 }
200
201 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
202 UpgradePromptContext(
203 title: "Available in Pro",
204 message: upgradeMessage(for: capability),
205 capability: capability
206 )
153207 }
154208
155209 static func enabledFeatureLabels() -> [String] {
DomainDig/PaywallView.swift added +123
@@ -0,0 +1,123 @@
1import StoreKit
2import SwiftUI
3
4struct PaywallView: View {
5 @Environment(\.dismiss) private var dismiss
6 @Environment(\.appDensity) private var appDensity
7 @State private var purchaseService = PurchaseService.shared
8
9 var body: some View {
10 NavigationStack {
11 List {
12 Section {
13 Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
14 .font(appDensity.font(.body, design: .default))
15 .foregroundStyle(.secondary)
16 .fixedSize(horizontal: false, vertical: true)
17 }
18
19 Section("What Pro Unlocks") {
20 featureRow("Unlimited tracked domains")
21 featureRow("Workflows")
22 featureRow("Larger batch sizes")
23 featureRow("Advanced exports")
24 }
25
26 Section("Subscription") {
27 if purchaseService.isLoadingProducts {
28 ProgressView("Loading pricing…")
29 } else if purchaseService.products.isEmpty {
30 Text("Pricing is unavailable right now.")
31 .font(appDensity.font(.caption, design: .default))
32 .foregroundStyle(.secondary)
33
34 Button("Retry") {
35 Task {
36 await purchaseService.refreshProducts()
37 }
38 }
39 } else {
40 ForEach(purchaseService.products, id: \.id) { product in
41 Button {
42 Task {
43 await purchaseService.purchase(product)
44 }
45 } label: {
46 VStack(alignment: .leading, spacing: 6) {
47 Text(subscriptionTitle(for: product))
48 .font(appDensity.font(.headline, design: .default, weight: .semibold))
49 .foregroundStyle(.primary)
50 Text(product.displayPrice)
51 .font(appDensity.font(.callout, design: .default))
52 .foregroundStyle(.secondary)
53 }
54 .frame(maxWidth: .infinity, alignment: .leading)
55 }
56 .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
57 }
58 }
59
60 if let statusMessage = purchaseService.statusMessage {
61 Text(statusMessage)
62 .font(appDensity.font(.caption, design: .default))
63 .foregroundStyle(.secondary)
64 }
65
66 if let errorMessage = purchaseService.errorMessage {
67 Text(errorMessage)
68 .font(appDensity.font(.caption, design: .default))
69 .foregroundStyle(.red)
70 }
71 }
72
73 Section("Account") {
74 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
75 Task {
76 await purchaseService.restorePurchases()
77 }
78 }
79 .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
80
81 if purchaseService.hasProAccess {
82 Button("Manage Subscription") {
83 Task {
84 await purchaseService.manageSubscription()
85 }
86 }
87 }
88 }
89 }
90 .navigationTitle("DomainDig Pro")
91 .toolbar {
92 ToolbarItem(placement: .topBarTrailing) {
93 Button("Done") {
94 dismiss()
95 }
96 }
97 }
98 }
99 .task {
100 await purchaseService.refreshProducts()
101 await purchaseService.refreshEntitlements()
102 }
103 .onDisappear {
104 purchaseService.clearMessages()
105 }
106 }
107
108 private func featureRow(_ title: String) -> some View {
109 Text(title)
110 .font(appDensity.font(.body, design: .default))
111 }
112
113 private func subscriptionTitle(for product: Product) -> String {
114 switch product.id {
115 case PurchaseService.monthlyProductID:
116 return "Pro Monthly"
117 case PurchaseService.yearlyProductID:
118 return "Pro Yearly"
119 default:
120 return product.displayName
121 }
122 }
123}
DomainDig/PurchaseService.swift added +245
@@ -0,0 +1,245 @@
1import Foundation
2import StoreKit
3
4#if canImport(UIKit)
5import UIKit
6#endif
7
8@MainActor
9@Observable
10final class PurchaseService {
11 struct CachedEntitlement: Codable {
12 let tier: FeatureTier
13 let activeProductID: String?
14 let updatedAt: Date
15 }
16
17 static let shared = PurchaseService()
18 static let monthlyProductID = "domaindig.pro.monthly"
19 static let yearlyProductID = "domaindig.pro.yearly"
20 static let productIDs = [monthlyProductID, yearlyProductID]
21
22 private static let entitlementCacheKey = "purchase.cachedEntitlement"
23
24 static var cachedEntitlement: CachedEntitlement? {
25 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
26 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
27 }
28
29 static var cachedTier: FeatureTier {
30 cachedEntitlement?.tier ?? .free
31 }
32
33 var products: [Product] = []
34 var currentTier: FeatureTier
35 var activeProductID: String?
36 var isLoadingProducts = false
37 var isPurchasing = false
38 var isRestoring = false
39 var statusMessage: String?
40 var errorMessage: String?
41
42 private var updatesTask: Task<Void, Never>?
43
44 private init() {
45 currentTier = Self.cachedTier
46 activeProductID = Self.cachedEntitlement?.activeProductID
47 updatesTask = observeTransactionUpdates()
48 Task {
49 await refreshProducts()
50 await refreshEntitlements()
51 }
52 }
53
54 var hasProAccess: Bool {
55 currentTier == .pro
56 }
57
58 func refreshProducts() async {
59 isLoadingProducts = true
60 errorMessage = nil
61
62 do {
63 let fetchedProducts = try await Product.products(for: Self.productIDs)
64 products = fetchedProducts.sorted { lhs, rhs in
65 productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
66 }
67 } catch {
68 products = []
69 errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
70 }
71
72 isLoadingProducts = false
73 }
74
75 func refreshEntitlements() async {
76 var activeTransactions: [Transaction] = []
77
78 for await result in Transaction.currentEntitlements {
79 guard case .verified(let transaction) = result else {
80 continue
81 }
82 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
83 continue
84 }
85 activeTransactions.append(transaction)
86 }
87
88 let activeProductID = activeTransactions
89 .sorted { $0.purchaseDate > $1.purchaseDate }
90 .first?
91 .productID
92
93 self.activeProductID = activeProductID
94 currentTier = activeProductID == nil ? .free : .pro
95 persistCurrentEntitlement()
96 }
97
98 func purchase(_ product: Product) async {
99 isPurchasing = true
100 statusMessage = nil
101 errorMessage = nil
102
103 do {
104 let result = try await product.purchase()
105
106 switch result {
107 case .success(let verification):
108 let transaction = try verifiedTransaction(from: verification)
109 apply(transaction: transaction)
110 await transaction.finish()
111 await refreshEntitlements()
112 statusMessage = "Pro is active."
113 case .userCancelled:
114 break
115 case .pending:
116 statusMessage = "Purchase is pending approval."
117 @unknown default:
118 errorMessage = "The purchase could not be completed."
119 }
120 } catch {
121 errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
122 }
123
124 isPurchasing = false
125 }
126
127 func restorePurchases() async {
128 isRestoring = true
129 statusMessage = nil
130 errorMessage = nil
131
132 do {
133 try await AppStore.sync()
134 await refreshEntitlements()
135 statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
136 } catch {
137 errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
138 }
139
140 isRestoring = false
141 }
142
143 func manageSubscription() async {
144 errorMessage = nil
145
146 #if canImport(UIKit)
147 if ProcessInfo.processInfo.isiOSAppOnMac {
148 errorMessage = "Manage Subscription is not available on this device."
149 return
150 }
151
152 guard let scene = UIApplication.shared.connectedScenes
153 .compactMap({ $0 as? UIWindowScene })
154 .first(where: { $0.activationState == .foregroundActive }) else {
155 errorMessage = "Manage Subscription is not available right now."
156 return
157 }
158
159 do {
160 try await AppStore.showManageSubscriptions(in: scene)
161 } catch {
162 errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
163 }
164 #else
165 errorMessage = "Manage Subscription is not available on this platform."
166 #endif
167 }
168
169 func clearMessages() {
170 statusMessage = nil
171 errorMessage = nil
172 }
173
174 private func apply(transaction: Transaction) {
175 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
176 return
177 }
178
179 activeProductID = transaction.productID
180 currentTier = .pro
181 persistCurrentEntitlement()
182 }
183
184 private func observeTransactionUpdates() -> Task<Void, Never> {
185 Task.detached(priority: .background) { [weak self] in
186 for await result in Transaction.updates {
187 guard let self else { return }
188 await self.handleTransactionUpdate(result)
189 }
190 }
191 }
192
193 private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
194 guard case .verified(let transaction) = result else { return }
195 apply(transaction: transaction)
196 await transaction.finish()
197 await refreshEntitlements()
198 }
199
200 private func persistCurrentEntitlement() {
201 let cachedEntitlement = CachedEntitlement(
202 tier: currentTier,
203 activeProductID: activeProductID,
204 updatedAt: Date()
205 )
206
207 if let data = try? JSONEncoder().encode(cachedEntitlement) {
208 UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
209 }
210 }
211
212 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
213 switch result {
214 case .verified(let transaction):
215 return transaction
216 case .unverified:
217 throw StoreKitError.notEntitled
218 }
219 }
220
221 private func productSortIndex(for productID: String) -> Int {
222 switch productID {
223 case Self.monthlyProductID:
224 return 0
225 case Self.yearlyProductID:
226 return 1
227 default:
228 return Int.max
229 }
230 }
231
232 private func storeMessage(for error: Error, fallback: String) -> String {
233 if let storeKitError = error as? StoreKitError {
234 switch storeKitError {
235 case .networkError:
236 return "The App Store is offline right now."
237 default:
238 return fallback
239 }
240 }
241
242 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
243 return message.isEmpty ? fallback : message
244 }
245}
DomainDig/RootTabView.swift +25
@@ -2,8 +2,11 @@ import SwiftUI
22
33struct RootTabView: View {
44 @Bindable var viewModel: DomainViewModel
5 @State private var purchaseService = PurchaseService.shared
56
67 var body: some View {
8 let _ = purchaseService.currentTier
9
710 TabView {
811 ContentView(viewModel: viewModel)
912 .tabItem {
@@ -38,5 +41,27 @@ struct RootTabView: View {
3841 Label("Settings", systemImage: "gearshape")
3942 }
4043 }
44 .sheet(isPresented: Binding(
45 get: { viewModel.isPaywallPresented },
46 set: { viewModel.isPaywallPresented = $0 }
47 )) {
48 PaywallView()
49 }
50 .alert(item: Binding(
51 get: { viewModel.upgradePrompt },
52 set: { viewModel.upgradePrompt = $0 }
53 )) { prompt in
54 Alert(
55 title: Text(prompt.title),
56 message: Text(prompt.message),
57 primaryButton: .default(Text("Open Paywall")) {
58 viewModel.upgradePrompt = nil
59 viewModel.isPaywallPresented = true
60 },
61 secondaryButton: .cancel(Text("Continue")) {
62 viewModel.upgradePrompt = nil
63 }
64 )
65 }
4166 }
4267}
DomainDig/WatchlistView.swift +3
@@ -4,6 +4,7 @@ struct WatchlistView: View {
44 @Environment(\.appDensity) private var appDensity
55 @Bindable var viewModel: DomainViewModel
66 @Environment(\.dismiss) private var dismiss
7 @State private var purchaseService = PurchaseService.shared
78 @State private var showWorkflowAddSheet = false
89
910 private var pinnedDomains: [TrackedDomain] {
@@ -15,6 +16,8 @@ struct WatchlistView: View {
1516 }
1617
1718 var body: some View {
19 let _ = purchaseService.currentTier
20
1821 List {
1922 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
2023 Section("Refresh Progress") {
DomainDig/WorkflowsView.swift +28 −25
@@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
1010struct WorkflowsView: View {
1111 @Environment(\.appDensity) private var appDensity
1212 @Bindable var viewModel: DomainViewModel
13 @State private var purchaseService = PurchaseService.shared
1314
1415 @State private var showingCreateWorkflow = false
1516
1617 var body: some View {
18 let _ = purchaseService.currentTier
19
1720 List {
18 if FeatureAccessService.hasAccess(to: .workflows) {
19 workflowContent
20 } else {
21 if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
2122 Section {
22 EmptyStateCardView(
23 title: "Workflows",
24 message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
25 suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
26 systemImage: "square.stack.3d.down.right"
27 )
23 Text(limitMessage)
24 .font(appDensity.font(.caption))
25 .foregroundStyle(.secondary)
2826 }
2927 .listRowBackground(Color(.systemGray6).opacity(0.5))
3028 }
29
30 workflowContent
3131 }
3232 .scrollContentBackground(.hidden)
3333 .background(Color.black)
@@ -36,17 +36,15 @@ struct WorkflowsView: View {
3636 }
3737 .navigationTitle("Workflows")
3838 .toolbar {
39 if FeatureAccessService.hasAccess(to: .workflows) {
40 ToolbarItemGroup(placement: .topBarTrailing) {
41 Button {
42 showingCreateWorkflow = true
43 } label: {
44 Image(systemName: "plus.circle")
45 }
39 ToolbarItemGroup(placement: .topBarTrailing) {
40 Button {
41 showingCreateWorkflow = true
42 } label: {
43 Image(systemName: "plus.circle")
44 }
4645
47 if !viewModel.workflows.isEmpty {
48 EditButton()
49 }
46 if !viewModel.workflows.isEmpty {
47 EditButton()
5048 }
5149 }
5250 }
@@ -429,10 +427,13 @@ struct WorkflowComposerView: View {
429427 private func saveWorkflow() {
430428 if let workflow {
431429 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
432 } else {
433 _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes)
430 dismiss()
431 return
432 }
433
434 if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
435 dismiss()
434436 }
435 dismiss()
436437 }
437438}
438439
@@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View {
678679 case .existing:
679680 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
680681 viewModel.addDomains(selectedDomainList, to: workflow)
682 dismiss()
681683 case .new:
682 _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes)
684 guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
685 return
686 }
687 dismiss()
683688 }
684
685 dismiss()
686689 }
687690}