@mpgd/capacitor-storekit 0.5.0 → 0.7.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/README.md CHANGED
@@ -59,14 +59,19 @@ over `getTransactions`, listing unfinished transactions and finished durables
59
59
  once (a current non-consumable entitlement the native side marks
60
60
  `unfinished: false` stays listed so a finish that raced the ledger grant can
61
61
  still be verified; the server ledger dedupes. A finished consumable is not
62
- listed. A `revokedAt` maps to `state: 'revoked'`, and one malformed
62
+ listed. A `revokedAt` maps to `state: 'revoked'`, and an unfinished revoked
63
+ transaction stays listed in that state so a refund before the finish can be
64
+ finished later (see Recovery); one malformed
63
65
  transaction is reported as a `purchaseError` without blocking its siblings);
64
- `finishTransaction({ transactionId, ledgerEntryId, isConsumable, retry? })`
66
+ `finishTransaction({ transactionId, ledgerEntryId, isConsumable, retry?, revoked? })`
65
67
  resolves the session app account token and finishes only a listed,
66
- non-revoked transaction bound to that token, or a durable of another token a
67
- restore shared to this player (see Recovery), and on a retry (`retry: true`)
68
+ non-revoked transaction bound to that token, or a durable of another token or
69
+ of no token a restore shared to this player (see Recovery), and on a retry (`retry: true`)
68
70
  answers `finished: true` without a native call for a consumable the list no
69
- longer carries (a lost acknowledgement, see Recovery);
71
+ longer carries (a lost acknowledgement, see Recovery); with `revoked: true`
72
+ (the SDK declares `acceptsRevokedFinish`; `ledgerEntryId` may then be
73
+ absent) it finishes only a listed, revoked consumable bound to that token (a
74
+ refund before the finish, see Recovery);
70
75
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
71
76
  and an availability probe as `getAvailability`, probing the configured
72
77
  SKUs batch by batch until one is sold. Every purchase carries the
@@ -116,7 +121,9 @@ where `signedTransaction` is the same JWS as `StorePurchase.token`.
116
121
  its `signedTransactionVerifier` (authenticity and transaction id binding)
117
122
  and then, by default, decides the grant on the transaction's current status
118
123
  from the App Store Server API, so a JWS retained from before a refund never
119
- grants; see the backend guide for the opt-in offline-only mode. Subscription
124
+ grants; see the backend guide for the opt-in offline-only mode, which instead
125
+ refuses a refund the backend recorded from App Store Server Notifications
126
+ (its `revocations` registry is required). Subscription
120
127
  mappings are rejected at construction. `getAppAccountToken` also filters
121
128
  recovery and finishing to the current player, so it must keep answering
122
129
  while sales are paused: pause new sales with the optional `purchaseGate`
@@ -162,6 +169,45 @@ and lists each skipped vector with its reason (host or server finish,
162
169
  quantity, server-push, an asynchronous store error event StoreKit never
163
170
  emits, and a pending transaction state StoreKit never reports).
164
171
 
172
+ ### Purchase sheet facts
173
+
174
+ The provider offers `CommerceProvider.presentation` (see "Store sheet facts"
175
+ in the `@mpgd/adapter-capacitor` README), so the gateway keeps the game
176
+ paused, and the game runtime keeps ads out, for the life of the purchase
177
+ sheet rather than until a result event. The native plugin reports `open`
178
+ right before OpenIAP calls `Product.purchase()` and `closed` when that call
179
+ returns or throws, before it answers; a call it refuses earlier (invalid
180
+ input, provider not ready, product unavailable) is `not-started`. An Ask to
181
+ Buy (`pending`) is `closed` when the call returns; its purchase still arrives
182
+ later through `transactionUpdated`. Facts reach JavaScript as
183
+ `purchasePresentation` events (retained, keyed by a random id per call) and
184
+ in the `presentation` field of the `purchase` answer or rejection data. The
185
+ adapter adds a 15-minute safety timeout.
186
+
187
+ Apple documents that `purchase(options:)` "displays the confirmation sheet"
188
+ and returns a `PurchaseResult`
189
+ ([purchase(options:)](https://developer.apple.com/documentation/storekit/product/purchase(options:)),
190
+ [purchase(confirmIn:options:)](https://developer.apple.com/documentation/storekit/product/purchase(confirmin:options:)-6dj6y)),
191
+ but not that it returns only once the sheet is dismissed. `closed` therefore
192
+ means "the purchase call ended"; that the sheet is gone by then is unverified
193
+ until device testing.
194
+
195
+ `getPresentationSupport` declares the facts. A web bundle running on a native
196
+ build from before them (a live update) gets `UNIMPLEMENTED` there: while an
197
+ adapter follows the facts, the store then reads `configuration-required` (the
198
+ shop sells nothing and the reconciler spends no idempotency key), and a
199
+ purchase that still gets through is refused with `CONFIGURATION_REQUIRED` /
200
+ `STOREKIT_PRESENTATION_UNSUPPORTED` instead of opening a sheet the game would
201
+ wait on; recovery is unaffected. `purchasePresentation: false` on the
202
+ provider keeps the result-based pause of earlier releases.
203
+
204
+ Device acceptance (not run; no device was available): the sheet cancelled, a
205
+ completed purchase, an Ask to Buy request, the app backgrounded with the
206
+ sheet up and resumed, and an iPad (sheet as a form sheet). Each must pause
207
+ the game from `open` to `closed`, and `closed` must not arrive while the sheet
208
+ is still visible. The steps and expected signals are in the kit's
209
+ [purchase device acceptance checklist](https://github.com/imjlk/mpgd-kit/blob/main/docs/PURCHASE_DEVICE_ACCEPTANCE.md).
210
+
165
211
  ### Generated games
166
212
 
167
213
  Games generated by `mpgd game create` (and the kit's private starter) ship
@@ -201,7 +247,7 @@ existing ledger grant, and calls `finishTransaction` only after a verified
201
247
  response with a ledger entry id. The StoreKit SDK and the native plugin both
202
248
  refuse to call `transaction.finish()` on a transaction whose app account
203
249
  token is missing or does not match the token they are given; the SDK gives the
204
- session's, except for a durable of another token (below). A caller who can read the token from the plugin can
250
+ session's, except for a durable of another token or of no token (below). A caller who can read the token from the plugin can
205
251
  still present it with a forged ledger entry id; that remaining hole needs a
206
252
  signed server finish capability. A listed, non-revoked durable StoreKit
207
253
  already finished (`unfinished: false`) answers `{ finished: true }` without a
@@ -217,8 +263,38 @@ durable takes nothing from the earlier guest: StoreKit keeps it in
217
263
  (`unfinished: false`). The native plugin is unchanged and still filters
218
264
  nothing by token on reads. A webview caller can therefore finish another
219
265
  token's unfinished durable, which, for the same reason, grants or removes
220
- nothing. A consumable of another token and a transaction bound to no token
221
- are never finished. Permanently rejected and account-mismatched
266
+ nothing.
267
+
268
+ A durable of the configured catalog still unfinished and bound to no token
269
+ (an offer code redeemed in the App Store, or another purchase made outside
270
+ the app, then restored; the backend's restore sharing grants it as
271
+ `APP_STORE_ACCOUNT_TOKEN_MISSING`) is finished with the explicit no-token
272
+ match: `finishTransaction({ transactionId, ledgerEntryId,
273
+ withoutAppAccountToken: true })`, with no `appAccountToken` field at all.
274
+ The native plugin then matches only a verified, non-revoked non-consumable
275
+ bound to no token; a missing token without the flag is still refused
276
+ (`STOREKIT_FINISH_REQUIRES_GRANT`), never read as "any token". The SDK sends
277
+ it only for a durable its catalog declares, for a record the reconciler
278
+ holds a ledger grant for, and only when the native build declares the match
279
+ through `getFinishSupport` (`{ withoutAppAccountToken: 1 }`). A native build
280
+ from before it answers that method with `UNIMPLEMENTED` (remembered) and would
281
+ refuse the call, so the SDK leaves the durable unfinished there and the record
282
+ keeps retrying `finishing`, as in earlier releases; the entitlement is
283
+ unaffected. Rebuild the app with this package's native code to finish it.
284
+ The catalog and ledger-grant conditions are the SDK's and the reconciler's;
285
+ the native match checks only the transaction itself and, like the token
286
+ match, is not a signed server capability, so a webview caller could finish
287
+ such a durable too, which grants or removes nothing.
288
+ Finishing takes nothing from anyone for the same reason as above:
289
+ StoreKit keeps a finished non-consumable in `Transaction.currentEntitlements`
290
+ ([Apple](https://developer.apple.com/documentation/storekit/transaction/currententitlements)).
291
+
292
+ A consumable of another token or of no token is never finished: the SDK
293
+ never admits it, the backend refuses one bound to no token for every
294
+ player (`APP_STORE_ACCOUNT_TOKEN_MISSING`), and it stays in
295
+ `Transaction.unfinished`, reported as an `account-mismatch` diagnostic on each
296
+ read. Subscriptions are never reported or finished by this plugin.
297
+ Permanently rejected and account-mismatched
222
298
  transactions are not finished: the client cannot prove content delivery for
223
299
  them, so persist that terminal result in the game-owned operation journal and
224
300
  do not run an automatic retry loop for it. An unknown backend result stays
@@ -266,6 +342,43 @@ until it is approved, so its `launched` record holds no ledger grant and is
266
342
  never finished; the approved transaction arrives through `transactionUpdated`
267
343
  and is verified and finished once.
268
344
 
345
+ A consumable the App Store refunds while it is still unfinished (the app
346
+ closed between the grant and the finish, or before the verify answered, and
347
+ the refund came first) stays in `Transaction.unfinished`, which StoreKit
348
+ redelivers at every launch until `finish()`, and the ordinary finish refuses
349
+ a revoked transaction. The SDK keeps it listed as `revoked` (its launch-time
350
+ `transactionUpdated` usually fires before the web listener attaches), and
351
+ the reconciler finishes it with `revoked: true` once the backend has
352
+ answered for it without a standing grant: the ledger reversed the grant
353
+ (App Store Server Notifications `REFUND`, read through
354
+ `getPurchaseLedgerStatus`), or the verify was refused as revoked
355
+ (`APP_STORE_TRANSACTION_REVOKED` or `LEDGER_GRANT_REVERSED`). A refunded
356
+ consumable of the session's own app account token that the journal holds no
357
+ record of is finished at once, without a verify. Nothing is granted or
358
+ verified again, and `REFUND_REVERSED` never re-grants. A revoked finish
359
+ names the reversed grant's ledger entry, or none when nothing was granted;
360
+ the native method requires a non-empty `ledgerEntryId`, so the SDK then
361
+ sends `revokedLedgerEntryIdPrefix` plus the transaction id, which names no
362
+ ledger entry and which the native code never reads. The SDK and the native
363
+ plugin (`StoreKitTransactionPolicy.finishableRevoked`, over
364
+ `Transaction.unfinished` only) finish only a revoked consumable bound to the
365
+ session token; a transaction still paid, a durable, another or no token,
366
+ and a malformed entry stay refused. So does one the list does not carry
367
+ (`finished: false`, no native call): the list cannot tell a consumable
368
+ already finished from one an early read does not carry yet, or from an
369
+ entry the native read skipped as unverifiable. The reconciler does not
370
+ count that answer as asked, so the next report asks again. Only revoked
371
+ consumables of the catalog are listed; a refunded durable is not. OpenIAP's finish takes the transaction by id and
372
+ does not check the revocation date. While the grant stands (a backend
373
+ without the ledger status read, or a refund not recorded yet) the
374
+ transaction stays unfinished, as before, and so does one the backend
375
+ refused for another reason. Version mixes keep the earlier behaviour: a
376
+ native build from before `revoked` ignores the flag and its ordinary gate
377
+ refuses the revoked transaction (`finished: false`, reported as a `finish`
378
+ failure and asked again on the next launch), an older adapter neither lists
379
+ revoked purchases nor forwards the flag, and an older reconciler never
380
+ sends it.
381
+
269
382
  ### Migrating from v1
270
383
 
271
384
  Commerce v2 PR C4 removed the v1 provider and its recovery driver.
@@ -39,6 +39,16 @@ export interface CreateCapacitorStoreKitCommerceProviderInput {
39
39
  * types that disagree with the catalog kind. Diagnostics only.
40
40
  */
41
41
  readonly onDiagnostic?: (diagnostic: CapacitorStoreKitDiagnostic) => void;
42
+ /**
43
+ * Purchase sheet facts (default true): the provider offers `presentation`
44
+ * (`open` before `Product.purchase()`, `closed` when it returns or
45
+ * throws), so the gateway and the game runtime pause until the sheet is
46
+ * done, not until a result. Needs an app built with this package's native
47
+ * code (otherwise new purchases are refused with
48
+ * `STOREKIT_PRESENTATION_UNSUPPORTED`). False keeps the result-based pause
49
+ * of earlier releases.
50
+ */
51
+ readonly purchasePresentation?: boolean;
42
52
  }
43
53
  /**
44
54
  * Commerce v2 `CommerceProvider` for the App Store: `createStoreCommerceProvider`
@@ -42,6 +42,9 @@ export function createCapacitorStoreKitCommerceProvider(input) {
42
42
  ...(input.sdk === undefined ? {} : { sdk: input.sdk }),
43
43
  ...(input.isIos === undefined ? {} : { isIos: input.isIos }),
44
44
  ...(input.onDiagnostic === undefined ? {} : { onDiagnostic: input.onDiagnostic }),
45
+ ...(input.purchasePresentation === undefined
46
+ ? {}
47
+ : { purchasePresentation: input.purchasePresentation }),
45
48
  });
46
49
  return refuseNewPurchasesUnlessGated(createStoreCommerceProvider({
47
50
  sdk,
@@ -26,11 +26,76 @@ export interface StoreKitTransaction {
26
26
  */
27
27
  readonly unfinished?: boolean;
28
28
  }
29
- export type StoreKitPurchaseOutcome = {
29
+ export type StoreKitPurchaseOutcome = ({
30
30
  readonly status: 'purchased';
31
31
  readonly transaction: StoreKitTransaction;
32
32
  } | {
33
33
  readonly status: 'pending' | 'cancelled';
34
+ }) & {
35
+ /**
36
+ * The purchase sheet when a `purchase` call with a `presentationId`
37
+ * answered (also in a rejection's `data`): `closed` once
38
+ * `Product.purchase()` returned or threw, `not-started` when the plugin
39
+ * refused before calling it.
40
+ */
41
+ readonly presentation?: StoreKitPurchaseAnswerPresentation;
42
+ };
43
+ export type StoreKitPurchasePresentationState = 'open' | 'closed' | 'not-started';
44
+ export type StoreKitPurchaseAnswerPresentation = StoreKitPurchasePresentationState | 'requested';
45
+ /**
46
+ * A sheet fact for the `purchase` call that sent `presentationId`: `open`
47
+ * right before `Product.purchase()`, `closed` when it returns or throws,
48
+ * `not-started` when the plugin refused before it. Apple documents that the
49
+ * call presents the confirmation sheet, not that it returns only once the
50
+ * sheet is gone; see the README.
51
+ */
52
+ export interface StoreKitPurchasePresentationEvent {
53
+ readonly presentationId: string;
54
+ readonly state: StoreKitPurchasePresentationState;
55
+ readonly reason?: 'interrupted';
56
+ }
57
+ /** What `getPresentationSupport` declares; `purchasePresentation` is the facts protocol version. */
58
+ export interface StoreKitPresentationSupport {
59
+ readonly purchasePresentation: number;
60
+ }
61
+ /**
62
+ * What `getFinishSupport` declares: `withoutAppAccountToken` is the protocol
63
+ * version of the explicit no-token finish, absent from a build before it.
64
+ */
65
+ export interface StoreKitFinishSupport {
66
+ readonly withoutAppAccountToken?: number;
67
+ }
68
+ /**
69
+ * One `finishTransaction` call: the session app account token, or explicitly
70
+ * none (`withoutAppAccountToken: true`, no token field at all), which matches
71
+ * only a non-consumable bound to no token. Never both, never neither.
72
+ */
73
+ export type StoreKitFinishTransactionInput = {
74
+ readonly transactionId: string;
75
+ readonly ledgerEntryId: string;
76
+ /** Session app account token; must match the verified transaction. */
77
+ readonly appAccountToken: string;
78
+ readonly withoutAppAccountToken?: never;
79
+ /**
80
+ * Finish a transaction StoreKit lists as revoked (refunded) instead:
81
+ * only an unfinished, revoked consumable bound to `appAccountToken`, and
82
+ * nothing else. Grants nothing; it stops StoreKit redelivering it. Never
83
+ * combined with `withoutAppAccountToken`. A native build from before the
84
+ * flag ignores it and refuses the revoked transaction (`finished: false`).
85
+ */
86
+ readonly revoked?: boolean;
87
+ } | {
88
+ readonly transactionId: string;
89
+ readonly ledgerEntryId: string;
90
+ readonly appAccountToken?: never;
91
+ readonly revoked?: never;
92
+ /**
93
+ * Finish a verified, non-revoked non-consumable bound to no app account
94
+ * token (bought or redeemed outside the app). Only a build whose
95
+ * `getFinishSupport` declares it accepts this; an earlier one rejects
96
+ * the call with `STOREKIT_FINISH_REQUIRES_GRANT`.
97
+ */
98
+ readonly withoutAppAccountToken: true;
34
99
  };
35
100
  export interface CapacitorStoreKitPlugin {
36
101
  getProducts(input: {
@@ -41,7 +106,22 @@ export interface CapacitorStoreKitPlugin {
41
106
  purchase(input: {
42
107
  readonly productId: string;
43
108
  readonly appAccountToken: string;
109
+ /** Asks for this call's sheet facts (`purchasePresentation`); 1-64 of `[A-Za-z0-9-]`. */
110
+ readonly presentationId?: string;
44
111
  }): Promise<StoreKitPurchaseOutcome>;
112
+ /**
113
+ * Declares the sheet facts the native code reports. A native build from
114
+ * before them answers `UNIMPLEMENTED`; optional so a plugin double without
115
+ * it still type-checks, and then the SDK refuses purchases that need facts.
116
+ */
117
+ getPresentationSupport?(): Promise<StoreKitPresentationSupport>;
118
+ /**
119
+ * Declares the finish matches beyond the session token. A native build
120
+ * from before it answers `UNIMPLEMENTED`; optional so a plugin double
121
+ * without it still type-checks, and then the SDK never finishes a
122
+ * transaction bound to no token.
123
+ */
124
+ getFinishSupport?(): Promise<StoreKitFinishSupport>;
45
125
  /** Includes unfinished consumables and current non-consumable entitlements. */
46
126
  getTransactions(): Promise<{
47
127
  readonly transactions: readonly StoreKitTransaction[];
@@ -51,22 +131,24 @@ export interface CapacitorStoreKitPlugin {
51
131
  readonly synced: boolean;
52
132
  }>;
53
133
  /**
54
- * Call only after the authenticated backend confirms the ledger grant.
55
- * Finishing never grants. Native finishes only a verified, non-revoked,
56
- * one-time transaction whose app account token matches `appAccountToken`.
134
+ * Call only after the authenticated backend confirms the ledger grant
135
+ * (with `revoked`: after it answered for a refunded transaction without a
136
+ * standing grant). Finishing never grants. Native finishes only a
137
+ * verified, non-revoked, one-time transaction whose app account token
138
+ * matches `appAccountToken`, or, with `withoutAppAccountToken: true`, a
139
+ * verified, non-revoked non-consumable bound to no token, or, with
140
+ * `revoked: true`, only a revoked consumable bound to `appAccountToken`.
57
141
  * The StoreKit SDK injects the session token and applies the same match
58
142
  * before this call, including for `commerce.v2.finishTransaction`. Games
59
143
  * must not call this plugin method directly. The token match is not a
60
144
  * signed server capability: a caller who can read `getTransactions` can
61
- * still supply that token with a forged ledger entry id.
145
+ * still supply that token (or the no-token flag) with a forged ledger
146
+ * entry id.
62
147
  */
63
- finishTransaction(input: {
64
- readonly transactionId: string;
65
- readonly ledgerEntryId: string;
66
- /** Session app account token; must match the verified transaction. */
67
- readonly appAccountToken: string;
68
- }): Promise<{
148
+ finishTransaction(input: StoreKitFinishTransactionInput): Promise<{
69
149
  readonly finished: boolean;
70
150
  }>;
71
151
  addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
152
+ /** Sheet facts of `purchase` calls that sent a `presentationId`. */
153
+ addListener(eventName: 'purchasePresentation', listener: (event: StoreKitPurchasePresentationEvent) => void): Promise<PluginListenerHandle>;
72
154
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A native support probe: asks the plugin once whether its build declares a
3
+ * feature, and answers every later caller from that answer. The rule matches
4
+ * the sheet-facts probe of `@mpgd/adapter-capacitor`
5
+ * (`createNativePurchasePresentation().supported()`), kept here so this
6
+ * package does not depend on that adapter's version for it:
7
+ *
8
+ * - `read` runs inside the promise chain, so a plugin proxy that throws on
9
+ * access is answered like a call that failed.
10
+ * - Any answer is passed to `declared` and remembered (a build without the
11
+ * method answers `undefined`).
12
+ * - `UNIMPLEMENTED` (a native build from before the method) is remembered as
13
+ * "not supported".
14
+ * - Any other failure answers "not supported" for this caller and is asked
15
+ * again by the next one.
16
+ */
17
+ export declare function probeNativeSupport(read: () => unknown, declared: (answer: unknown) => boolean): () => Promise<boolean>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A native support probe: asks the plugin once whether its build declares a
3
+ * feature, and answers every later caller from that answer. The rule matches
4
+ * the sheet-facts probe of `@mpgd/adapter-capacitor`
5
+ * (`createNativePurchasePresentation().supported()`), kept here so this
6
+ * package does not depend on that adapter's version for it:
7
+ *
8
+ * - `read` runs inside the promise chain, so a plugin proxy that throws on
9
+ * access is answered like a call that failed.
10
+ * - Any answer is passed to `declared` and remembered (a build without the
11
+ * method answers `undefined`).
12
+ * - `UNIMPLEMENTED` (a native build from before the method) is remembered as
13
+ * "not supported".
14
+ * - Any other failure answers "not supported" for this caller and is asked
15
+ * again by the next one.
16
+ */
17
+ export function probeNativeSupport(read, declared) {
18
+ let support;
19
+ return () => {
20
+ if (support !== undefined) {
21
+ return support;
22
+ }
23
+ const attempt = Promise.resolve()
24
+ .then(read)
25
+ .then(declared, (error) => {
26
+ if (!isRecord(error) || error.code !== 'UNIMPLEMENTED') {
27
+ support = undefined;
28
+ }
29
+ return false;
30
+ });
31
+ support = attempt;
32
+ return attempt;
33
+ };
34
+ }
35
+ function isRecord(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ export type StoreKitPurchasePresentationState = 'open' | 'closed' | 'not-started' | 'unknown';
3
+ /** A sheet fact of one `requestPurchase` call; see `StoreSdkPurchasePresentationEvent`. */
4
+ export interface StoreKitSdkPurchasePresentationEvent {
5
+ readonly presentationId: string;
6
+ readonly state: StoreKitPurchasePresentationState;
7
+ readonly reason?: 'interrupted' | 'timeout';
8
+ }
9
+ /** The SDK's `purchasePresentation`; see `StoreSdkPurchasePresentation` in `@mpgd/adapter-capacitor`. */
10
+ export interface StoreKitSdkPurchasePresentation {
11
+ subscribe(listener: (event: StoreKitSdkPurchasePresentationEvent) => void): () => void;
12
+ }
13
+ /** See `NativePurchasePresentation` in `@mpgd/adapter-capacitor`. */
14
+ export interface StoreKitNativePurchasePresentation {
15
+ readonly purchasePresentation: StoreKitSdkPurchasePresentation;
16
+ supported(): Promise<boolean>;
17
+ followed(): boolean;
18
+ begin(presentationId: string): void;
19
+ answered(presentationId: string, answerOrError: unknown): void;
20
+ }
21
+ /** The native surface the facts need: the plugin's event and support check. */
22
+ export interface StoreKitNativePresentationPlugin {
23
+ addListener(eventName: 'purchasePresentation', listener: (event: unknown) => void): Promise<PluginListenerHandle>;
24
+ readonly getPresentationSupport?: () => Promise<unknown>;
25
+ }
26
+ /** The adapter's facts helper over `native`, or undefined with an adapter from before it. */
27
+ export declare function resolveStoreKitNativePurchasePresentation(native: StoreKitNativePresentationPlugin, adapterExports?: object): StoreKitNativePurchasePresentation | undefined;
@@ -0,0 +1,9 @@
1
+ import * as adapterCapacitor from '@mpgd/adapter-capacitor';
2
+ /** The adapter's facts helper over `native`, or undefined with an adapter from before it. */
3
+ export function resolveStoreKitNativePurchasePresentation(native, adapterExports = adapterCapacitor) {
4
+ const factory = adapterExports.createNativePurchasePresentation;
5
+ if (typeof factory !== 'function') {
6
+ return undefined;
7
+ }
8
+ return factory(native);
9
+ }
@@ -2,8 +2,18 @@ import { type StoreSdk, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from
2
2
  import type { StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
3
  import type { CapacitorStoreKitPlugin, StoreKitTransaction } from './definitions.js';
4
4
  import { storeKitStoreSdkCapabilities } from './commerce-profile.js';
5
+ import { type StoreKitSdkPurchasePresentation } from './purchase-presentation.js';
6
+ export type { StoreKitSdkPurchasePresentation, StoreKitSdkPurchasePresentationEvent, } from './purchase-presentation.js';
5
7
  /** The plugin surface the commerce v2 SDK drives; `CapacitorStoreKit` by default. */
6
- export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener'>;
8
+ export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener' | 'getPresentationSupport' | 'getFinishSupport'>;
9
+ /**
10
+ * The code of a purchase refused because the native plugin in the app binary
11
+ * reports no sheet facts (a web bundle newer than its native build). The
12
+ * provider offers `presentation`, so a purchase it could not follow is not
13
+ * sold; rebuilding the app with this package's native code, or
14
+ * `purchasePresentation: false`, sells again. Recovery is unaffected.
15
+ */
16
+ export declare const storeKitPresentationUnsupportedCode: 'STOREKIT_PRESENTATION_UNSUPPORTED';
7
17
  /** One-time kinds the App Store sells; `durable` is a StoreKit non-consumable. */
8
18
  export type StoreKitOneTimeKind = 'consumable' | 'durable';
9
19
  /** A store SKU the SDK is configured to sell and the kind the catalog declares for it. */
@@ -47,7 +57,20 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
47
57
  readonly isIos?: () => boolean;
48
58
  /** Observes transactions the SDK excludes; never a grant signal. */
49
59
  readonly onDiagnostic?: (diagnostic: CapacitorStoreKitDiagnostic) => void;
60
+ /**
61
+ * Report the purchase sheet's facts (`purchasePresentation`), so the
62
+ * adapter's provider offers `CommerceProvider.presentation` and the game
63
+ * pauses until `Product.purchase()` returns rather than until a result
64
+ * event. Defaults to true. A native build without the facts then refuses
65
+ * new purchases with `STOREKIT_PRESENTATION_UNSUPPORTED`; false keeps the
66
+ * result-based pause of earlier releases.
67
+ */
68
+ readonly purchasePresentation?: boolean;
50
69
  }
70
+ /** The StoreKit `StoreSdk`, with the sheet facts it declares unless turned off. */
71
+ export type CapacitorStoreKitStoreSdk = StoreSdk & {
72
+ readonly purchasePresentation?: StoreKitSdkPurchasePresentation;
73
+ };
51
74
  /**
52
75
  * Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
53
76
  * StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
@@ -67,7 +90,7 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
67
90
  * or on a new device; the reconciler sends them with `intent: 'restore'` and
68
91
  * the backend's restore sharing decides (RFC "Restore sharing").
69
92
  */
70
- export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): StoreSdk;
93
+ export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): CapacitorStoreKitStoreSdk;
71
94
  export { storeKitStoreSdkCapabilities };
72
95
  /**
73
96
  * StoreKit plugin codes merged over `defaultStoreSdkErrorCodeMap`. Codes the
@@ -79,6 +102,12 @@ export { storeKitStoreSdkCapabilities };
79
102
  export declare const storeKitStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
80
103
  /** The default table plus every StoreKit code; what the provider uses. */
81
104
  export declare const capacitorStoreKitErrorCodeMap: StoreSdkErrorCodeMap;
105
+ /**
106
+ * The `ledgerEntryId` the SDK sends the native plugin for a revoked finish
107
+ * that names no ledger entry (a refund of a purchase never granted). The
108
+ * native method requires the field on every call; it is not a ledger entry.
109
+ */
110
+ export declare const revokedLedgerEntryIdPrefix = "revoked-without-grant:";
82
111
  /** Normalizes a required `app-account-token` binding to the lowercase UUID StoreKit expects. */
83
112
  export declare function readAppAccountToken(binding: StorePurchaseAccountBinding | undefined): string;
84
113
  /** Maps a verified StoreKit transaction to the SDK purchase: the JWS is the token. */
package/dist/store-sdk.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { Capacitor } from '@capacitor/core';
2
2
  import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
3
3
  import { appStoreStoreId, storeKitStoreSdkCapabilities } from './commerce-profile.js';
4
+ import { probeNativeSupport } from './native-support.js';
4
5
  import { CapacitorStoreKit } from './plugin.js';
6
+ import { resolveStoreKitNativePurchasePresentation, } from './purchase-presentation.js';
7
+ /**
8
+ * The code of a purchase refused because the native plugin in the app binary
9
+ * reports no sheet facts (a web bundle newer than its native build). The
10
+ * provider offers `presentation`, so a purchase it could not follow is not
11
+ * sold; rebuilding the app with this package's native code, or
12
+ * `purchasePresentation: false`, sells again. Recovery is unaffected.
13
+ */
14
+ export const storeKitPresentationUnsupportedCode = 'STOREKIT_PRESENTATION_UNSUPPORTED';
5
15
  /**
6
16
  * Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
7
17
  * StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
@@ -23,6 +33,10 @@ import { CapacitorStoreKit } from './plugin.js';
23
33
  */
24
34
  export function createCapacitorStoreKitStoreSdk(input) {
25
35
  const sdk = input.sdk ?? CapacitorStoreKit;
36
+ // Sheet facts of `purchase` calls that carry a presentation id.
37
+ const presentation = input.purchasePresentation === false
38
+ ? undefined
39
+ : resolveStoreKitNativePurchasePresentation(sdk);
26
40
  const isIos = input.isIos ?? (() => Capacitor.getPlatform() === 'ios');
27
41
  const onDiagnostic = input.onDiagnostic ?? (() => undefined);
28
42
  if (typeof input.getAppAccountToken !== 'function') {
@@ -253,13 +267,27 @@ export function createCapacitorStoreKitStoreSdk(input) {
253
267
  }
254
268
  return expected;
255
269
  };
270
+ /**
271
+ * Whether the native build declares the explicit no-token finish
272
+ * (`getFinishSupport`). A build from before it answers `UNIMPLEMENTED`,
273
+ * which is remembered; any other failure is asked again on the next
274
+ * finish, and both answer "not supported" for this one, so the durable is
275
+ * left unfinished (as before) rather than sent a call the build refuses.
276
+ */
277
+ const finishWithoutTokenSupported = probeNativeSupport(() => {
278
+ const check = sdk.getFinishSupport;
279
+ return typeof check === 'function' ? check.call(sdk) : undefined;
280
+ }, (answer) => isRecord(answer)
281
+ && typeof answer.withoutAppAccountToken === 'number'
282
+ && answer.withoutAppAccountToken >= 1);
256
283
  // Overlapping finishes for the same transaction and ledger entry share one
257
284
  // native call; the v2 reconciler is the gate that authorizes the call.
258
285
  const inFlightFinishes = new Map();
259
286
  /**
260
287
  * Mirrors the native finish gate: the transaction must be listed, not
261
- * revoked, and bound to the session app account token. A missing list is
262
- * an invalid response, not a quiet "not finished".
288
+ * revoked, and bound to the session app account token (exceptions for a
289
+ * durable below). A missing list is an invalid response, not a quiet "not
290
+ * finished".
263
291
  *
264
292
  * On a retried finish (`retry`: the caller asked to finish this purchase
265
293
  * before, in an earlier attempt or session), a consumable the list no
@@ -284,8 +312,8 @@ export function createCapacitorStoreKitStoreSdk(input) {
284
312
  * reconciler cannot hold a grant for it from this SDK, and the answer
285
313
  * neither grants nor takes anything.
286
314
  *
287
- * Two exceptions for a listed, non-revoked durable, the only kind the
288
- * restore read admits from another token:
315
+ * Three exceptions for a listed, non-revoked durable, the only kind the
316
+ * restore read admits from another token or from none:
289
317
  *
290
318
  * - StoreKit already finished it (`unfinished: false`): nothing is left to
291
319
  * finish whatever its token, so a restore shared to this player ends
@@ -299,16 +327,24 @@ export function createCapacitorStoreKitStoreSdk(input) {
299
327
  * finishing it takes nothing from any player: the earlier guest's next
300
328
  * read still lists it (`unfinished: false`) for its own verification. It
301
329
  * is finished with its own token, which the native gate matches.
330
+ * - It is unfinished (or the build omits the flag), the catalog declares
331
+ * its SKU durable, and it is bound to no token (bought or redeemed
332
+ * outside the app, such as an offer code, then restored): the same
333
+ * reasoning holds, since the caller holds this player's ledger grant and
334
+ * StoreKit keeps the finished durable as a current entitlement. It is
335
+ * finished with the explicit no-token match (`withoutAppAccountToken`),
336
+ * which matches only a non-consumable bound to no token, and only when
337
+ * the native build declares it (`getFinishSupport`); an earlier build
338
+ * leaves it unfinished, and the caller retries as before.
339
+ *
340
+ * A consumable of another token or of no token, or a durable whose SKU the
341
+ * catalog does not declare durable, is never finished here.
302
342
  *
303
- * A consumable of another token, or a transaction bound to no token, is
304
- * never finished here.
343
+ * A revoked finish (`revoked`, see `revokedFinishPermitted`) has its own
344
+ * gate; an ordinary finish of a revoked transaction stays refused.
305
345
  */
306
346
  const finishPermitted = async (transactionId, appAccountToken, lostAcknowledgement) => {
307
- const response = await sdk.getTransactions();
308
- if (!isRecord(response) || !Array.isArray(response.transactions)) {
309
- throw evidenceInvalid('StoreKit returned an invalid transaction list.');
310
- }
311
- const entries = response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
347
+ const entries = await listedEntries(transactionId);
312
348
  const transaction = entries.find(isValidStoreKitTransaction);
313
349
  if (transaction === undefined) {
314
350
  return { verdict: entries.length === 0 && lostAcknowledgement ? 'finished' : 'refused' };
@@ -321,17 +357,62 @@ export function createCapacitorStoreKitStoreSdk(input) {
321
357
  }
322
358
  const bound = transaction.appAccountToken?.toLowerCase();
323
359
  if (bound === appAccountToken) {
324
- return { verdict: 'finish', appAccountToken };
360
+ return { verdict: 'finish', binding: { appAccountToken } };
361
+ }
362
+ if (transaction.type !== 'non_consumable' || kinds.get(transaction.productId) !== 'durable') {
363
+ return { verdict: 'refused' };
364
+ }
365
+ if (bound !== undefined) {
366
+ return { verdict: 'finish', binding: { appAccountToken: bound } };
325
367
  }
326
- if (bound !== undefined
327
- && transaction.type === 'non_consumable'
328
- && kinds.get(transaction.productId) === 'durable') {
329
- return { verdict: 'finish', appAccountToken: bound };
368
+ return await finishWithoutTokenSupported()
369
+ ? { verdict: 'finish', binding: { withoutAppAccountToken: true } }
370
+ : { verdict: 'refused' };
371
+ };
372
+ /** The native list's entries of one transaction id, in any shape. */
373
+ const listedEntries = async (transactionId) => {
374
+ const response = await sdk.getTransactions();
375
+ if (!isRecord(response) || !Array.isArray(response.transactions)) {
376
+ throw evidenceInvalid('StoreKit returned an invalid transaction list.');
330
377
  }
331
- return { verdict: 'refused' };
378
+ return response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
379
+ };
380
+ /**
381
+ * The gate of a revoked finish (`revoked`): a consumable the App Store
382
+ * refunded while it was still unfinished, which StoreKit keeps in
383
+ * `Transaction.unfinished` and redelivers at every launch until `finish()`.
384
+ * The caller (the reconciler) asks only once the backend answered for it
385
+ * without a standing grant, and finishing it grants nothing: it only stops
386
+ * the redelivery. It is finished only when the native list carries it as
387
+ * revoked (`revokedAt`), as a consumable of a SKU the catalog declares
388
+ * consumable, bound to the session token. A transaction that is not
389
+ * revoked (still a paid purchase), a durable, or a consumable of another
390
+ * or no token stays refused, as does an entry of the id in a malformed
391
+ * shape. One the list does not carry is refused too (`finished: false`,
392
+ * no native call): the list cannot tell a consumable already finished
393
+ * (a refund of a finished consumable is reported as well) from one a read
394
+ * early in the launch does not carry yet, and answering finished would
395
+ * report a finish that never happened. The reconciler does not count that
396
+ * answer as asked, so a later report of the transaction asks again.
397
+ */
398
+ const revokedFinishPermitted = async (transactionId, appAccountToken) => {
399
+ const entries = await listedEntries(transactionId);
400
+ const transaction = entries.find(isValidStoreKitTransaction);
401
+ if (transaction === undefined) {
402
+ return 'refused';
403
+ }
404
+ return transaction.revokedAt !== undefined
405
+ && transaction.type === 'consumable'
406
+ && kinds.get(transaction.productId) === 'consumable'
407
+ && transaction.appAccountToken?.toLowerCase() === appAccountToken
408
+ ? 'finish'
409
+ : 'refused';
332
410
  };
333
411
  return {
334
412
  store: appStoreStoreId,
413
+ // `finishTransaction` reads `revoked` (see `revokedFinishPermitted`).
414
+ acceptsRevokedFinish: true,
415
+ ...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
335
416
  capabilities: storeKitStoreSdkCapabilities,
336
417
  async fetchProducts(request) {
337
418
  requireIos();
@@ -354,7 +435,33 @@ export function createCapacitorStoreKitStoreSdk(input) {
354
435
  requireIos();
355
436
  const appAccountToken = readAppAccountToken(request.accountBinding);
356
437
  const expected = await assertSellable(request.sku);
357
- const outcome = await sdk.purchase({ productId: request.sku, appAccountToken });
438
+ // Only an adapter that reads sheet facts sends an id (one from before
439
+ // them never does, and the purchase runs as in earlier releases).
440
+ const presentationId = presentation === undefined ? undefined : request.presentationId;
441
+ if (presentation !== undefined && presentationId !== undefined) {
442
+ if (!(await presentation.supported())) {
443
+ throw storeKitError(storeKitPresentationUnsupportedCode, 'The app was built with a StoreKit plugin that reports no purchase sheet facts; '
444
+ + "rebuild it with this package's native code.", false);
445
+ }
446
+ presentation.begin(presentationId);
447
+ }
448
+ let outcome;
449
+ try {
450
+ outcome = await sdk.purchase({
451
+ productId: request.sku,
452
+ appAccountToken,
453
+ ...(presentationId === undefined ? {} : { presentationId }),
454
+ });
455
+ }
456
+ catch (error) {
457
+ if (presentationId !== undefined) {
458
+ presentation?.answered(presentationId, error);
459
+ }
460
+ throw error;
461
+ }
462
+ if (presentationId !== undefined) {
463
+ presentation?.answered(presentationId, outcome);
464
+ }
358
465
  if (!isRecord(outcome)) {
359
466
  throw evidenceInvalid('StoreKit returned an invalid purchase result.');
360
467
  }
@@ -423,38 +530,70 @@ export function createCapacitorStoreKitStoreSdk(input) {
423
530
  requireIos();
424
531
  // The session token is the binding. A bridge or plugin caller cannot
425
532
  // substitute another account's token on this path: the only other
426
- // token used is a durable's own, read from the native list.
533
+ // bindings used are a durable's own token, read from the native list,
534
+ // and the explicit no-token match for a durable the list shows unbound.
427
535
  const appAccountToken = await Promise.resolve(resolveAppAccountToken());
428
- const { transactionId, ledgerEntryId } = request;
536
+ const { transactionId } = request;
537
+ const revoked = request.revoked === true;
538
+ // A revoked finish may name no ledger entry (nothing was ever granted);
539
+ // a given one must still be non-empty, and an ordinary finish needs it.
540
+ const given = request.ledgerEntryId;
429
541
  if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
430
- || typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
542
+ || (given === undefined
543
+ ? !revoked
544
+ : typeof given !== 'string' || given.trim() === '')) {
431
545
  throw storeKitError('STOREKIT_FINISH_REQUIRES_GRANT', 'StoreKit finish requires a transaction id and the ledger entry that granted it.', false);
432
546
  }
547
+ // Native detail: the plugin's `finishTransaction` requires a non-empty
548
+ // `ledgerEntryId` on every call. A revoked finish without a ledger entry
549
+ // sends `revokedLedgerEntryIdPrefix` + the transaction id there; it names
550
+ // no ledger entry and the native code never reads it.
551
+ const ledgerEntryId = typeof given === 'string'
552
+ ? given
553
+ : `${revokedLedgerEntryIdPrefix}${transactionId}`;
433
554
  const active = inFlightFinishes.get(transactionId);
434
- if (active !== undefined && active.ledgerEntryId === ledgerEntryId) {
555
+ if (active !== undefined
556
+ && active.ledgerEntryId === ledgerEntryId
557
+ && active.revoked === revoked) {
435
558
  return active.promise;
436
559
  }
437
560
  const promise = (async () => {
438
- // Same match the native plugin applies: missing, revoked, unbound, or
439
- // another account never reaches `transaction.finish()`, except a
440
- // durable of another token (see `finishPermitted`). On a retry, a
561
+ if (revoked) {
562
+ if (request.isConsumable !== true) {
563
+ return { finished: false };
564
+ }
565
+ if (await revokedFinishPermitted(transactionId, appAccountToken) !== 'finish') {
566
+ return { finished: false };
567
+ }
568
+ // Always the session token, never the no-token match. A native
569
+ // build from before the flag ignores it and applies its ordinary
570
+ // gate, which refuses a revoked transaction: `finished: false`.
571
+ return readFinishResult(await sdk.finishTransaction({
572
+ transactionId,
573
+ ledgerEntryId,
574
+ appAccountToken,
575
+ revoked: true,
576
+ }));
577
+ }
578
+ // Same match the native plugin applies: missing, revoked, or another
579
+ // account never reaches `transaction.finish()`, except a durable of
580
+ // another token or of none (see `finishPermitted`). On a retry, a
441
581
  // consumable that left the unfinished list is already finished (a
442
582
  // lost acknowledgement).
443
583
  const permitted = await finishPermitted(transactionId, appAccountToken, request.isConsumable === true && request.retry === true);
444
584
  if (permitted.verdict !== 'finish') {
445
585
  return { finished: permitted.verdict === 'finished' };
446
586
  }
447
- const reported = await sdk.finishTransaction({
448
- transactionId,
449
- ledgerEntryId,
450
- appAccountToken: permitted.appAccountToken,
451
- });
452
- if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
453
- throw evidenceInvalid('StoreKit returned an invalid finish result.');
454
- }
455
- return { finished: reported.finished };
587
+ const { binding } = permitted;
588
+ return readFinishResult('withoutAppAccountToken' in binding
589
+ ? await sdk.finishTransaction({ transactionId, ledgerEntryId, withoutAppAccountToken: true })
590
+ : await sdk.finishTransaction({
591
+ transactionId,
592
+ ledgerEntryId,
593
+ appAccountToken: binding.appAccountToken,
594
+ }));
456
595
  })();
457
- inFlightFinishes.set(transactionId, { ledgerEntryId, promise });
596
+ inFlightFinishes.set(transactionId, { ledgerEntryId, revoked, promise });
458
597
  try {
459
598
  return await promise;
460
599
  }
@@ -494,6 +633,12 @@ export function createCapacitorStoreKitStoreSdk(input) {
494
633
  if (productIds.length === 0) {
495
634
  return 'configuration-required';
496
635
  }
636
+ // An adapter that reads sheet facts over a native build without them
637
+ // would refuse every purchase: report the store as needing a rebuild,
638
+ // so the shop sells nothing and no idempotency key is spent.
639
+ if (presentation !== undefined && presentation.followed() && !(await presentation.supported())) {
640
+ return 'configuration-required';
641
+ }
497
642
  try {
498
643
  // Probe batch by batch until one configured product is sold; only a
499
644
  // catalog with no sellable product at all is a configuration problem.
@@ -543,6 +688,7 @@ export const storeKitStoreSdkErrorCodeMap = Object.freeze({
543
688
  STOREKIT_FINISH_REQUIRES_GRANT: 'CONFIGURATION_REQUIRED',
544
689
  STOREKIT_EVIDENCE_INVALID: 'INVALID_RESPONSE',
545
690
  STOREKIT_TRANSACTION_UNVERIFIED: 'INVALID_RESPONSE',
691
+ STOREKIT_PRESENTATION_UNSUPPORTED: 'CONFIGURATION_REQUIRED',
546
692
  });
547
693
  /** The default table plus every StoreKit code; what the provider uses. */
548
694
  export const capacitorStoreKitErrorCodeMap = Object.freeze({
@@ -550,6 +696,12 @@ export const capacitorStoreKitErrorCodeMap = Object.freeze({
550
696
  ...storeKitStoreSdkErrorCodeMap,
551
697
  });
552
698
  const maxProductIdsPerLookup = 100;
699
+ /**
700
+ * The `ledgerEntryId` the SDK sends the native plugin for a revoked finish
701
+ * that names no ledger entry (a refund of a purchase never granted). The
702
+ * native method requires the field on every call; it is not a ledger entry.
703
+ */
704
+ export const revokedLedgerEntryIdPrefix = 'revoked-without-grant:';
553
705
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
554
706
  const nilUuid = '00000000-0000-0000-0000-000000000000';
555
707
  const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
@@ -629,6 +781,12 @@ function isValidStoreKitProduct(value) {
629
781
  && typeof value.formattedPrice === 'string'
630
782
  && typeof value.currencyCode === 'string' && value.currencyCode.length > 0;
631
783
  }
784
+ function readFinishResult(reported) {
785
+ if (!isRecord(reported) || typeof reported.finished !== 'boolean') {
786
+ throw evidenceInvalid('StoreKit returned an invalid finish result.');
787
+ }
788
+ return { finished: reported.finished };
789
+ }
632
790
  function storeKitError(code, message, retryable) {
633
791
  return Object.assign(new Error(message), { code, retryable });
634
792
  }
@@ -18,7 +18,9 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
18
18
  CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
19
19
  CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
20
20
  CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
21
- CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
21
+ CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "getFinishSupport", returnType: CAPPluginReturnPromise)
22
24
  ]
23
25
 
24
26
  private let connection = OpenIapAppleConnection()
@@ -79,25 +81,66 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
79
81
  }
80
82
  }
81
83
 
84
+ /// Declares the sheet facts this native code reports
85
+ /// (`purchasePresentation` events and the `presentation` field of
86
+ /// `purchase` answers). A build without this method answers
87
+ /// `UNIMPLEMENTED`, and the TypeScript SDK then sells nothing that would
88
+ /// wait for facts.
89
+ @objc func getPresentationSupport(_ call: CAPPluginCall) {
90
+ call.resolve(["purchasePresentation": MpgdPurchaseSheet.protocolVersion])
91
+ }
92
+
93
+ /// Declares the finish matches this native code applies beyond the
94
+ /// session token: `withoutAppAccountToken`, the explicit no-token match
95
+ /// for a non-consumable bound to no token. A build without this method
96
+ /// answers `UNIMPLEMENTED`, and the TypeScript SDK then leaves such a
97
+ /// transaction unfinished, as before.
98
+ @objc func getFinishSupport(_ call: CAPPluginCall) {
99
+ call.resolve(["withoutAppAccountToken": StoreKitTransactionPolicy.finishWithoutAppAccountTokenVersion])
100
+ }
101
+
82
102
  @objc func purchase(_ call: CAPPluginCall) {
103
+ // Correlates this call's sheet facts; sent only by a TypeScript SDK
104
+ // that reads them. Without it the call reports no facts, as before.
105
+ let presentationId = call.getString("presentationId")
106
+ if presentationId != nil && !MpgdPurchaseSheet.isValidPresentationId(presentationId) {
107
+ call.reject("The presentation id is invalid.", "STOREKIT_INVALID_PURCHASE", nil,
108
+ ["presentation": MpgdPurchaseSheet.Fact.notStarted.rawValue])
109
+ return
110
+ }
111
+ let sheet = presentationId.map { id in
112
+ MpgdPurchaseSheet(presentationId: id) { [weak self] id, fact in
113
+ self?.notifyPresentation(id, fact)
114
+ }
115
+ }
83
116
  guard let productId = call.getString("productId"),
84
117
  StoreKitTransactionPolicy.isValidProductId(productId),
85
118
  let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
86
119
  call.getString("appAccountToken"), allowNil: true) else {
87
- call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
120
+ sheet?.end()
121
+ call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE",
122
+ nil, Self.presentationData(sheet))
123
+ return
124
+ }
125
+ if let message = connection.providerError {
126
+ sheet?.end()
127
+ call.reject(message, "STOREKIT_CONFIGURATION_ERROR", nil, Self.presentationData(sheet))
88
128
  return
89
129
  }
90
- guard providerReady(call) else { return }
91
130
  Task { @MainActor [module] in
92
131
  do {
93
132
  let result = try await module.fetchProducts(ProductRequest(skus: [productId], type: .inApp))
94
133
  guard let product = Self.iosProducts(result).first(where: { $0.id == productId }),
95
134
  MpgdOneTimeType(productType: product.typeIOS) != nil else {
96
- call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
135
+ sheet?.end()
136
+ call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE",
137
+ nil, Self.presentationData(sheet))
97
138
  return
98
139
  }
99
140
  } catch {
100
- call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
141
+ sheet?.end()
142
+ call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error,
143
+ Self.presentationData(sheet))
101
144
  return
102
145
  }
103
146
  let request = RequestPurchaseProps(
@@ -109,40 +152,68 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
109
152
  ))),
110
153
  type: .inApp
111
154
  )
155
+ // `open` right before OpenIAP calls `Product.purchase()`, which
156
+ // presents the sheet; `closed` once that call returned or threw,
157
+ // before the answer, which carries the same fact.
158
+ sheet?.open()
112
159
  do {
113
160
  let outcome = try await module.requestPurchase(request)
161
+ sheet?.end()
114
162
  guard case .purchase(let purchased?) = outcome,
115
163
  case .purchaseIos(let ios) = purchased,
116
164
  let transaction = StoreKitTransactionPolicy.transaction(from: ios),
117
165
  StoreKitTransactionPolicy.matchesPurchaseRequest(
118
166
  transaction, productId: productId, appAccountToken: accountToken) else {
119
167
  call.reject("StoreKit transaction failed verification or account binding.",
120
- "STOREKIT_TRANSACTION_UNVERIFIED")
168
+ "STOREKIT_TRANSACTION_UNVERIFIED", nil, Self.presentationData(sheet))
121
169
  return
122
170
  }
123
- call.resolve([
171
+ call.resolve(Self.withPresentation([
124
172
  "status": "purchased",
125
173
  "transaction": StoreKitTransactionPolicy.payload(transaction)
126
- ])
174
+ ], sheet))
127
175
  } catch let error as PurchaseError {
176
+ sheet?.end()
128
177
  switch StoreKitTransactionPolicy.purchaseFailure(error.code) {
129
178
  case .cancelled:
130
- call.resolve(["status": "cancelled"])
179
+ call.resolve(Self.withPresentation(["status": "cancelled"], sheet))
131
180
  case .pending:
132
181
  // Ask to Buy: the approved transaction arrives through
133
- // `transactionUpdated` or `getTransactions` later.
134
- call.resolve(["status": "pending"])
182
+ // `transactionUpdated` or `getTransactions` later. The
183
+ // sheet itself is done.
184
+ call.resolve(Self.withPresentation(["status": "pending"], sheet))
135
185
  case .rejected(let code, let message):
136
- call.reject(message, code, error)
186
+ call.reject(message, code, error, Self.presentationData(sheet))
137
187
  }
138
188
  } catch {
189
+ sheet?.end()
139
190
  // The purchase sheet may have completed before an error reached us.
140
191
  call.reject("StoreKit purchase result is uncertain; requery transactions.",
141
- "STOREKIT_PURCHASE_UNCERTAIN", error)
192
+ "STOREKIT_PURCHASE_UNCERTAIN", error, Self.presentationData(sheet))
142
193
  }
143
194
  }
144
195
  }
145
196
 
197
+ /// Retained until a listener consumes it; the random id keeps a fact of
198
+ /// an earlier web session from matching a new call.
199
+ private func notifyPresentation(_ presentationId: String, _ fact: MpgdPurchaseSheet.Fact) {
200
+ notifyListeners("purchasePresentation",
201
+ data: ["presentationId": presentationId, "state": fact.rawValue],
202
+ retainUntilConsumed: true)
203
+ }
204
+
205
+ private static func presentationData(_ sheet: MpgdPurchaseSheet?) -> [String: Any]? {
206
+ guard let sheet else { return nil }
207
+ return ["presentation": sheet.answer]
208
+ }
209
+
210
+ private static func withPresentation(_ answer: [String: Any], _ sheet: MpgdPurchaseSheet?) -> [String: Any] {
211
+ guard let sheet else { return answer }
212
+ var answered = answer
213
+ answered["presentation"] = sheet.answer
214
+ return answered
215
+ }
216
+
146
217
  @objc func getTransactions(_ call: CAPPluginCall) {
147
218
  guard providerReady(call) else { return }
148
219
  Task {
@@ -180,11 +251,15 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
180
251
  // SDK supplies the session token and applies the same match before
181
252
  // calling. A signed server finish capability is still required to stop
182
253
  // a webview caller who read the token from `getTransactions`.
254
+ // `withoutAppAccountToken: true` (and no token argument) instead
255
+ // matches only a non-consumable bound to no token; a missing token
256
+ // without it is refused, as before.
183
257
  guard let transactionId = call.getString("transactionId"), UInt64(transactionId) != nil,
184
258
  let ledgerEntryId = call.getString("ledgerEntryId"),
185
259
  !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
186
- let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
187
- call.getString("appAccountToken")) else {
260
+ let binding = StoreKitTransactionPolicy.finishBinding(
261
+ appAccountToken: call.options["appAccountToken"],
262
+ withoutAppAccountToken: call.options["withoutAppAccountToken"]) else {
188
263
  call.reject(
189
264
  "A transaction ID, confirmed ledger entry, and app account token are required.",
190
265
  "STOREKIT_FINISH_REQUIRES_GRANT"
@@ -192,28 +267,52 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
192
267
  return
193
268
  }
194
269
  guard providerReady(call) else { return }
270
+ // `revoked`: finish a consumable the App Store refunded while it was
271
+ // unfinished, so StoreKit stops redelivering it. Grants nothing; the
272
+ // StoreKit SDK asks only once the game backend answered for it
273
+ // without a standing grant. Only a verified, revoked, unfinished
274
+ // consumable bound to the session token matches
275
+ // (`StoreKitTransactionPolicy.finishableRevoked`), read from
276
+ // `Transaction.unfinished` only; anything else answers
277
+ // `finished: false` and is left alone.
278
+ let revoked = call.getBool("revoked") == true
195
279
  Task { [module] in
196
280
  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 {
281
+ var candidate: MpgdStoreKitTransaction?
282
+ if revoked {
283
+ // Only the session token, never the no-token match: a
284
+ // revoked finish of a call without a token finds nothing.
285
+ if case .appAccountToken(let token) = binding {
286
+ candidate = StoreKitTransactionPolicy.finishableRevoked(
287
+ transactionId: transactionId,
288
+ appAccountToken: token,
289
+ in: await RecoveryReadShim.unfinished())
290
+ }
291
+ } else {
292
+ // The verified transaction behind the id, through the same
293
+ // read as `getTransactions` (an unverified sibling never
294
+ // blocks it): unfinished first, then the StoreKit history
295
+ // (`Transaction.all`) the 0.3 plugin searched, so finishing
296
+ // an already-finished durable stays an idempotent
297
+ // `finished: true`.
207
298
  candidate = StoreKitTransactionPolicy.finishable(
208
299
  transactionId: transactionId,
209
- appAccountToken: accountToken,
210
- in: await RecoveryReadShim.history())
300
+ binding: binding,
301
+ in: await RecoveryReadShim.unfinished())
302
+ if candidate == nil {
303
+ candidate = StoreKitTransactionPolicy.finishable(
304
+ transactionId: transactionId,
305
+ binding: binding,
306
+ in: await RecoveryReadShim.history())
307
+ }
211
308
  }
212
309
  guard let match = candidate else {
213
310
  call.resolve(["finished": false])
214
311
  return
215
312
  }
216
- // OpenIAP finishes it (and checks the Apple storeId).
313
+ // OpenIAP finishes it by id from `Transaction.unfinished` (and
314
+ // checks the Apple storeId); it does not check the revocation
315
+ // date.
217
316
  try await module.finishTransaction(
218
317
  purchase: .purchaseIos(StoreKitTransactionPolicy.finishInput(match)),
219
318
  isConsumable: match.type == .consumable)
@@ -0,0 +1,62 @@
1
+ import Foundation
2
+
3
+ /// The purchase sheet facts of one `purchase` call that sent a presentation
4
+ /// id. The plugin reports `open` right before OpenIAP calls
5
+ /// `Product.purchase()` and `closed` when that returns or throws; a call it
6
+ /// refuses before then is `not-started`. Each sheet reports at most one
7
+ /// `open` and exactly one terminal fact, whatever order the plugin calls
8
+ /// these in. Apple documents that `Product.purchase()` presents the
9
+ /// confirmation sheet, not that it returns only once the sheet is dismissed,
10
+ /// so `closed` is "the purchase call ended" until device testing proves more.
11
+ public final class MpgdPurchaseSheet {
12
+ public enum Fact: String, Sendable {
13
+ case open
14
+ case closed
15
+ case notStarted = "not-started"
16
+ }
17
+
18
+ /// The facts protocol version `getPresentationSupport` declares.
19
+ public static let protocolVersion = 1
20
+
21
+ public let presentationId: String
22
+ private let emit: (String, Fact) -> Void
23
+ private var opened = false
24
+ public private(set) var ended: Fact?
25
+
26
+ public init(presentationId: String, emit: @escaping (String, Fact) -> Void) {
27
+ self.presentationId = presentationId
28
+ self.emit = emit
29
+ }
30
+
31
+ /// The `presentation` value of the call's answer: the terminal fact, or
32
+ /// `open` while the sheet is up, or `requested` before anything happened.
33
+ public var answer: String {
34
+ if let ended { return ended.rawValue }
35
+ return opened ? Fact.open.rawValue : "requested"
36
+ }
37
+
38
+ public func open() {
39
+ guard ended == nil, !opened else { return }
40
+ opened = true
41
+ emit(presentationId, .open)
42
+ }
43
+
44
+ /// The purchase call ended: `closed` after `open`, otherwise `not-started`.
45
+ public func end() {
46
+ guard ended == nil else { return }
47
+ let fact: Fact = opened ? .closed : .notStarted
48
+ ended = fact
49
+ emit(presentationId, fact)
50
+ }
51
+
52
+ /// 1-64 characters of `[A-Za-z0-9-]`, as the TypeScript SDK sends (a UUID).
53
+ public static func isValidPresentationId(_ value: String?) -> Bool {
54
+ guard let value, !value.isEmpty, value.count <= 64 else { return false }
55
+ return value.unicodeScalars.allSatisfy { scalar in
56
+ (scalar.value >= 0x30 && scalar.value <= 0x39)
57
+ || (scalar.value >= 0x41 && scalar.value <= 0x5A)
58
+ || (scalar.value >= 0x61 && scalar.value <= 0x7A)
59
+ || scalar == "-"
60
+ }
61
+ }
62
+ }
@@ -219,6 +219,95 @@ public enum StoreKitTransactionPolicy {
219
219
  return nil
220
220
  }
221
221
 
222
+ /// The transaction a revoked finish (`finishTransaction` with `revoked`)
223
+ /// may finish: the verified, **revoked** consumable with this id whose app
224
+ /// account token equals the session token. The App Store refunded it while
225
+ /// it was unfinished, so StoreKit keeps it in `Transaction.unfinished` and
226
+ /// redelivers it at every launch until `finish()`; finishing it grants
227
+ /// nothing. A transaction that is not revoked (still paid), a durable, and
228
+ /// another or no token never match, so this path never finishes a
229
+ /// purchase the player still holds. Read from `Transaction.unfinished`
230
+ /// only: a finished one has nothing left to finish.
231
+ public static func finishableRevoked(
232
+ transactionId: String,
233
+ appAccountToken: String,
234
+ in entries: [ReadEntry]
235
+ ) -> MpgdStoreKitTransaction? {
236
+ let expected = appAccountToken.lowercased()
237
+ for entry in entries {
238
+ guard case .oneTime(let transaction) = entry,
239
+ transaction.transactionId == transactionId,
240
+ transaction.revokedAtMillis != nil,
241
+ transaction.type == .consumable,
242
+ transaction.appAccountToken == expected else { continue }
243
+ return transaction
244
+ }
245
+ return nil
246
+ }
247
+
248
+ /// The protocol version of the explicit no-token finish
249
+ /// (`finishTransaction` with `withoutAppAccountToken: true`), declared by
250
+ /// `getFinishSupport`. A build from before it answers `UNIMPLEMENTED`
251
+ /// there and refuses such a call, so the TypeScript SDK never sends one.
252
+ public static let finishWithoutAppAccountTokenVersion = 1
253
+
254
+ /// Which app account token one `finishTransaction` call matches.
255
+ public enum FinishBinding: Equatable, Sendable {
256
+ /// The caller's (lowercased, non-nil UUID) token; see `finishable`.
257
+ case appAccountToken(String)
258
+ /// Explicitly none: matches only a non-consumable bound to no token.
259
+ case withoutAppAccountToken
260
+ }
261
+
262
+ /// The binding a `finishTransaction` call asks for, from its raw
263
+ /// arguments; nil refuses the call. Without `withoutAppAccountToken` it is
264
+ /// the token path of earlier builds (a valid, non-nil UUID). With it, only
265
+ /// the literal `true` and no `appAccountToken` argument at all (not even
266
+ /// null) select the no-token match. A missing token is never read as "no
267
+ /// token", and never as a wildcard.
268
+ public static func finishBinding(appAccountToken: Any?, withoutAppAccountToken: Any?) -> FinishBinding? {
269
+ guard let flag = withoutAppAccountToken else {
270
+ guard let token = normalizedAccountToken(appAccountToken as? String) else { return nil }
271
+ return .appAccountToken(token)
272
+ }
273
+ guard isBooleanTrue(flag), appAccountToken == nil else { return nil }
274
+ return .withoutAppAccountToken
275
+ }
276
+
277
+ /// A JSON `true` as Capacitor hands it over (a CFBoolean). A number such
278
+ /// as `1` is an `NSNumber` that `as? Bool` would also read as true.
279
+ static func isBooleanTrue(_ value: Any) -> Bool {
280
+ guard let number = value as? NSNumber else { return false }
281
+ return CFGetTypeID(number) == CFBooleanGetTypeID() && number.boolValue
282
+ }
283
+
284
+ /// `finishable` for either binding. A token matches only a transaction
285
+ /// bound to that token (`finishable(transactionId:appAccountToken:in:)`).
286
+ /// `withoutAppAccountToken` matches only a verified, non-revoked
287
+ /// non-consumable bound to no token (one bought or redeemed outside the
288
+ /// app); never a consumable and never a transaction that carries a token.
289
+ /// The TypeScript SDK sends it only for a durable of its catalog after the
290
+ /// backend's ledger grant; StoreKit keeps a finished non-consumable in
291
+ /// `Transaction.currentEntitlements`, so the finish takes nothing away.
292
+ public static func finishable(
293
+ transactionId: String,
294
+ binding: FinishBinding,
295
+ in entries: [ReadEntry]
296
+ ) -> MpgdStoreKitTransaction? {
297
+ if case .appAccountToken(let token) = binding {
298
+ return finishable(transactionId: transactionId, appAccountToken: token, in: entries)
299
+ }
300
+ for entry in entries {
301
+ guard case .oneTime(let transaction) = entry,
302
+ transaction.transactionId == transactionId,
303
+ transaction.revokedAtMillis == nil,
304
+ transaction.type == .nonConsumable,
305
+ transaction.appAccountToken == nil else { continue }
306
+ return transaction
307
+ }
308
+ return nil
309
+ }
310
+
222
311
  /// The OpenIAP purchase handed to `OpenIapModule.finishTransaction` for a
223
312
  /// transaction the read resolved. OpenIAP finishes by transaction id
224
313
  /// (looking it up in `Transaction.unfinished`, then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {
@@ -38,18 +38,18 @@
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.17.0",
42
- "@mpgd/bridge": "^0.17.0",
43
- "@mpgd/platform": "^0.33.0"
41
+ "@mpgd/adapter-capacitor": "^0.19.0",
42
+ "@mpgd/bridge": "^0.18.0",
43
+ "@mpgd/platform": "^0.35.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
47
  "ttsc": "0.34.2",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.17.0",
50
- "@mpgd/bridge": "0.17.0",
51
- "@mpgd/game-services": "0.31.0",
52
- "@mpgd/platform": "0.33.0"
49
+ "@mpgd/adapter-capacitor": "0.19.0",
50
+ "@mpgd/bridge": "0.18.1",
51
+ "@mpgd/platform": "0.35.0",
52
+ "@mpgd/game-services": "0.33.0"
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 && 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"
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 && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/purchase-presentation.test.ts"
63
63
  }
64
64
  }