Document and support StoreKit sandbox testing
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user