krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

2199384f887cf5634f3fdcf651eeb9c2c7ed457a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-19T20:02:43Z

fix: finish half-completed storekit implementation
 Docs/StoreKit.md                                   |  42 ++++++++
 Hutch.xcodeproj/project.pbxproj                    |  18 ++--
 .../xcshareddata/WorkspaceSettings.xcsettings      |   5 +
 .../xcshareddata/xcschemes/Hutch.xcscheme          |   3 +
 Hutch/Views/More/AboutView.swift                   |  71 +++++++++++--
 Hutch/Views/More/TipStoreViewModel.swift           | 118 ++++++++++++++++++---
 HutchTests/TipStoreViewModelTests.swift            |  22 ++++
 SyncedProducts.storekit                            |  87 +++++++++++++++
 8 files changed, 338 insertions(+), 28 deletions(-)

diff --git a/Docs/StoreKit.md b/Docs/StoreKit.md
new file mode 100644
index 0000000..0d2eb33
--- /dev/null
+++ b/Docs/StoreKit.md
@@ -0,0 +1,42 @@
+# StoreKit Setup For Hutch
+
+Hutch uses StoreKit 2 for one-time consumable tip products:
+
+- `net.cleberg.hutch.tip.small`
+- `net.cleberg.hutch.tip.medium`
+- `net.cleberg.hutch.tip.large`
+
+## Xcode setup
+
+1. Open `Hutch.xcodeproj` in Xcode.
+2. Select the `Hutch` target.
+3. Open `Signing & Capabilities`.
+4. Confirm the App ID for `net.cleberg.Hutch` has the In-App Purchase capability enabled in the Apple Developer portal.
+5. In Xcode, choose `File > New > File... > StoreKit Configuration File`.
+6. Prefer a synced StoreKit file so Xcode pulls the real products from App Store Connect.
+7. Open `Product > Scheme > Edit Scheme...`.
+8. Select `Run`, then the `Options` tab.
+9. Set `StoreKit Configuration` to the synced `.storekit` file for local testing.
+
+## App Store Connect checks
+
+1. Confirm all three products exist with the exact product IDs listed above.
+2. Confirm each product has complete metadata, pricing, and availability.
+3. If a product is in `Developer Action Needed`, edit the rejected detail or cancel the pending change, then resubmit it.
+4. Wait up to one hour for metadata changes to propagate to sandbox.
+
+## Sandbox test checklist
+
+1. On a physical device, enable Developer Mode if needed.
+2. Sign in to a Sandbox Apple Account in `Settings > Developer > Sandbox Apple Account`.
+3. Install a development build from Xcode or a TestFlight build.
+4. Open `More > About`.
+5. Verify the tip products load with names and prices.
+6. Complete one purchase successfully.
+7. Use `Restore / Sync Purchases` to verify StoreKit can talk to the App Store account.
+8. Record this full flow for App Review when resubmitting.
+
+## Code locations
+
+- `Hutch/Views/More/TipStoreViewModel.swift`
+- `Hutch/Views/More/AboutView.swift`
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8d8dc45..eca3935 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -54,6 +54,7 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
 		8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
 		8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
 		8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
@@ -169,6 +170,7 @@
 		8B4B28C82F6704280045FA19 = {
 			isa = PBXGroup;
 			children = (
+				8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */,
 				8B2FA9CC2F7E211800E609E2 /* Docs */,
 				8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */,
 				8B8182C22F6B742B000AE049 /* HutchTests */,
@@ -515,7 +517,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 77;
+				CURRENT_PROJECT_VERSION = 78;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +534,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.9;
+				MARKETING_VERSION = 3.1.10;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +554,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 77;
+				CURRENT_PROJECT_VERSION = 78;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +571,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.9;
+				MARKETING_VERSION = 3.1.10;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +634,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 77;
+				CURRENT_PROJECT_VERSION = 78;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +644,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.9;
+				MARKETING_VERSION = 3.1.10;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +663,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 77;
+				CURRENT_PROJECT_VERSION = 78;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +673,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.9;
+				MARKETING_VERSION = 3.1.10;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict/>
+</plist>
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
index d05e796..974a7f8 100644
--- a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
+++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
@@ -68,6 +68,9 @@
             ReferencedContainer = "container:Hutch.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../SyncedProducts.storekit">
+      </StoreKitConfigurationFileReference>
    </LaunchAction>
    <ProfileAction
       buildConfiguration = "Release"
diff --git a/Hutch/Views/More/AboutView.swift b/Hutch/Views/More/AboutView.swift
index 887bef1..106c8d2 100644
--- a/Hutch/Views/More/AboutView.swift
+++ b/Hutch/Views/More/AboutView.swift
@@ -93,12 +93,18 @@ struct AboutView: View {
 
             Section {
                 if storeViewModel.isLoading {
-                    ProgressView()
+                    ProgressView("Loading tip options…")
                         .themedRow()
                 } else if storeViewModel.products.isEmpty {
-                    Text("Tips unavailable")
-                        .foregroundStyle(.secondary)
-                        .themedRow()
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Tips unavailable")
+                            .font(.headline)
+                        Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.")
+                            .font(.subheadline)
+                            .foregroundStyle(.secondary)
+                    }
+                    .padding(.vertical, 4)
+                    .themedRow()
                 } else {
                     ForEach(storeViewModel.products, id: \.id) { product in
                         Button {
@@ -107,15 +113,49 @@ struct AboutView: View {
                             }
                         } label: {
                             HStack {
-                                Text(product.displayName)
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(product.displayName)
+                                    Text(product.id)
+                                        .font(.caption)
+                                        .foregroundStyle(.secondary)
+                                }
                                 Spacer()
-                                Text(product.displayPrice)
-                                    .foregroundStyle(.secondary)
+                                if storeViewModel.isPurchasing(product) {
+                                    ProgressView()
+                                        .controlSize(.small)
+                                } else {
+                                    Text(product.displayPrice)
+                                        .foregroundStyle(.secondary)
+                                }
                             }
                         }
+                        .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
+                        .themedRow()
+                    }
+                }
+
+                if let errorMessage = storeViewModel.errorMessage {
+                    Text(errorMessage)
+                        .font(.footnote)
+                        .foregroundStyle(.secondary)
                         .themedRow()
+                }
+
+                Button("Retry Loading Tips") {
+                    Task {
+                        await storeViewModel.loadProducts()
+                    }
+                }
+                .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil)
+                .themedRow()
+
+                Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") {
+                    Task {
+                        await storeViewModel.restorePurchases()
                     }
                 }
+                .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
+                .themedRow()
             } header: {
                 Text("Support Development")
             } footer: {
@@ -139,6 +179,23 @@ struct AboutView: View {
         .themedList()
         .navigationTitle("About")
         .navigationBarTitleDisplayMode(.inline)
+        .alert(
+            "Store Message",
+            isPresented: Binding(
+                get: { storeViewModel.statusMessage != nil },
+                set: { isPresented in
+                    if !isPresented {
+                        storeViewModel.clearStatusMessage()
+                    }
+                }
+            )
+        ) {
+            Button("OK") {
+                storeViewModel.clearStatusMessage()
+            }
+        } message: {
+            Text(storeViewModel.statusMessage ?? "")
+        }
         .task {
             await storeViewModel.loadProducts()
         }
diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift
index 54868ba..bc909c1 100644
--- a/Hutch/Views/More/TipStoreViewModel.swift
+++ b/Hutch/Views/More/TipStoreViewModel.swift
@@ -2,43 +2,135 @@ import StoreKit
 
 @Observable
 final class TipStoreViewModel {
+    enum TipProduct: String, CaseIterable {
+        case small
+        case medium
+        case large
+
+        var id: String {
+            "net.cleberg.hutch.tip.\(rawValue)"
+        }
+
+        var displayName: String {
+            switch self {
+            case .small:
+                "Small Tip"
+            case .medium:
+                "Medium Tip"
+            case .large:
+                "Large Tip"
+            }
+        }
+    }
+
+    static let productIDs = TipProduct.allCases.map(\.id)
+
     var products: [Product] = []
     var isLoading = false
+    var isRestoringPurchases = false
+    var purchasingProductID: String?
     var errorMessage: String?
+    var statusMessage: String?
+
+    private var transactionUpdatesTask: Task<Void, Never>?
 
-    private let productIDs = [
-        "net.cleberg.hutch.tip.small",
-        "net.cleberg.hutch.tip.medium",
-        "net.cleberg.hutch.tip.large"
-    ]
+    init() {
+        transactionUpdatesTask = Task.detached(priority: .background) {
+            for await verification in Transaction.updates {
+                guard case .verified(let transaction) = verification else { continue }
+                await transaction.finish()
+            }
+        }
+    }
+
+    deinit {
+        transactionUpdatesTask?.cancel()
+    }
 
+    @MainActor
     func loadProducts() async {
+        guard !isLoading else { return }
+
         isLoading = true
+        errorMessage = nil
         defer { isLoading = false }
+
         do {
-            let fetched = try await Product.products(for: productIDs)
-            // Sort by price ascending to maintain small/medium/large order
-            products = fetched.sorted { $0.price < $1.price }
+            let fetched = try await Product.products(for: Self.productIDs)
+            let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
+            let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
+            let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }
+
+            products = orderedProducts
+
+            if !missingProducts.isEmpty {
+                let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
+                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."
+            }
         } catch {
-            errorMessage = error.localizedDescription
+            products = []
+            errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
         }
     }
 
+    @MainActor
     func purchase(_ product: Product) async {
+        guard purchasingProductID == nil else { return }
+
+        purchasingProductID = product.id
+        errorMessage = nil
+        statusMessage = nil
+        defer { purchasingProductID = nil }
+
         do {
             let result = try await product.purchase()
             switch result {
             case .success(let verification):
-                if case .verified(let transaction) = verification {
+                switch verification {
+                case .verified(let transaction):
                     await transaction.finish()
+                    statusMessage = "Purchase completed successfully."
+                case .unverified(_, let error):
+                    errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
                 }
-            case .userCancelled, .pending:
+
+            case .pending:
+                statusMessage = "Purchase is pending approval."
+
+            case .userCancelled:
                 break
+
             @unknown default:
-                break
+                errorMessage = "The App Store returned an unknown purchase result."
             }
         } catch {
-            errorMessage = error.localizedDescription
+            errorMessage = "Purchase failed. \(error.localizedDescription)"
+        }
+    }
+
+    @MainActor
+    func restorePurchases() async {
+        guard !isRestoringPurchases else { return }
+
+        isRestoringPurchases = true
+        errorMessage = nil
+        defer { isRestoringPurchases = false }
+
+        do {
+            try await AppStore.sync()
+            statusMessage = "Purchase history synced with the App Store."
+            await loadProducts()
+        } catch {
+            errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
         }
     }
+
+    @MainActor
+    func clearStatusMessage() {
+        statusMessage = nil
+    }
+
+    func isPurchasing(_ product: Product) -> Bool {
+        purchasingProductID == product.id
+    }
 }
diff --git a/HutchTests/TipStoreViewModelTests.swift b/HutchTests/TipStoreViewModelTests.swift
new file mode 100644
index 0000000..ed58d1e
--- /dev/null
+++ b/HutchTests/TipStoreViewModelTests.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TipStoreViewModelTests {
+
+    @Test
+    func tipProductIdentifiersMatchAppStoreConnectOrder() {
+        #expect(TipStoreViewModel.productIDs == [
+            "net.cleberg.hutch.tip.small",
+            "net.cleberg.hutch.tip.medium",
+            "net.cleberg.hutch.tip.large",
+        ])
+    }
+
+    @Test
+    func tipProductDisplayNamesMatchReviewMetadata() {
+        #expect(TipStoreViewModel.TipProduct.small.displayName == "Small Tip")
+        #expect(TipStoreViewModel.TipProduct.medium.displayName == "Medium Tip")
+        #expect(TipStoreViewModel.TipProduct.large.displayName == "Large Tip")
+    }
+}
diff --git a/SyncedProducts.storekit b/SyncedProducts.storekit
new file mode 100644
index 0000000..6d8ae4c
--- /dev/null
+++ b/SyncedProducts.storekit
@@ -0,0 +1,87 @@
+{
+  "appPolicies" : {
+    "eula" : "",
+    "policies" : [
+      {
+        "locale" : "en_US",
+        "policyText" : "",
+        "policyURL" : ""
+      }
+    ]
+  },
+  "identifier" : "8055EC65",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+    {
+      "displayPrice" : "4.99",
+      "familyShareable" : false,
+      "internalID" : "6762209132",
+      "localizations" : [
+        {
+          "description" : "Large Tip",
+          "displayName" : "Large Tip",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "net.cleberg.hutch.tip.large",
+      "referenceName" : "Large Tip",
+      "type" : "Consumable"
+    },
+    {
+      "displayPrice" : "2.99",
+      "familyShareable" : false,
+      "internalID" : "6762209073",
+      "localizations" : [
+        {
+          "description" : "Medium Tip",
+          "displayName" : "Medium Tip",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "net.cleberg.hutch.tip.medium",
+      "referenceName" : "Medium Tip",
+      "type" : "Consumable"
+    },
+    {
+      "displayPrice" : "0.99",
+      "familyShareable" : false,
+      "internalID" : "6762209018",
+      "localizations" : [
+        {
+          "description" : "Small Tip",
+          "displayName" : "Small Tip",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "net.cleberg.hutch.tip.small",
+      "referenceName" : "Small Tip",
+      "type" : "Consumable"
+    }
+  ],
+  "settings" : {
+    "_applicationInternalID" : "6760742299",
+    "_askToBuyEnabled" : false,
+    "_billingGracePeriodEnabled" : false,
+    "_billingIssuesEnabled" : false,
+    "_developerTeamID" : "ZCNAX3VL9D",
+    "_disableDialogs" : false,
+    "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 798317262.14421904,
+    "_locale" : "en_US",
+    "_renewalBillingIssuesEnabled" : false,
+    "_storefront" : "USA",
+    "_storeKitErrors" : [
+
+    ],
+    "_timeRate" : 0
+  },
+  "subscriptionGroups" : [
+
+  ],
+  "version" : {
+    "major" : 5,
+    "minor" : 0
+  }
+}