Commit de690661f6

de690661f6a34f121ed09ce22d859cf2bad8c443

parent: 9c3ddd4a21

Unsigned

cmc <hello@cleberg.net> · 2026-04-19 20:02 UTC

fix: finish half-completed storekit implementation

Layout: unified · split

Docs/StoreKit.md added +42
@@ -0,0 +1,42 @@
1# StoreKit Setup For Hutch
2
3Hutch uses StoreKit 2 for one-time consumable tip products:
4
5- `net.cleberg.hutch.tip.small`
6- `net.cleberg.hutch.tip.medium`
7- `net.cleberg.hutch.tip.large`
8
9## Xcode setup
10
111. Open `Hutch.xcodeproj` in Xcode.
122. Select the `Hutch` target.
133. Open `Signing & Capabilities`.
144. Confirm the App ID for `net.cleberg.Hutch` has the In-App Purchase capability enabled in the Apple Developer portal.
155. In Xcode, choose `File > New > File... > StoreKit Configuration File`.
166. Prefer a synced StoreKit file so Xcode pulls the real products from App Store Connect.
177. Open `Product > Scheme > Edit Scheme...`.
188. Select `Run`, then the `Options` tab.
199. Set `StoreKit Configuration` to the synced `.storekit` file for local testing.
20
21## App Store Connect checks
22
231. Confirm all three products exist with the exact product IDs listed above.
242. Confirm each product has complete metadata, pricing, and availability.
253. If a product is in `Developer Action Needed`, edit the rejected detail or cancel the pending change, then resubmit it.
264. Wait up to one hour for metadata changes to propagate to sandbox.
27
28## Sandbox test checklist
29
301. On a physical device, enable Developer Mode if needed.
312. Sign in to a Sandbox Apple Account in `Settings > Developer > Sandbox Apple Account`.
323. Install a development build from Xcode or a TestFlight build.
334. Open `More > About`.
345. Verify the tip products load with names and prices.
356. Complete one purchase successfully.
367. Use `Restore / Sync Purchases` to verify StoreKit can talk to the App Store account.
378. Record this full flow for App Review when resubmitting.
38
39## Code locations
40
41- `Hutch/Views/More/TipStoreViewModel.swift`
42- `Hutch/Views/More/AboutView.swift`
Hutch.xcodeproj/project.pbxproj +10 −8
@@ -54,6 +54,7 @@
5454/* End PBXCopyFilesBuildPhase section */
5555
5656/* Begin PBXFileReference section */
57 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
5758 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
5859 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
5960 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
@@ -169,6 +170,7 @@
169170 8B4B28C82F6704280045FA19 = {
170171 isa = PBXGroup;
171172 children = (
173 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */,
172174 8B2FA9CC2F7E211800E609E2 /* Docs */,
173175 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */,
174176 8B8182C22F6B742B000AE049 /* HutchTests */,
@@ -515,7 +517,7 @@
515517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517519 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 77;
520 CURRENT_PROJECT_VERSION = 78;
519521 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520522 ENABLE_PREVIEWS = YES;
521523 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +534,7 @@
532534 "$(inherited)",
533535 "@executable_path/Frameworks",
534536 );
535 MARKETING_VERSION = 3.1.9;
537 MARKETING_VERSION = 3.1.10;
536538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537539 PRODUCT_NAME = "$(TARGET_NAME)";
538540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +554,7 @@
552554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554556 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 77;
557 CURRENT_PROJECT_VERSION = 78;
556558 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557559 ENABLE_PREVIEWS = YES;
558560 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +571,7 @@
569571 "$(inherited)",
570572 "@executable_path/Frameworks",
571573 );
572 MARKETING_VERSION = 3.1.9;
574 MARKETING_VERSION = 3.1.10;
573575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574576 PRODUCT_NAME = "$(TARGET_NAME)";
575577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +634,7 @@
632634 APPLICATION_EXTENSION_API_ONLY = YES;
633635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634636 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 77;
637 CURRENT_PROJECT_VERSION = 78;
636638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637639 GENERATE_INFOPLIST_FILE = NO;
638640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +644,7 @@
642644 "@executable_path/Frameworks",
643645 "@executable_path/../../Frameworks",
644646 );
645 MARKETING_VERSION = 3.1.9;
647 MARKETING_VERSION = 3.1.10;
646648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647649 PRODUCT_NAME = "$(TARGET_NAME)";
648650 SKIP_INSTALL = YES;
@@ -661,7 +663,7 @@
661663 APPLICATION_EXTENSION_API_ONLY = YES;
662664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663665 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 77;
666 CURRENT_PROJECT_VERSION = 78;
665667 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666668 GENERATE_INFOPLIST_FILE = NO;
667669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +673,7 @@
671673 "@executable_path/Frameworks",
672674 "@executable_path/../../Frameworks",
673675 );
674 MARKETING_VERSION = 3.1.9;
676 MARKETING_VERSION = 3.1.10;
675677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676678 PRODUCT_NAME = "$(TARGET_NAME)";
677679 SKIP_INSTALL = YES;
Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings added +5
@@ -0,0 +1,5 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict/>
5</plist>
Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +3
@@ -68,6 +68,9 @@
6868 ReferencedContainer = "container:Hutch.xcodeproj">
6969 </BuildableReference>
7070 </BuildableProductRunnable>
71 <StoreKitConfigurationFileReference
72 identifier = "../../SyncedProducts.storekit">
73 </StoreKitConfigurationFileReference>
7174 </LaunchAction>
7275 <ProfileAction
7376 buildConfiguration = "Release"
Hutch/Views/More/AboutView.swift +64 −7
@@ -93,12 +93,18 @@ struct AboutView: View {
9393
9494 Section {
9595 if storeViewModel.isLoading {
96 ProgressView()
96 ProgressView("Loading tip options…")
9797 .themedRow()
9898 } else if storeViewModel.products.isEmpty {
99 Text("Tips unavailable")
100 .foregroundStyle(.secondary)
101 .themedRow()
99 VStack(alignment: .leading, spacing: 8) {
100 Text("Tips unavailable")
101 .font(.headline)
102 Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.")
103 .font(.subheadline)
104 .foregroundStyle(.secondary)
105 }
106 .padding(.vertical, 4)
107 .themedRow()
102108 } else {
103109 ForEach(storeViewModel.products, id: \.id) { product in
104110 Button {
@@ -107,15 +113,49 @@ struct AboutView: View {
107113 }
108114 } label: {
109115 HStack {
110 Text(product.displayName)
116 VStack(alignment: .leading, spacing: 2) {
117 Text(product.displayName)
118 Text(product.id)
119 .font(.caption)
120 .foregroundStyle(.secondary)
121 }
111122 Spacer()
112 Text(product.displayPrice)
113 .foregroundStyle(.secondary)
123 if storeViewModel.isPurchasing(product) {
124 ProgressView()
125 .controlSize(.small)
126 } else {
127 Text(product.displayPrice)
128 .foregroundStyle(.secondary)
129 }
114130 }
115131 }
132 .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
133 .themedRow()
134 }
135 }
136
137 if let errorMessage = storeViewModel.errorMessage {
138 Text(errorMessage)
139 .font(.footnote)
140 .foregroundStyle(.secondary)
116141 .themedRow()
142 }
143
144 Button("Retry Loading Tips") {
145 Task {
146 await storeViewModel.loadProducts()
147 }
148 }
149 .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil)
150 .themedRow()
151
152 Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") {
153 Task {
154 await storeViewModel.restorePurchases()
117155 }
118156 }
157 .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
158 .themedRow()
119159 } header: {
120160 Text("Support Development")
121161 } footer: {
@@ -139,6 +179,23 @@ struct AboutView: View {
139179 .themedList()
140180 .navigationTitle("About")
141181 .navigationBarTitleDisplayMode(.inline)
182 .alert(
183 "Store Message",
184 isPresented: Binding(
185 get: { storeViewModel.statusMessage != nil },
186 set: { isPresented in
187 if !isPresented {
188 storeViewModel.clearStatusMessage()
189 }
190 }
191 )
192 ) {
193 Button("OK") {
194 storeViewModel.clearStatusMessage()
195 }
196 } message: {
197 Text(storeViewModel.statusMessage ?? "")
198 }
142199 .task {
143200 await storeViewModel.loadProducts()
144201 }
Hutch/Views/More/TipStoreViewModel.swift +105 −13
@@ -2,43 +2,135 @@ import StoreKit
22
33@Observable
44final class TipStoreViewModel {
5 enum TipProduct: String, CaseIterable {
6 case small
7 case medium
8 case large
9
10 var id: String {
11 "net.cleberg.hutch.tip.\(rawValue)"
12 }
13
14 var displayName: String {
15 switch self {
16 case .small:
17 "Small Tip"
18 case .medium:
19 "Medium Tip"
20 case .large:
21 "Large Tip"
22 }
23 }
24 }
25
26 static let productIDs = TipProduct.allCases.map(\.id)
27
528 var products: [Product] = []
629 var isLoading = false
30 var isRestoringPurchases = false
31 var purchasingProductID: String?
732 var errorMessage: String?
33 var statusMessage: String?
34
35 private var transactionUpdatesTask: Task<Void, Never>?
836
9 private let productIDs = [
10 "net.cleberg.hutch.tip.small",
11 "net.cleberg.hutch.tip.medium",
12 "net.cleberg.hutch.tip.large"
13 ]
37 init() {
38 transactionUpdatesTask = Task.detached(priority: .background) {
39 for await verification in Transaction.updates {
40 guard case .verified(let transaction) = verification else { continue }
41 await transaction.finish()
42 }
43 }
44 }
45
46 deinit {
47 transactionUpdatesTask?.cancel()
48 }
1449
50 @MainActor
1551 func loadProducts() async {
52 guard !isLoading else { return }
53
1654 isLoading = true
55 errorMessage = nil
1756 defer { isLoading = false }
57
1858 do {
19 let fetched = try await Product.products(for: productIDs)
20 // Sort by price ascending to maintain small/medium/large order
21 products = fetched.sorted { $0.price < $1.price }
59 let fetched = try await Product.products(for: Self.productIDs)
60 let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
61 let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
62 let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }
63
64 products = orderedProducts
65
66 if !missingProducts.isEmpty {
67 let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
68 errorMessage = "Missing products from the App Store response: \(missingNames). Confirm the product identifiers match App Store Connect exactly and that each item is approved or available in sandbox."
69 }
2270 } catch {
23 errorMessage = error.localizedDescription
71 products = []
72 errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
2473 }
2574 }
2675
76 @MainActor
2777 func purchase(_ product: Product) async {
78 guard purchasingProductID == nil else { return }
79
80 purchasingProductID = product.id
81 errorMessage = nil
82 statusMessage = nil
83 defer { purchasingProductID = nil }
84
2885 do {
2986 let result = try await product.purchase()
3087 switch result {
3188 case .success(let verification):
32 if case .verified(let transaction) = verification {
89 switch verification {
90 case .verified(let transaction):
3391 await transaction.finish()
92 statusMessage = "Purchase completed successfully."
93 case .unverified(_, let error):
94 errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
3495 }
35 case .userCancelled, .pending:
96
97 case .pending:
98 statusMessage = "Purchase is pending approval."
99
100 case .userCancelled:
36101 break
102
37103 @unknown default:
38 break
104 errorMessage = "The App Store returned an unknown purchase result."
39105 }
40106 } catch {
41 errorMessage = error.localizedDescription
107 errorMessage = "Purchase failed. \(error.localizedDescription)"
108 }
109 }
110
111 @MainActor
112 func restorePurchases() async {
113 guard !isRestoringPurchases else { return }
114
115 isRestoringPurchases = true
116 errorMessage = nil
117 defer { isRestoringPurchases = false }
118
119 do {
120 try await AppStore.sync()
121 statusMessage = "Purchase history synced with the App Store."
122 await loadProducts()
123 } catch {
124 errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
42125 }
43126 }
127
128 @MainActor
129 func clearStatusMessage() {
130 statusMessage = nil
131 }
132
133 func isPurchasing(_ product: Product) -> Bool {
134 purchasingProductID == product.id
135 }
44136}
HutchTests/TipStoreViewModelTests.swift added +22
@@ -0,0 +1,22 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct TipStoreViewModelTests {
6
7 @Test
8 func tipProductIdentifiersMatchAppStoreConnectOrder() {
9 #expect(TipStoreViewModel.productIDs == [
10 "net.cleberg.hutch.tip.small",
11 "net.cleberg.hutch.tip.medium",
12 "net.cleberg.hutch.tip.large",
13 ])
14 }
15
16 @Test
17 func tipProductDisplayNamesMatchReviewMetadata() {
18 #expect(TipStoreViewModel.TipProduct.small.displayName == "Small Tip")
19 #expect(TipStoreViewModel.TipProduct.medium.displayName == "Medium Tip")
20 #expect(TipStoreViewModel.TipProduct.large.displayName == "Large Tip")
21 }
22}
SyncedProducts.storekit added +87
@@ -0,0 +1,87 @@
1{
2 "appPolicies" : {
3 "eula" : "",
4 "policies" : [
5 {
6 "locale" : "en_US",
7 "policyText" : "",
8 "policyURL" : ""
9 }
10 ]
11 },
12 "identifier" : "8055EC65",
13 "nonRenewingSubscriptions" : [
14
15 ],
16 "products" : [
17 {
18 "displayPrice" : "4.99",
19 "familyShareable" : false,
20 "internalID" : "6762209132",
21 "localizations" : [
22 {
23 "description" : "Large Tip",
24 "displayName" : "Large Tip",
25 "locale" : "en_US"
26 }
27 ],
28 "productID" : "net.cleberg.hutch.tip.large",
29 "referenceName" : "Large Tip",
30 "type" : "Consumable"
31 },
32 {
33 "displayPrice" : "2.99",
34 "familyShareable" : false,
35 "internalID" : "6762209073",
36 "localizations" : [
37 {
38 "description" : "Medium Tip",
39 "displayName" : "Medium Tip",
40 "locale" : "en_US"
41 }
42 ],
43 "productID" : "net.cleberg.hutch.tip.medium",
44 "referenceName" : "Medium Tip",
45 "type" : "Consumable"
46 },
47 {
48 "displayPrice" : "0.99",
49 "familyShareable" : false,
50 "internalID" : "6762209018",
51 "localizations" : [
52 {
53 "description" : "Small Tip",
54 "displayName" : "Small Tip",
55 "locale" : "en_US"
56 }
57 ],
58 "productID" : "net.cleberg.hutch.tip.small",
59 "referenceName" : "Small Tip",
60 "type" : "Consumable"
61 }
62 ],
63 "settings" : {
64 "_applicationInternalID" : "6760742299",
65 "_askToBuyEnabled" : false,
66 "_billingGracePeriodEnabled" : false,
67 "_billingIssuesEnabled" : false,
68 "_developerTeamID" : "ZCNAX3VL9D",
69 "_disableDialogs" : false,
70 "_failTransactionsEnabled" : false,
71 "_lastSynchronizedDate" : 798317262.14421904,
72 "_locale" : "en_US",
73 "_renewalBillingIssuesEnabled" : false,
74 "_storefront" : "USA",
75 "_storeKitErrors" : [
76
77 ],
78 "_timeRate" : 0
79 },
80 "subscriptionGroups" : [
81
82 ],
83 "version" : {
84 "major" : 5,
85 "minor" : 0
86 }
87}