Document and support StoreKit sandbox testing

This commit is contained in:
FuelBoard Contributor
2026-09-26 19:34:41 +01:00
parent 20f53b9290
commit d0ee8edac3
4 changed files with 346 additions and 3 deletions
+101 -2
View File
@@ -249,6 +249,55 @@ struct SettingsView: View {
}
.onAppear { refreshWidgetDiag() }
Section {
Button {
Task { await tipStore.load() }
} label: {
Label("Probe App Store products", systemImage: "arrow.clockwise")
}
LabeledContent("Probe", value: tipStore.productLoadStatus)
if let loadedAt = tipStore.lastProductLoadDate {
LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard))
}
LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs")
LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products")
if !tipStore.debugProducts.isEmpty {
ForEach(tipStore.debugProducts) { product in
VStack(alignment: .leading, spacing: 3) {
Text(product.id)
.font(.caption.monospaced())
.textSelection(.enabled)
Text(product.displayName)
.font(.footnote.weight(.medium))
Text("\(product.type) · \(product.displayPrice) · raw \(product.price)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
} else {
Text("No products were returned by StoreKit.")
.font(.caption)
.foregroundStyle(.secondary)
}
if !tipStore.missingProductIDs.isEmpty {
Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))")
.font(.caption2)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
if let error = tipStore.lastProductLoadError {
Text("Error: \(error)")
.font(.caption2)
.foregroundStyle(.red)
.textSelection(.enabled)
}
} header: {
Text("IAP Diagnostics")
} footer: {
Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.")
}
Section {
HStack {
Text("Connection")
@@ -656,6 +705,22 @@ final class TipStore: ObservableObject {
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false
@Published private(set) var outcome: TipOutcome?
@Published private(set) var productLoadStatus = "Not probed"
@Published private(set) var lastProductLoadDate: Date?
@Published private(set) var lastProductLoadError: String?
@Published private(set) var debugProducts: [DebugProduct] = []
struct DebugProduct: Identifiable {
let id: String
let displayName: String
let type: String
let displayPrice: String
let price: String
}
var missingProductIDs: [String] {
Self.tiers.map(\.id).filter { products[$0] == nil }
}
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>?
@@ -666,6 +731,12 @@ final class TipStore: ObservableObject {
/// the queue but are never finished or acknowledged.
private var updatesTask: Task<Void, Never>?
/// Drains verified FuelBoard transactions that were already unfinished
/// before this store was created (for example after termination during a
/// sandbox purchase). `Transaction.updates` only covers updates delivered
/// while the listener is active, so both sequences are required.
private var unfinishedTask: Task<Void, Never>?
/// Uses the existing hidden developer switch in Settings. StoreKit
/// diagnostics must stay silent unless the developer explicitly enables
/// Debug mode with the five-tap Version gesture.
@@ -682,10 +753,17 @@ final class TipStore: ObservableObject {
await self?.handle(update)
}
}
unfinishedTask = Task { [weak self] in
for await update in Transaction.unfinished {
Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received")
await self?.handle(update)
}
}
}
deinit {
updatesTask?.cancel()
unfinishedTask?.cancel()
dismissTask?.cancel()
}
@@ -693,7 +771,11 @@ final class TipStore: ObservableObject {
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
// Never deliver or finish an untrusted purchase.
guard case .verified(let transaction) = update else {
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
if case .unverified(_, let error) = update {
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)")
} else {
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
}
return
}
// Only acknowledge our own consumables (future products get their own).
@@ -738,17 +820,34 @@ final class TipStore: ObservableObject {
// Refreshes product state on every visit so newly-approved products
// (or restored transactions) are picked up.
let ids = Self.tiers.map(\.id)
productLoadStatus = "Loading…"
lastProductLoadError = nil
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
do {
let fetched = try await Product.products(for: ids)
lastProductLoadDate = Date()
Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
for product in fetched {
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)")
}
merge(fetched)
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
debugProducts = fetched.map {
DebugProduct(
id: $0.id,
displayName: $0.displayName,
type: String(describing: $0.type),
displayPrice: $0.displayPrice,
price: String(describing: $0.price)
)
}
productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed"
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
} catch {
lastProductLoadDate = Date()
productLoadStatus = "Failed"
lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)"
debugProducts = []
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
// Keep the cards visible so the user can retry while App Store
// Connect metadata propagates; displayPrice() shows "Unavailable"