@mpgd/capacitor-storekit 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Package.swift CHANGED
@@ -1,19 +1,49 @@
1
1
  // swift-tools-version: 5.9
2
2
  import PackageDescription
3
3
 
4
+ // `MPGD_STOREKIT_BRIDGE_TESTS=1 swift test` builds only the store-neutral
5
+ // bridge and its XCTests on a Mac. Capacitor ships iOS-only binaries, so the
6
+ // plugin target is left out of that host build. Consumers never set it.
7
+ let bridgeTestsOnly = Context.environment["MPGD_STOREKIT_BRIDGE_TESTS"] == "1"
8
+
9
+ let openIap = Target.Dependency.product(name: "OpenIAP", package: "openiap")
10
+
11
+ let bridge = Target.target(
12
+ name: "MpgdStoreKitBridge",
13
+ dependencies: [openIap],
14
+ path: "ios/Sources/MpgdStoreKitBridge"
15
+ )
16
+
4
17
  let package = Package(
5
18
  name: "MpgdCapacitorStoreKit",
6
- platforms: [.iOS(.v15)],
7
- products: [
8
- .library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"])
19
+ platforms: bridgeTestsOnly ? [.iOS(.v15), .macOS(.v14)] : [.iOS(.v15)],
20
+ products: bridgeTestsOnly ? [] : [
21
+ .library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"]),
22
+ // `cap sync ios` derives the product it links from the npm package
23
+ // name (`@mpgd/capacitor-storekit` -> `MpgdCapacitorStorekit`).
24
+ .library(name: "MpgdCapacitorStorekit", targets: ["MpgdCapacitorStoreKit"])
9
25
  ],
10
- dependencies: [
26
+ dependencies: (bridgeTestsOnly ? [] : [
11
27
  .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2")
28
+ ]) + [
29
+ // OpenIAP Apple core (StoreKit 2), pinned exactly. See
30
+ // docs/specs/commerce/openiap-migration.md before changing it.
31
+ .package(url: "https://github.com/hyodotdev/openiap.git", exact: "4.0.5")
12
32
  ],
13
- targets: [
33
+ targets: bridgeTestsOnly ? [
34
+ bridge,
35
+ .testTarget(
36
+ name: "MpgdStoreKitBridgeTests",
37
+ dependencies: ["MpgdStoreKitBridge", openIap],
38
+ path: "ios/Tests/MpgdStoreKitBridgeTests"
39
+ )
40
+ ] : [
41
+ bridge,
14
42
  .target(
15
43
  name: "MpgdCapacitorStoreKit",
16
44
  dependencies: [
45
+ "MpgdStoreKitBridge",
46
+ openIap,
17
47
  .product(name: "Capacitor", package: "capacitor-swift-pm"),
18
48
  .product(name: "Cordova", package: "capacitor-swift-pm")
19
49
  ],
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @mpgd/capacitor-storekit
2
2
 
3
- Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
3
+ Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS, built
4
+ on the [OpenIAP](https://github.com/hyodotdev/openiap) Apple core (4.0.5).
4
5
  It does not enable native IAP in the base Capacitor shell. Install it only in a
5
6
  game-owned iOS project and register `createCapacitorStoreKitServiceProvider(...)`
6
7
  in that game's provider list.
@@ -93,7 +94,12 @@ type disagrees with the catalog kind is never listed or emitted, so the
93
94
  reconciler never journals a purchase it cannot resolve or that belongs to
94
95
  another player, and never blocks a valid sibling on one. Each exclusion is
95
96
  reported through `onDiagnostic` (`unmapped-sku`, `account-mismatch`,
96
- `kind-mismatch`); diagnostics are never a grant signal. The native
97
+ `kind-mismatch`); diagnostics are never a grant signal. One exception: the
98
+ player's Restore Purchases read (`getAvailablePurchases({ restore: true })`,
99
+ sent only by the reconciler's `restorePurchases`) also lists a durable bound
100
+ to another or no token, the state of every earlier purchase after a reinstall
101
+ or on a new iPhone or iPad, so the backend's restore sharing can decide;
102
+ consumables of another token stay excluded. The native
97
103
  `transactionUpdated` listener is attached while either a `purchaseUpdated`
98
104
  or a `purchaseError` subscriber exists.
99
105
 
@@ -146,8 +152,11 @@ C3): the service provider is registered for every catalog product whose
146
152
  `platformProductIds.ios` names an App Store product id (subscriptions are
147
153
  skipped), and `getAppAccountToken` is the reference binding in
148
154
  `src/platform/commerceAccountBinding.ts`, a UUID (version 5 layout) over the
149
- SHA-256 of `mpgd-commerce-account:<playerId>` for the authenticated player;
150
- a guest session throws, so no sheet opens without a binding. The backend's
155
+ SHA-256 of `mpgd-commerce-account:<playerId>` for the server session's
156
+ player (the generated game's `guestIdentity.ts` signs in a server-issued
157
+ guest when `VITE_MPGD_IDENTITY_URL` is set) or an authenticated platform
158
+ player; the native shell's local player throws an error naming the missing
159
+ server session, so no sheet opens without a binding. The backend's
151
160
  `resolveAppAccountToken` must derive the same UUID, or both are replaced
152
161
  together.
153
162
 
@@ -172,10 +181,13 @@ of truth. The reconciler sends only transactions whose
172
181
  each one on the backend under its original idempotency key or returns the
173
182
  existing ledger grant, and calls `finishTransaction` only after a verified
174
183
  response with a ledger entry id. The StoreKit SDK and the native plugin both
175
- refuse to finish a transaction whose app account token is missing or does
176
- not match that session. A caller who can read the token from the plugin can
184
+ refuse to call `transaction.finish()` on a transaction whose app account
185
+ token is missing or does not match that session. A caller who can read the token from the plugin can
177
186
  still present it with a forged ledger entry id; that remaining hole needs a
178
- signed server finish capability. Permanently rejected and account-mismatched
187
+ signed server finish capability. A listed, non-revoked durable StoreKit
188
+ already finished (`unfinished: false`) answers `{ finished: true }` without a
189
+ native call whatever its token, so a purchase shared to this guest from an
190
+ earlier guest's restore ends there. Permanently rejected and account-mismatched
179
191
  transactions are not finished: the client cannot prove content delivery for
180
192
  them, so persist that terminal result in the game-owned operation journal and
181
193
  do not run an automatic retry loop for it. An unknown backend result stays
@@ -198,8 +210,31 @@ Commerce v2 PR C4 removed the v1 provider and its recovery driver.
198
210
  | `gateway.commerceV2`, the v1 `commerce.*` bridge methods | `gateway.commerce` and the `commerce.v2.*` methods |
199
211
  | `features: ['nativeIap']` on the registry entry | none: the entry declares no `features` and its readiness is `getCapabilities().commerce` |
200
212
 
201
- The Kit tests use mocked StoreKit responses and a syntax-only local Swift
202
- parse. The macOS CI job compiles the optional Swift package against an iOS
203
- Simulator SDK. Neither check proves a signed App Store purchase, sandbox
204
- checkout, TestFlight installation, or device grant. Validate those with a
213
+ ## Native engine
214
+
215
+ The Swift package keeps its 0.3 Capacitor contract (methods,
216
+ `transactionUpdated`, payload fields, `STOREKIT_*` codes, the finish gate)
217
+ over OpenIAP's `OpenIapModule`; the
218
+ [migration record](../../docs/specs/commerce/openiap-migration.md) lists the
219
+ contract, the call mapping, and every behavior difference. Two points to know
220
+ when integrating:
221
+
222
+ - `getTransactions` and the `finishTransaction` lookup read StoreKit's
223
+ verified lists directly (a read-only shim), because OpenIAP's queries fail
224
+ as a whole on one unverifiable transaction; an unverified entry is skipped
225
+ on its own, as in 0.3. The lists carry every app account token; the
226
+ TypeScript SDK admits the session account.
227
+ - OpenIAP does not deliver revoked transactions, so a small shim forwards
228
+ verified revocations from `Transaction.updates`; it never finishes anything.
229
+
230
+ The package declares the products `MpgdCapacitorStoreKit` and
231
+ `MpgdCapacitorStorekit` (the spelling `cap sync ios` links). SwiftPM clones
232
+ the OpenIAP monorepo on first resolution.
233
+
234
+ The Kit tests use mocked StoreKit responses, Swift XCTests of the native
235
+ policy (`MPGD_STOREKIT_BRIDGE_TESTS=1 swift test` on a Mac), and simulator
236
+ builds of the workspace and the packed package
237
+ (`pnpm smoke:native-packed-store-plugins ios`). None of these checks proves a
238
+ signed App Store purchase, sandbox checkout, TestFlight installation, or
239
+ device grant. Validate those with a
205
240
  game-owned account, keys, and device before a production rollout.
@@ -39,7 +39,8 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
39
39
  readonly products: readonly CapacitorStoreKitStoreSdkProduct[];
40
40
  /**
41
41
  * The active player's app account token (a UUID). Transactions bound to any
42
- * other token are never listed or emitted; the backend binds the same value.
42
+ * other token are never emitted and never listed, except a durable on the
43
+ * player's Restore Purchases read; the backend binds the same value.
43
44
  */
44
45
  readonly getAppAccountToken: () => string | Promise<string>;
45
46
  readonly sdk?: CapacitorStoreKitStoreSdkPlugin;
@@ -60,7 +61,11 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
60
61
  * StoreKit type disagrees with the catalog kind is reported through
61
62
  * `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
62
63
  * the reconciler never journals a purchase it cannot resolve or that belongs
63
- * to another player.
64
+ * to another player. One exception: the player's Restore Purchases read
65
+ * (`getAvailablePurchases({ restore: true })`) also lists durables bound to
66
+ * another or no token, the state of every earlier purchase after a reinstall
67
+ * or on a new device; the reconciler sends them with `intent: 'restore'` and
68
+ * the backend's restore sharing decides (RFC "Restore sharing").
64
69
  */
65
70
  export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): StoreSdk;
66
71
  export { storeKitStoreSdkCapabilities };
package/dist/store-sdk.js CHANGED
@@ -15,7 +15,11 @@ import { CapacitorStoreKit } from './plugin.js';
15
15
  * StoreKit type disagrees with the catalog kind is reported through
16
16
  * `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
17
17
  * the reconciler never journals a purchase it cannot resolve or that belongs
18
- * to another player.
18
+ * to another player. One exception: the player's Restore Purchases read
19
+ * (`getAvailablePurchases({ restore: true })`) also lists durables bound to
20
+ * another or no token, the state of every earlier purchase after a reinstall
21
+ * or on a new device; the reconciler sends them with `intent: 'restore'` and
22
+ * the backend's restore sharing decides (RFC "Restore sharing").
19
23
  */
20
24
  export function createCapacitorStoreKitStoreSdk(input) {
21
25
  const sdk = input.sdk ?? CapacitorStoreKit;
@@ -69,8 +73,13 @@ export function createCapacitorStoreKitStoreSdk(input) {
69
73
  /**
70
74
  * Keeps a verified transaction only when it is sellable to the active
71
75
  * player: configured SKU, declared kind, and the active account token.
76
+ * `restore` (the player's Restore Purchases read) also keeps a durable
77
+ * bound to another or no token: after a reinstall or on a new device every
78
+ * earlier purchase carries the earlier guest's token, and the backend's
79
+ * restore sharing decides whether this player may hold it. A consumable
80
+ * bound to another token is never kept.
72
81
  */
73
- const admit = (transaction, appAccountToken) => {
82
+ const admit = (transaction, appAccountToken, restore = false) => {
74
83
  const expected = kinds.get(transaction.productId);
75
84
  if (expected === undefined) {
76
85
  onDiagnostic({
@@ -80,7 +89,10 @@ export function createCapacitorStoreKitStoreSdk(input) {
80
89
  });
81
90
  return false;
82
91
  }
83
- if (transaction.appAccountToken?.toLowerCase() !== appAccountToken) {
92
+ const restorable = restore
93
+ && expected === 'durable'
94
+ && kindOf(transaction.type) === 'durable';
95
+ if (transaction.appAccountToken?.toLowerCase() !== appAccountToken && !restorable) {
84
96
  onDiagnostic({
85
97
  type: 'account-mismatch',
86
98
  sku: transaction.productId,
@@ -247,7 +259,11 @@ export function createCapacitorStoreKitStoreSdk(input) {
247
259
  /**
248
260
  * Mirrors the native finish gate: the transaction must be listed, not
249
261
  * revoked, and bound to the session app account token. A missing list is
250
- * an invalid response, not a quiet "not finished".
262
+ * an invalid response, not a quiet "not finished". A listed, non-revoked
263
+ * durable StoreKit already finished (`unfinished: false`) has nothing left
264
+ * to finish whatever its token: a restore shared to this player from an
265
+ * earlier guest's purchase ends there instead of retrying a finish the
266
+ * native token match refuses.
251
267
  */
252
268
  const finishPermitted = async (transactionId, appAccountToken) => {
253
269
  const response = await sdk.getTransactions();
@@ -255,9 +271,13 @@ export function createCapacitorStoreKitStoreSdk(input) {
255
271
  throw evidenceInvalid('StoreKit returned an invalid transaction list.');
256
272
  }
257
273
  const transaction = response.transactions.find((candidate) => isValidStoreKitTransaction(candidate) && candidate.transactionId === transactionId);
258
- return transaction !== undefined
259
- && transaction.revokedAt === undefined
260
- && transaction.appAccountToken?.toLowerCase() === appAccountToken;
274
+ if (transaction === undefined || transaction.revokedAt !== undefined) {
275
+ return 'refused';
276
+ }
277
+ if (transaction.type === 'non_consumable' && transaction.unfinished === false) {
278
+ return 'finished';
279
+ }
280
+ return transaction.appAccountToken?.toLowerCase() === appAccountToken ? 'finish' : 'refused';
261
281
  };
262
282
  return {
263
283
  store: appStoreStoreId,
@@ -310,10 +330,11 @@ export function createCapacitorStoreKitStoreSdk(input) {
310
330
  throw evidenceInvalid('StoreKit returned an invalid purchase result.');
311
331
  }
312
332
  },
313
- async getAvailablePurchases() {
333
+ async getAvailablePurchases(request) {
314
334
  // Pending (Ask to Buy) transactions never appear in `Transaction.unfinished`
315
335
  // before approval, so `includePending` has nothing to filter here.
316
336
  requireIos();
337
+ const restore = request?.restore === true;
317
338
  const appAccountToken = await resolveAppAccountToken();
318
339
  const response = await sdk.getTransactions();
319
340
  if (!isRecord(response) || !Array.isArray(response.transactions)) {
@@ -341,7 +362,7 @@ export function createCapacitorStoreKitStoreSdk(input) {
341
362
  continue;
342
363
  }
343
364
  seen.add(transaction.transactionId);
344
- if (admit(transaction, appAccountToken)) {
365
+ if (admit(transaction, appAccountToken, restore)) {
345
366
  purchases.push(toSdkPurchase(transaction));
346
367
  }
347
368
  }
@@ -364,8 +385,9 @@ export function createCapacitorStoreKitStoreSdk(input) {
364
385
  const promise = (async () => {
365
386
  // Same match the native plugin applies: missing, revoked, unbound, or
366
387
  // another account never reaches `transaction.finish()`.
367
- if (!(await finishPermitted(transactionId, appAccountToken))) {
368
- return { finished: false };
388
+ const permitted = await finishPermitted(transactionId, appAccountToken);
389
+ if (permitted !== 'finish') {
390
+ return { finished: permitted === 'finished' };
369
391
  }
370
392
  const reported = await sdk.finishTransaction({
371
393
  transactionId,
@@ -1,7 +1,14 @@
1
1
  import Capacitor
2
2
  import Foundation
3
- import StoreKit
3
+ import MpgdStoreKitBridge
4
+ import OpenIAP
4
5
 
6
+ /// The `CapacitorStoreKit` plugin: the 0.3 JavaScript contract over the
7
+ /// OpenIAP Apple core. OpenIAP talks to StoreKit 2; this class only parses
8
+ /// Capacitor calls, applies `StoreKitTransactionPolicy`, and answers in the
9
+ /// payload shapes the 0.3 plugin defined. Nothing here grants, and nothing
10
+ /// here finishes a transaction except `finishTransaction`, which the game
11
+ /// calls only after its backend confirmed the ledger grant.
5
12
  @objc(CapacitorStoreKitPlugin)
6
13
  public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
7
14
  public let identifier = "CapacitorStoreKitPlugin"
@@ -14,45 +21,57 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
14
21
  CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
15
22
  ]
16
23
 
17
- private var updatesTask: Task<Void, Never>?
24
+ private let connection = OpenIapAppleConnection()
25
+ private let revocations = RevocationUpdatesShim()
26
+
27
+ private var module: OpenIapModule { connection.module }
18
28
 
19
29
  @objc override public func load() {
20
30
  super.load()
21
- updatesTask = Task { @MainActor [weak self] in
22
- for await result in Transaction.updates {
23
- guard !Task.isCancelled else { return }
24
- if let payload = Self.verifiedPayload(result) {
25
- self?.notifyListeners("transactionUpdated", data: payload)
31
+ // OpenIAP delivers verified, non-revoked updates (and the result of a
32
+ // purchase call, which StoreSdk and the reconciler deduplicate); the
33
+ // shim delivers verified revocations OpenIAP skips. Neither is
34
+ // retained for a listener that attaches later: `getTransactions` is
35
+ // the authoritative recovery query.
36
+ connection.start { [weak self] purchase in
37
+ guard let transaction = StoreKitTransactionPolicy.transaction(from: purchase) else { return }
38
+ self?.notifyListeners("transactionUpdated", data: StoreKitTransactionPolicy.payload(transaction))
39
+ }
40
+ if connection.providerError == nil {
41
+ revocations.start { [weak self] transaction in
42
+ // Delivered on the main actor, as OpenIAP's listener and the
43
+ // 0.3 update loop were, never from the shim's detached task.
44
+ Task { @MainActor in
45
+ self?.notifyListeners("transactionUpdated", data: StoreKitTransactionPolicy.payload(transaction))
26
46
  }
27
47
  }
28
48
  }
29
49
  }
30
50
 
31
51
  deinit {
32
- updatesTask?.cancel()
52
+ revocations.stop()
53
+ connection.stop()
54
+ }
55
+
56
+ /// Rejects the call when OpenIAP did not select the Apple provider.
57
+ private func providerReady(_ call: CAPPluginCall) -> Bool {
58
+ guard let message = connection.providerError else { return true }
59
+ call.reject(message, "STOREKIT_CONFIGURATION_ERROR")
60
+ return false
33
61
  }
34
62
 
35
63
  @objc func getProducts(_ call: CAPPluginCall) {
36
64
  guard let identifiers = call.options["productIds"] as? [String],
37
65
  !identifiers.isEmpty, identifiers.count <= 100,
38
- identifiers.allSatisfy({ Self.validProductId($0) }) else {
66
+ identifiers.allSatisfy({ StoreKitTransactionPolicy.isValidProductId($0) }) else {
39
67
  call.reject("Provide 1-100 valid product IDs.", "STOREKIT_INVALID_PRODUCTS")
40
68
  return
41
69
  }
42
- Task { @MainActor in
70
+ guard providerReady(call) else { return }
71
+ Task { @MainActor [module] in
43
72
  do {
44
- let products = try await Product.products(for: identifiers)
45
- let values: [[String: Any]] = products.compactMap { product in
46
- guard let type = Self.oneTimeType(product.type) else { return nil }
47
- return [
48
- "productId": product.id,
49
- "type": type,
50
- "title": product.displayName,
51
- "description": product.description,
52
- "formattedPrice": product.displayPrice,
53
- "currencyCode": product.priceFormatStyle.currencyCode
54
- ]
55
- }
73
+ let result = try await module.fetchProducts(ProductRequest(skus: identifiers, type: .inApp))
74
+ let values = Self.iosProducts(result).compactMap(StoreKitTransactionPolicy.productPayload)
56
75
  call.resolve(["products": values])
57
76
  } catch {
58
77
  call.reject("StoreKit product lookup failed.", "STOREKIT_UNAVAILABLE", error)
@@ -61,46 +80,60 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
61
80
  }
62
81
 
63
82
  @objc func purchase(_ call: CAPPluginCall) {
64
- guard let productId = call.getString("productId"), Self.validProductId(productId),
65
- let rawAccountToken = call.getString("appAccountToken"),
66
- let accountToken = UUID(uuidString: rawAccountToken) else {
83
+ guard let productId = call.getString("productId"),
84
+ StoreKitTransactionPolicy.isValidProductId(productId),
85
+ let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
86
+ call.getString("appAccountToken"), allowNil: true) else {
67
87
  call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
68
88
  return
69
89
  }
70
- Task { @MainActor in
71
- let product: Product
90
+ guard providerReady(call) else { return }
91
+ Task { @MainActor [module] in
72
92
  do {
73
- guard let found = try await Product.products(for: [productId]).first,
74
- found.id == productId,
75
- Self.oneTimeType(found.type) != nil else {
93
+ let result = try await module.fetchProducts(ProductRequest(skus: [productId], type: .inApp))
94
+ guard let product = Self.iosProducts(result).first(where: { $0.id == productId }),
95
+ MpgdOneTimeType(productType: product.typeIOS) != nil else {
76
96
  call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
77
97
  return
78
98
  }
79
- product = found
80
99
  } catch {
81
100
  call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
82
101
  return
83
102
  }
84
- do {
85
- let result = try await product.purchase(options: [.appAccountToken(accountToken)])
86
- switch result {
87
- case .success(let verification):
88
- guard let payload = Self.verifiedPayload(verification),
89
- payload["productId"] as? String == productId,
90
- (payload["appAccountToken"] as? String)?.lowercased()
91
- == accountToken.uuidString.lowercased() else {
92
- call.reject("StoreKit transaction failed verification or account binding.",
93
- "STOREKIT_TRANSACTION_UNVERIFIED")
94
- return
95
- }
103
+ let request = RequestPurchaseProps(
104
+ request: .purchase(RequestPurchasePropsByPlatforms(apple: RequestPurchaseIosProps(
96
105
  // Never finish here: the game backend must commit its ledger grant first.
97
- call.resolve(["status": "purchased", "transaction": payload])
106
+ andDangerouslyFinishTransactionAutomatically: false,
107
+ appAccountToken: accountToken,
108
+ sku: productId
109
+ ))),
110
+ type: .inApp
111
+ )
112
+ do {
113
+ let outcome = try await module.requestPurchase(request)
114
+ guard case .purchase(let purchased?) = outcome,
115
+ case .purchaseIos(let ios) = purchased,
116
+ let transaction = StoreKitTransactionPolicy.transaction(from: ios),
117
+ StoreKitTransactionPolicy.matchesPurchaseRequest(
118
+ transaction, productId: productId, appAccountToken: accountToken) else {
119
+ call.reject("StoreKit transaction failed verification or account binding.",
120
+ "STOREKIT_TRANSACTION_UNVERIFIED")
121
+ return
122
+ }
123
+ call.resolve([
124
+ "status": "purchased",
125
+ "transaction": StoreKitTransactionPolicy.payload(transaction)
126
+ ])
127
+ } catch let error as PurchaseError {
128
+ switch StoreKitTransactionPolicy.purchaseFailure(error.code) {
129
+ case .cancelled:
130
+ call.resolve(["status": "cancelled"])
98
131
  case .pending:
132
+ // Ask to Buy: the approved transaction arrives through
133
+ // `transactionUpdated` or `getTransactions` later.
99
134
  call.resolve(["status": "pending"])
100
- case .userCancelled:
101
- call.resolve(["status": "cancelled"])
102
- @unknown default:
103
- call.reject("StoreKit returned an unknown purchase result.", "STOREKIT_UNKNOWN_RESULT")
135
+ case .rejected(let code, let message):
136
+ call.reject(message, code, error)
104
137
  }
105
138
  } catch {
106
139
  // The purchase sheet may have completed before an error reached us.
@@ -111,36 +144,28 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
111
144
  }
112
145
 
113
146
  @objc func getTransactions(_ call: CAPPluginCall) {
147
+ guard providerReady(call) else { return }
114
148
  Task {
115
- var byId: [String: [String: Any]] = [:]
116
- for await result in Transaction.unfinished {
117
- if var payload = Self.verifiedPayload(result),
118
- let id = payload["transactionId"] as? String {
119
- payload["unfinished"] = true
120
- byId[id] = payload
121
- }
122
- }
123
- // Current non-consumable entitlements are listed too.
124
- // `unfinished: false` means StoreKit already finished them; the SDK
125
- // still lists those durables so a finish that raced the ledger
126
- // grant can be verified. The server ledger dedupes.
127
- for await result in Transaction.currentEntitlements {
128
- if var payload = Self.verifiedPayload(result),
129
- payload["type"] as? String == "non_consumable",
130
- let id = payload["transactionId"] as? String,
131
- byId[id] == nil {
132
- payload["unfinished"] = false
133
- byId[id] = payload
134
- }
135
- }
136
- call.resolve(["transactions": Array(byId.values)])
149
+ // Verified `Transaction.unfinished`, then current entitlements, read
150
+ // by `RecoveryReadShim`: an unverified entry is skipped on its own
151
+ // (0.3 behavior) instead of failing the whole OpenIAP query. Never
152
+ // `Transaction.all`: finished consumables must not return to the
153
+ // recovery list. No account filter: the TypeScript SDK admits.
154
+ let unfinished = await RecoveryReadShim.unfinished()
155
+ let entitlements = await RecoveryReadShim.currentEntitlements()
156
+ call.resolve(["transactions": StoreKitTransactionPolicy.listedTransactions(
157
+ unfinished: unfinished,
158
+ entitlements: entitlements
159
+ )])
137
160
  }
138
161
  }
139
162
 
140
163
  @objc func sync(_ call: CAPPluginCall) {
141
- Task { @MainActor in
164
+ guard providerReady(call) else { return }
165
+ // User-initiated restore only (`AppStore.sync` may show sign-in UI).
166
+ Task { @MainActor [module] in
142
167
  do {
143
- try await AppStore.sync()
168
+ _ = try await module.syncIOS()
144
169
  call.resolve(["synced": true])
145
170
  } catch {
146
171
  call.reject("StoreKit account synchronization failed.", "STOREKIT_SYNC_FAILED", error)
@@ -155,67 +180,56 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
155
180
  // SDK supplies the session token and applies the same match before
156
181
  // calling. A signed server finish capability is still required to stop
157
182
  // a webview caller who read the token from `getTransactions`.
158
- guard let rawId = call.getString("transactionId"), let id = UInt64(rawId),
183
+ guard let transactionId = call.getString("transactionId"), UInt64(transactionId) != nil,
159
184
  let ledgerEntryId = call.getString("ledgerEntryId"),
160
185
  !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
161
- let rawAccountToken = call.getString("appAccountToken"),
162
- let accountToken = UUID(uuidString: rawAccountToken),
163
- accountToken.uuidString.lowercased()
164
- != "00000000-0000-0000-0000-000000000000" else {
186
+ let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
187
+ call.getString("appAccountToken")) else {
165
188
  call.reject(
166
189
  "A transaction ID, confirmed ledger entry, and app account token are required.",
167
190
  "STOREKIT_FINISH_REQUIRES_GRANT"
168
191
  )
169
192
  return
170
193
  }
171
- let expectedToken = accountToken.uuidString.lowercased()
172
- Task {
173
- for await result in Transaction.all {
174
- guard case .verified(let transaction) = result,
175
- transaction.id == id,
176
- transaction.revocationDate == nil,
177
- Self.oneTimeType(transaction.productType) != nil,
178
- transaction.appAccountToken?.uuidString.lowercased() == expectedToken
179
- else { continue }
180
- await transaction.finish()
194
+ guard providerReady(call) else { return }
195
+ Task { [module] in
196
+ do {
197
+ // The verified transaction behind the id, through the same read
198
+ // as `getTransactions` (an unverified sibling never blocks it):
199
+ // unfinished first, then the StoreKit history
200
+ // (`Transaction.all`) the 0.3 plugin searched, so finishing an
201
+ // already-finished durable stays an idempotent `finished: true`.
202
+ var candidate = StoreKitTransactionPolicy.finishable(
203
+ transactionId: transactionId,
204
+ appAccountToken: accountToken,
205
+ in: await RecoveryReadShim.unfinished())
206
+ if candidate == nil {
207
+ candidate = StoreKitTransactionPolicy.finishable(
208
+ transactionId: transactionId,
209
+ appAccountToken: accountToken,
210
+ in: await RecoveryReadShim.history())
211
+ }
212
+ guard let match = candidate else {
213
+ call.resolve(["finished": false])
214
+ return
215
+ }
216
+ // OpenIAP finishes it (and checks the Apple storeId).
217
+ try await module.finishTransaction(
218
+ purchase: .purchaseIos(StoreKitTransactionPolicy.finishInput(match)),
219
+ isConsumable: match.type == .consumable)
181
220
  call.resolve(["finished": true])
182
- return
221
+ } catch {
222
+ call.reject("StoreKit could not finish the transaction.",
223
+ StoreKitTransactionPolicy.queryFailureCode(error), error)
183
224
  }
184
- call.resolve(["finished": false])
185
225
  }
186
226
  }
187
227
 
188
- private static func verifiedPayload(
189
- _ result: VerificationResult<Transaction>
190
- ) -> [String: Any]? {
191
- guard case .verified(let transaction) = result,
192
- let type = oneTimeType(transaction.productType) else { return nil }
193
- var payload: [String: Any] = [
194
- "transactionId": String(transaction.id),
195
- "originalTransactionId": String(transaction.originalID),
196
- "productId": transaction.productID,
197
- "type": type,
198
- "purchasedAt": ISO8601DateFormatter().string(from: transaction.purchaseDate),
199
- "signedTransaction": result.jwsRepresentation
200
- ]
201
- if let accountToken = transaction.appAccountToken {
202
- payload["appAccountToken"] = accountToken.uuidString.lowercased()
203
- }
204
- if let revocationDate = transaction.revocationDate {
205
- payload["revokedAt"] = ISO8601DateFormatter().string(from: revocationDate)
228
+ private static func iosProducts(_ result: FetchProductsResult) -> [ProductIOS] {
229
+ guard case .products(let products?) = result else { return [] }
230
+ return products.compactMap { product in
231
+ if case .productIos(let ios) = product { return ios }
232
+ return nil
206
233
  }
207
- return payload
208
- }
209
-
210
- private static func oneTimeType(_ type: Product.ProductType) -> String? {
211
- switch type {
212
- case .consumable: return "consumable"
213
- case .nonConsumable: return "non_consumable"
214
- default: return nil
215
- }
216
- }
217
-
218
- private static func validProductId(_ value: String) -> Bool {
219
- value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
220
234
  }
221
235
  }
@@ -0,0 +1,68 @@
1
+ import Foundation
2
+ import OpenIAP
3
+
4
+ /// The OpenIAP Apple connection one Capacitor plugin instance owns.
5
+ ///
6
+ /// This is the store-neutral half of the bridge: it selects the Apple
7
+ /// provider explicitly, checks that the selected provider really is `apple`,
8
+ /// keeps one OpenIAP purchase listener for the plugin's lifetime, and closes
9
+ /// the connection when the plugin goes away. mpgd policy (which transactions
10
+ /// are reported and finished) lives in `StoreKitTransactionPolicy`.
11
+ public final class OpenIapAppleConnection {
12
+ public let module: OpenIapModule
13
+ private var purchaseSubscription: Subscription?
14
+ private var started = false
15
+
16
+ /// The Apple provider is selected explicitly rather than through the
17
+ /// `dev.hyo.openiap.PROVIDER` Info.plist key, so a host app cannot route
18
+ /// mpgd's App Store evidence through another store's provider.
19
+ public init(module: OpenIapModule = OpenIapModule(factory: OpenIapAppleProviderFactory())) {
20
+ self.module = module
21
+ }
22
+
23
+ /// The provider error, or nil when OpenIAP selected the Apple provider.
24
+ /// Every plugin method rejects while this is non-nil.
25
+ public var providerError: String? {
26
+ guard let storeId = module.storeId else {
27
+ return "The OpenIAP Apple provider could not be selected."
28
+ }
29
+ guard storeId == StoreKitTransactionPolicy.appleStoreId else {
30
+ return "OpenIAP selected the \(storeId) provider; this plugin requires apple."
31
+ }
32
+ return nil
33
+ }
34
+
35
+ /// Opens the connection and attaches the single purchase listener. Safe to
36
+ /// call more than once; later calls are no-ops. `onPurchase` receives every
37
+ /// verified purchase OpenIAP reports, replays included
38
+ /// (`dedupeTransactionIOS: false`), so the plugin keeps forwarding each
39
+ /// StoreKit update the way the 0.3 `Transaction.updates` loop did.
40
+ public func start(onPurchase: @escaping @Sendable (PurchaseIOS) -> Void) {
41
+ guard !started, providerError == nil else { return }
42
+ started = true
43
+ purchaseSubscription = module.purchaseUpdatedListener({ purchase in
44
+ if case .purchaseIos(let ios) = purchase {
45
+ onPurchase(ios)
46
+ }
47
+ }, options: PurchaseUpdatedListenerOptions(dedupeTransactionIOS: false))
48
+ Task { [module] in
49
+ // Every OpenIAP call also ensures the connection; this only starts
50
+ // the transaction listener at launch, as the 0.3 plugin did.
51
+ _ = try? await module.initConnection()
52
+ }
53
+ }
54
+
55
+ /// Removes the listener and ends the connection. The plugin calls this
56
+ /// from `deinit`; nothing is finished or cancelled in StoreKit.
57
+ public func stop() {
58
+ if let subscription = purchaseSubscription {
59
+ module.removeListener(subscription)
60
+ purchaseSubscription = nil
61
+ }
62
+ guard started else { return }
63
+ started = false
64
+ Task { [module] in
65
+ _ = try? await module.endConnection()
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,85 @@
1
+ import Foundation
2
+ import StoreKit
3
+
4
+ /// Compatibility shim: the verified, read-only transaction lists behind
5
+ /// `getTransactions` and the `finishTransaction` lookup.
6
+ ///
7
+ /// Why: OpenIAP Apple 4.0.5's `getPendingTransactionsIOS` and
8
+ /// `getAvailablePurchases` throw on the first transaction StoreKit cannot
9
+ /// verify (`OpenIapStoreKitModule.checkVerified`), so one unverifiable
10
+ /// transaction on a device would hide every verified one from recovery and
11
+ /// block finishing them. The 0.3 plugin skipped an unverified transaction on
12
+ /// its own. This shim reads `Transaction.unfinished`,
13
+ /// `Transaction.currentEntitlements`, and `Transaction.all` the same way and
14
+ /// classifies each entry (`StoreKitTransactionPolicy.ReadEntry`).
15
+ ///
16
+ /// Scope: reads only. It never purchases, finishes, or filters by account
17
+ /// (the TypeScript SDK admits transactions for the session account). Purchase,
18
+ /// finish, and the update listener stay on OpenIAP; the finish itself is
19
+ /// `OpenIapModule.finishTransaction`, whose own `Transaction.unfinished` scan
20
+ /// skips unverified entries.
21
+ ///
22
+ /// Errors: these StoreKit sequences do not throw. Nothing here turns a
23
+ /// failure into an empty list; an empty list means StoreKit returned none.
24
+ ///
25
+ /// Removal: delete this shim once the pinned OpenIAP release offers a
26
+ /// verified-only read that skips (and reports) unverified entries (upstream
27
+ /// request 3 in `docs/specs/commerce/openiap-upstream-proposal.md`).
28
+ public enum RecoveryReadShim {
29
+ /// `Transaction.unfinished`.
30
+ public static func unfinished() async -> [StoreKitTransactionPolicy.ReadEntry] {
31
+ await read(StoreKit.Transaction.unfinished)
32
+ }
33
+
34
+ /// `Transaction.currentEntitlements` (active non-consumables and
35
+ /// subscriptions; revoked ones are not entitlements).
36
+ public static func currentEntitlements() async -> [StoreKitTransactionPolicy.ReadEntry] {
37
+ await read(StoreKit.Transaction.currentEntitlements)
38
+ }
39
+
40
+ /// `Transaction.all`, the history the 0.3 finish searched.
41
+ public static func history() async -> [StoreKitTransactionPolicy.ReadEntry] {
42
+ await read(StoreKit.Transaction.all)
43
+ }
44
+
45
+ static func read(_ sequence: StoreKit.Transaction.Transactions) async -> [StoreKitTransactionPolicy.ReadEntry] {
46
+ var entries: [StoreKitTransactionPolicy.ReadEntry] = []
47
+ for await result in sequence {
48
+ entries.append(entry(result))
49
+ }
50
+ return entries
51
+ }
52
+
53
+ static func entry(
54
+ _ result: VerificationResult<StoreKit.Transaction>
55
+ ) -> StoreKitTransactionPolicy.ReadEntry {
56
+ guard case .verified(let transaction) = result else { return .unverified }
57
+ guard let mapped = MpgdStoreKitTransaction(verified: transaction, jws: result.jwsRepresentation) else {
58
+ return .notOneTime
59
+ }
60
+ return .oneTime(mapped)
61
+ }
62
+ }
63
+
64
+ extension MpgdStoreKitTransaction {
65
+ /// The 0.3 fields of a StoreKit-verified one-time transaction; nil for a
66
+ /// subscription. Shared by the read and revocation shims.
67
+ init?(verified transaction: StoreKit.Transaction, jws: String) {
68
+ let type: MpgdOneTimeType
69
+ switch transaction.productType {
70
+ case .consumable: type = .consumable
71
+ case .nonConsumable: type = .nonConsumable
72
+ default: return nil
73
+ }
74
+ self.init(
75
+ transactionId: String(transaction.id),
76
+ originalTransactionId: String(transaction.originalID),
77
+ productId: transaction.productID,
78
+ type: type,
79
+ appAccountToken: transaction.appAccountToken?.uuidString,
80
+ purchasedAtMillis: transaction.purchaseDate.timeIntervalSince1970 * 1000,
81
+ revokedAtMillis: transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 },
82
+ signedTransaction: jws
83
+ )
84
+ }
85
+ }
@@ -0,0 +1,57 @@
1
+ import Foundation
2
+ import StoreKit
3
+
4
+ /// Compatibility shim: forwards verified **revoked** one-time transactions.
5
+ ///
6
+ /// Why: OpenIAP Apple 4.0.5's `Transaction.updates` listener skips any
7
+ /// transaction with a `revocationDate` (`OpenIapStoreKitModule`
8
+ /// `startTransactionListener`, "Skipping revoked transaction"). The 0.3 mpgd
9
+ /// plugin forwarded those updates as `transactionUpdated` with `revokedAt`,
10
+ /// and `StoreSdk` turns them into `state: 'revoked'` so the game asks its
11
+ /// server to re-check the grant. Dropping them would silently remove the
12
+ /// refund signal.
13
+ ///
14
+ /// Scope: this observes `Transaction.updates` only to read revocations. It
15
+ /// never purchases, lists, or finishes anything, and it ignores every
16
+ /// non-revoked update (OpenIAP's listener delivers those). The signal is a
17
+ /// prompt for the server to re-verify, never a ledger clawback by itself.
18
+ ///
19
+ /// Removal: delete this shim once the pinned OpenIAP release can deliver
20
+ /// revoked transactions to its purchase listener (the upstream proposal in
21
+ /// `docs/specs/commerce/openiap-upstream-proposal.md` asks for that).
22
+ public final class RevocationUpdatesShim {
23
+ private var task: Task<Void, Never>?
24
+
25
+ public init() {}
26
+
27
+ public func start(onRevoked: @escaping @Sendable (MpgdStoreKitTransaction) -> Void) {
28
+ guard task == nil else { return }
29
+ task = Task.detached(priority: .utility) {
30
+ for await result in StoreKit.Transaction.updates {
31
+ if Task.isCancelled { return }
32
+ guard case .verified(let transaction) = result,
33
+ let revoked = Self.revokedTransaction(transaction, jws: result.jwsRepresentation) else {
34
+ continue
35
+ }
36
+ onRevoked(revoked)
37
+ }
38
+ }
39
+ }
40
+
41
+ public func stop() {
42
+ task?.cancel()
43
+ task = nil
44
+ }
45
+
46
+ deinit {
47
+ task?.cancel()
48
+ }
49
+
50
+ static func revokedTransaction(
51
+ _ transaction: StoreKit.Transaction,
52
+ jws: String
53
+ ) -> MpgdStoreKitTransaction? {
54
+ guard transaction.revocationDate != nil else { return nil }
55
+ return MpgdStoreKitTransaction(verified: transaction, jws: jws)
56
+ }
57
+ }
@@ -0,0 +1,356 @@
1
+ import Foundation
2
+ import OpenIAP
3
+
4
+ /// The one-time product kinds the mpgd StoreKit plugin sells and reports.
5
+ /// Raw values are the plugin's wire values (`StoreKitTransaction.type`).
6
+ public enum MpgdOneTimeType: String, Equatable, Sendable {
7
+ case consumable = "consumable"
8
+ case nonConsumable = "non_consumable"
9
+
10
+ /// The App Store product kind as StoreKit and OpenIAP describe a product.
11
+ public init?(productType: ProductTypeIOS) {
12
+ switch productType {
13
+ case .consumable: self = .consumable
14
+ case .nonConsumable: self = .nonConsumable
15
+ default: return nil
16
+ }
17
+ }
18
+
19
+ /// The `type` claim of a StoreKit-verified JWS transaction
20
+ /// (`JWSTransactionDecodedPayload.type`): `Consumable` or `Non-Consumable`.
21
+ /// Subscriptions and unknown values are not one-time purchases.
22
+ public init?(transactionTypeClaim: String) {
23
+ switch transactionTypeClaim {
24
+ case "Consumable": self = .consumable
25
+ case "Non-Consumable": self = .nonConsumable
26
+ default: return nil
27
+ }
28
+ }
29
+ }
30
+
31
+ /// The fields of one verified StoreKit transaction the mpgd plugin reports.
32
+ /// Both the OpenIAP purchase path and the revocation shim build this value,
33
+ /// so every `transactionUpdated` and `getTransactions` entry has one shape.
34
+ public struct MpgdStoreKitTransaction: Equatable, Sendable {
35
+ public let transactionId: String
36
+ public let originalTransactionId: String
37
+ public let productId: String
38
+ public let type: MpgdOneTimeType
39
+ /// Lowercased UUID, as the 0.3 native plugin reported it.
40
+ public let appAccountToken: String?
41
+ public let purchasedAtMillis: Double
42
+ public let revokedAtMillis: Double?
43
+ /// The StoreKit-verified JWS; the backend verifies it again.
44
+ public let signedTransaction: String
45
+
46
+ public init(
47
+ transactionId: String,
48
+ originalTransactionId: String,
49
+ productId: String,
50
+ type: MpgdOneTimeType,
51
+ appAccountToken: String?,
52
+ purchasedAtMillis: Double,
53
+ revokedAtMillis: Double?,
54
+ signedTransaction: String
55
+ ) {
56
+ self.transactionId = transactionId
57
+ self.originalTransactionId = originalTransactionId
58
+ self.productId = productId
59
+ self.type = type
60
+ self.appAccountToken = appAccountToken?.lowercased()
61
+ self.purchasedAtMillis = purchasedAtMillis
62
+ self.revokedAtMillis = revokedAtMillis
63
+ self.signedTransaction = signedTransaction
64
+ }
65
+ }
66
+
67
+ /// mpgd policy over OpenIAP's canonical Apple purchase: which transactions the
68
+ /// plugin reports, the payload shape it reports them in, which ones it may
69
+ /// finish, and how OpenIAP errors become the plugin's existing codes. Nothing
70
+ /// here calls StoreKit; the Capacitor plugin and tests drive it directly.
71
+ public enum StoreKitTransactionPolicy {
72
+ /// The provider the plugin requires. OpenIAP's Apple provider reports
73
+ /// `apple`; mpgd names the same store `app-store` on the JavaScript side.
74
+ public static let appleStoreId = StoreIds.Apple
75
+
76
+ // MARK: - Mapping
77
+
78
+ /// What the plugin makes of one OpenIAP Apple purchase (the `purchase`
79
+ /// result and listener events).
80
+ public enum Classification: Equatable {
81
+ /// A verified consumable or non-consumable the plugin reports.
82
+ case oneTime(MpgdStoreKitTransaction)
83
+ /// A subscription (its JWS `type` claim says so); never reported.
84
+ case notOneTime
85
+ /// No readable StoreKit JWS, no `type` claim, another provider, or no
86
+ /// id. Never reported from the OpenIAP purchase and listener paths.
87
+ case unreadable
88
+ }
89
+
90
+ public static func classify(_ purchase: PurchaseIOS) -> Classification {
91
+ guard purchase.storeId == appleStoreId, purchase.store == .apple,
92
+ !purchase.id.isEmpty, !purchase.productId.isEmpty,
93
+ let jws = purchase.purchaseToken, isCompactJws(jws),
94
+ let claims = jwsPayload(jws),
95
+ let typeClaim = claims["type"] as? String else {
96
+ return .unreadable
97
+ }
98
+ guard let type = MpgdOneTimeType(transactionTypeClaim: typeClaim) else {
99
+ return .notOneTime
100
+ }
101
+ return .oneTime(MpgdStoreKitTransaction(
102
+ transactionId: purchase.id,
103
+ // OpenIAP omits an original id of 0; the 0.3 plugin always sent one.
104
+ originalTransactionId: purchase.originalTransactionIdentifierIOS ?? purchase.id,
105
+ productId: purchase.productId,
106
+ type: type,
107
+ appAccountToken: purchase.appAccountToken,
108
+ purchasedAtMillis: purchase.transactionDate,
109
+ revokedAtMillis: purchase.revocationDateIOS,
110
+ signedTransaction: jws
111
+ ))
112
+ }
113
+
114
+ /// The verified one-time transaction an OpenIAP Apple purchase describes,
115
+ /// or nil for anything the plugin does not report.
116
+ public static func transaction(from purchase: PurchaseIOS) -> MpgdStoreKitTransaction? {
117
+ if case .oneTime(let transaction) = classify(purchase) { return transaction }
118
+ return nil
119
+ }
120
+
121
+ /// The `StoreKitTransaction` payload of the 0.3 native plugin, key for key.
122
+ /// `unfinished` is set only on `getTransactions` entries.
123
+ public static func payload(
124
+ _ transaction: MpgdStoreKitTransaction,
125
+ unfinished: Bool? = nil
126
+ ) -> [String: Any] {
127
+ var payload: [String: Any] = [
128
+ "transactionId": transaction.transactionId,
129
+ "originalTransactionId": transaction.originalTransactionId,
130
+ "productId": transaction.productId,
131
+ "type": transaction.type.rawValue,
132
+ "purchasedAt": isoTimestamp(milliseconds: transaction.purchasedAtMillis),
133
+ "signedTransaction": transaction.signedTransaction
134
+ ]
135
+ if let token = transaction.appAccountToken {
136
+ payload["appAccountToken"] = token
137
+ }
138
+ if let revoked = transaction.revokedAtMillis {
139
+ payload["revokedAt"] = isoTimestamp(milliseconds: revoked)
140
+ }
141
+ if let unfinished {
142
+ payload["unfinished"] = unfinished
143
+ }
144
+ return payload
145
+ }
146
+
147
+ /// The `StoreKitProduct` payload of a one-time product, or nil for a
148
+ /// subscription the plugin does not sell.
149
+ public static func productPayload(_ product: ProductIOS) -> [String: Any]? {
150
+ guard let type = MpgdOneTimeType(productType: product.typeIOS) else {
151
+ return nil
152
+ }
153
+ return [
154
+ "productId": product.id,
155
+ "type": type.rawValue,
156
+ "title": product.displayNameIOS,
157
+ "description": product.description,
158
+ "formattedPrice": product.displayPrice,
159
+ "currencyCode": product.currency
160
+ ]
161
+ }
162
+
163
+ /// One entry of a StoreKit read (`RecoveryReadShim`): a verified one-time
164
+ /// transaction, a verified subscription the plugin never reports, or a
165
+ /// transaction StoreKit could not verify, which is skipped on its own as
166
+ /// the 0.3 plugin did, so it never hides or blocks a verified sibling.
167
+ public enum ReadEntry: Equatable, Sendable {
168
+ case oneTime(MpgdStoreKitTransaction)
169
+ case notOneTime
170
+ case unverified
171
+ }
172
+
173
+ /// `getTransactions`: every verified unfinished one-time transaction
174
+ /// (`unfinished: true`), then every current non-consumable entitlement not
175
+ /// already listed (`unfinished: false`), merged by transaction id.
176
+ /// Finished consumables are never added: they are not current
177
+ /// entitlements, and the game reads a consumable leaving this list as
178
+ /// evidence that its finish landed. No account filter here: the
179
+ /// TypeScript SDK admits transactions for the session account (and
180
+ /// restore handling needs to see the others).
181
+ public static func listedTransactions(
182
+ unfinished: [ReadEntry],
183
+ entitlements: [ReadEntry]
184
+ ) -> [[String: Any]] {
185
+ var seen = Set<String>()
186
+ var listed: [[String: Any]] = []
187
+ for entry in unfinished {
188
+ guard case .oneTime(let transaction) = entry,
189
+ seen.insert(transaction.transactionId).inserted else { continue }
190
+ listed.append(payload(transaction, unfinished: true))
191
+ }
192
+ for entry in entitlements {
193
+ guard case .oneTime(let transaction) = entry,
194
+ transaction.type == .nonConsumable,
195
+ seen.insert(transaction.transactionId).inserted else { continue }
196
+ listed.append(payload(transaction, unfinished: false))
197
+ }
198
+ return listed
199
+ }
200
+
201
+ /// The transaction `finishTransaction` may finish: the verified one-time
202
+ /// transaction with this id that is not revoked and whose app account
203
+ /// token equals the session token. OpenIAP finishes whatever it is given,
204
+ /// so this is the plugin's local gate; it is still not a signed server
205
+ /// capability (see `finishTransaction` in the plugin definitions).
206
+ public static func finishable(
207
+ transactionId: String,
208
+ appAccountToken: String,
209
+ in entries: [ReadEntry]
210
+ ) -> MpgdStoreKitTransaction? {
211
+ let expected = appAccountToken.lowercased()
212
+ for entry in entries {
213
+ guard case .oneTime(let transaction) = entry,
214
+ transaction.transactionId == transactionId,
215
+ transaction.revokedAtMillis == nil,
216
+ transaction.appAccountToken == expected else { continue }
217
+ return transaction
218
+ }
219
+ return nil
220
+ }
221
+
222
+ /// The OpenIAP purchase handed to `OpenIapModule.finishTransaction` for a
223
+ /// transaction the read resolved. OpenIAP finishes by transaction id
224
+ /// (looking it up in `Transaction.unfinished`, then
225
+ /// `Transaction.latest(for:)`) after checking `storeId`; these are the
226
+ /// fields it reads.
227
+ public static func finishInput(_ transaction: MpgdStoreKitTransaction) -> PurchaseIOS {
228
+ PurchaseIOS(
229
+ appAccountToken: transaction.appAccountToken,
230
+ id: transaction.transactionId,
231
+ isAutoRenewing: false,
232
+ originalTransactionIdentifierIOS: transaction.originalTransactionId,
233
+ productId: transaction.productId,
234
+ purchaseState: .purchased,
235
+ purchaseToken: transaction.signedTransaction,
236
+ quantity: 1,
237
+ revocationDateIOS: transaction.revokedAtMillis,
238
+ store: .apple,
239
+ storeId: appleStoreId,
240
+ transactionDate: transaction.purchasedAtMillis,
241
+ transactionId: transaction.transactionId
242
+ )
243
+ }
244
+
245
+ /// Whether a just-purchased transaction is the one the call asked for:
246
+ /// same product and bound to the token the purchase was started with.
247
+ public static func matchesPurchaseRequest(
248
+ _ transaction: MpgdStoreKitTransaction,
249
+ productId: String,
250
+ appAccountToken: String
251
+ ) -> Bool {
252
+ transaction.productId == productId
253
+ && transaction.appAccountToken == appAccountToken.lowercased()
254
+ && transaction.revokedAtMillis == nil
255
+ }
256
+
257
+ // MARK: - Errors
258
+
259
+ /// How a `requestPurchase` failure answers the plugin's `purchase` call.
260
+ public enum PurchaseFailure: Equatable {
261
+ case cancelled
262
+ /// Ask to Buy or another deferred payment: never a cancellation.
263
+ case pending
264
+ case rejected(code: String, message: String)
265
+ }
266
+
267
+ /// Maps an OpenIAP purchase error to the 0.3 plugin outcome. Anything that
268
+ /// may have happened after the purchase sheet opened stays
269
+ /// `STOREKIT_PURCHASE_UNCERTAIN`, so the game requeries instead of
270
+ /// assuming nothing was bought.
271
+ public static func purchaseFailure(_ code: ErrorCode) -> PurchaseFailure {
272
+ switch code {
273
+ case .userCancelled:
274
+ return .cancelled
275
+ case .deferredPayment, .pending:
276
+ return .pending
277
+ case .transactionValidationFailed:
278
+ return .rejected(code: "STOREKIT_TRANSACTION_UNVERIFIED",
279
+ message: "StoreKit transaction failed verification or account binding.")
280
+ case .skuNotFound, .itemUnavailable, .emptySkuList:
281
+ return .rejected(code: "STOREKIT_PRODUCT_UNAVAILABLE",
282
+ message: "The one-time product is unavailable.")
283
+ case .queryProduct:
284
+ return .rejected(code: "STOREKIT_PRODUCT_LOOKUP_FAILED",
285
+ message: "StoreKit product lookup failed.")
286
+ case .developerError:
287
+ return .rejected(code: "STOREKIT_INVALID_PURCHASE",
288
+ message: "A product ID and app account token are required.")
289
+ case .iapNotAvailable, .notPrepared, .initConnection:
290
+ // Raised before any purchase sheet: nothing was bought.
291
+ return .rejected(code: "STOREKIT_UNAVAILABLE",
292
+ message: "StoreKit purchases are unavailable on this device.")
293
+ case .unknown:
294
+ return .rejected(code: "STOREKIT_UNKNOWN_RESULT",
295
+ message: "StoreKit returned an unknown purchase result.")
296
+ default:
297
+ return .rejected(code: "STOREKIT_PURCHASE_UNCERTAIN",
298
+ message: "StoreKit purchase result is uncertain; requery transactions.")
299
+ }
300
+ }
301
+
302
+ /// The code an OpenIAP failure rejects with (`finishTransaction`). The
303
+ /// transaction lists come from `RecoveryReadShim`, which skips an
304
+ /// unverified entry instead of failing.
305
+ public static func queryFailureCode(_ error: Error) -> String {
306
+ if let purchaseError = error as? PurchaseError,
307
+ purchaseError.code == .transactionValidationFailed {
308
+ return "STOREKIT_TRANSACTION_UNVERIFIED"
309
+ }
310
+ return "STOREKIT_UNAVAILABLE"
311
+ }
312
+
313
+ // MARK: - Validation and formatting
314
+
315
+ public static func isValidProductId(_ value: String) -> Bool {
316
+ value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
317
+ }
318
+
319
+ /// A UUID app account token, lowercased; nil when invalid. `finish`
320
+ /// also refuses the nil UUID, as the 0.3 plugin did; `purchase` accepted
321
+ /// any UUID in 0.3 and still does (the StoreKit SDK never sends the nil one).
322
+ public static func normalizedAccountToken(_ value: String?, allowNil: Bool = false) -> String? {
323
+ guard let value, let uuid = UUID(uuidString: value) else { return nil }
324
+ let normalized = uuid.uuidString.lowercased()
325
+ return !allowNil && normalized == "00000000-0000-0000-0000-000000000000" ? nil : normalized
326
+ }
327
+
328
+ /// The 0.3 plugin's timestamp format: `ISO8601DateFormatter` defaults.
329
+ public static func isoTimestamp(milliseconds: Double) -> String {
330
+ ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: milliseconds / 1000))
331
+ }
332
+
333
+ static func isCompactJws(_ value: String) -> Bool {
334
+ let parts = value.split(separator: ".", omittingEmptySubsequences: false)
335
+ return parts.count == 3 && parts.allSatisfy { !$0.isEmpty }
336
+ }
337
+
338
+ /// The claims of a compact JWS StoreKit already verified. Only read for
339
+ /// the `type` claim OpenIAP's `PurchaseIOS` does not carry.
340
+ static func jwsPayload(_ jws: String) -> [String: Any]? {
341
+ let parts = jws.split(separator: ".", omittingEmptySubsequences: false)
342
+ guard parts.count == 3 else { return nil }
343
+ var base64 = String(parts[1])
344
+ .replacingOccurrences(of: "-", with: "+")
345
+ .replacingOccurrences(of: "_", with: "/")
346
+ while base64.count % 4 != 0 {
347
+ base64.append("=")
348
+ }
349
+ guard let data = Data(base64Encoded: base64),
350
+ let object = try? JSONSerialization.jsonObject(with: data),
351
+ let claims = object as? [String: Any] else {
352
+ return nil
353
+ }
354
+ return claims
355
+ }
356
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,23 +33,23 @@
33
33
  },
34
34
  "files": [
35
35
  "dist",
36
- "ios",
36
+ "ios/Sources",
37
37
  "Package.swift"
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.12.0",
41
+ "@mpgd/adapter-capacitor": "^0.14.0",
42
42
  "@mpgd/bridge": "^0.16.0",
43
- "@mpgd/platform": "^0.28.0"
43
+ "@mpgd/platform": "^0.30.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.32.1",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.12.5",
50
- "@mpgd/bridge": "0.16.0",
51
- "@mpgd/game-services": "0.28.1",
52
- "@mpgd/platform": "0.28.0"
49
+ "@mpgd/adapter-capacitor": "0.14.0",
50
+ "@mpgd/game-services": "0.30.0",
51
+ "@mpgd/platform": "0.30.0",
52
+ "@mpgd/bridge": "0.16.2"
53
53
  },
54
54
  "main": "./dist/index.js",
55
55
  "types": "./dist/index.d.ts",
@@ -59,6 +59,6 @@
59
59
  "scripts": {
60
60
  "check": "ttsc --noEmit",
61
61
  "lint": "ttsc --noEmit",
62
- "test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts"
62
+ "test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/openiap-engine-swap.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-restore-reinstall.test.ts"
63
63
  }
64
64
  }