@mpgd/capacitor-storekit 0.4.5 → 0.6.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
@@ -61,10 +61,12 @@ once (a current non-consumable entitlement the native side marks
61
61
  still be verified; the server ledger dedupes. A finished consumable is not
62
62
  listed. A `revokedAt` maps to `state: 'revoked'`, and one malformed
63
63
  transaction is reported as a `purchaseError` without blocking its siblings);
64
- `finishTransaction({ transactionId, ledgerEntryId })` resolves the session
65
- app account token and finishes only a listed, non-revoked transaction bound
66
- to that token, or a durable of another token a restore shared to this
67
- player (see Recovery);
64
+ `finishTransaction({ transactionId, ledgerEntryId, isConsumable, retry? })`
65
+ 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
+ answers `finished: true` without a native call for a consumable the list no
69
+ longer carries (a lost acknowledgement, see Recovery);
68
70
  `restorePurchases` over `sync`; `transactionUpdated` as `purchaseUpdated`;
69
71
  and an availability probe as `getAvailability`, probing the configured
70
72
  SKUs batch by batch until one is sold. Every purchase carries the
@@ -105,7 +107,7 @@ consumables of another token stay excluded. The native
105
107
  or a `purchaseError` subscriber exists.
106
108
 
107
109
  `createCapacitorStoreKitCommerceProvider({ products, getAppAccountToken,
108
- bundleId?, sdk?, isIos?, mapError?, onDiagnostic? })` builds the provider
110
+ purchaseGate?, bundleId?, sdk?, isIos?, mapError?, onDiagnostic? })` builds the provider
109
111
  with `authority: { evidence: 'client-token', accountBinding: 'required' }`
110
112
  and evidence `{ schema: 'app-store.signed-transaction.v1', payload: {
111
113
  signedTransaction, transactionId, originalTransactionId?, bundleId? } }`,
@@ -115,7 +117,18 @@ its `signedTransactionVerifier` (authenticity and transaction id binding)
115
117
  and then, by default, decides the grant on the transaction's current status
116
118
  from the App Store Server API, so a JWS retained from before a refund never
117
119
  grants; see the backend guide for the opt-in offline-only mode. Subscription
118
- mappings are rejected at construction. `createCapacitorStoreKitServiceProvider`
120
+ mappings are rejected at construction. `getAppAccountToken` also filters
121
+ recovery and finishing to the current player, so it must keep answering
122
+ while sales are paused: pause new sales with the optional `purchaseGate`
123
+ instead, which is asked only before a new purchase opens the StoreKit sheet
124
+ (never by recovery, restore, or finishing) and refuses with `failed`,
125
+ `ITEM_UNAVAILABLE`, and `PURCHASES_PAUSED` (see the adapter-capacitor
126
+ README). With an adapter-capacitor from before
127
+ the gate, which would ignore it, the plugin detects the missing gate support
128
+ and answers every new purchase itself with a `failed` result, `ITEM_UNAVAILABLE` and
129
+ `PURCHASE_GATE_UNSUPPORTED` instead of selling; recovery is unaffected.
130
+ A token that cannot be resolved is a `failed` result with
131
+ `STOREKIT_ACCOUNT_LOOKUP_FAILED` (a thrown `CommerceError` keeps its own code). `createCapacitorStoreKitServiceProvider`
119
132
  wraps the provider as a `CapacitorServiceProvider` (id `apple-storekit` by
120
133
  default, no `features`, no `methods`) so the gateway serves the six
121
134
  `commerce.v2.*` bridge methods from it, exposes it as `gateway.commerce`, and
@@ -131,13 +144,17 @@ Finishing never grants. The SDK finishes any transaction it is given a
131
144
  (`createCommerceReconciler` in `@mpgd/game-services`) is the gate, and it
132
145
  calls `finishTransaction` only for a journal record in `granted` or
133
146
  `finishing` that holds the ledger entry id the backend grant wrote
134
- (`assertCommerceFinishAllowed`). The journal revision the grant wrote is the
135
- pairing, and the real control stays server-side, in the verifier that checks
136
- the JWS before the ledger grant, and in the native plugin never granting
137
- anything on finish. `src/commerce-reconciler-storekit-smoke.test.ts` shows the
138
- reconciler over the fake plugin: a grant finishes with the ledger entry, a
139
- rejection and an outage never finish, a failed finish is retried, and another
140
- account's transaction is never submitted.
147
+ (`assertCommerceFinishAllowed`), or for a `finished` consumable StoreKit
148
+ lists again, with that same ledger entry (see Recovery). The journal revision
149
+ the grant wrote is the pairing, and the real control stays server-side, in
150
+ the verifier that checks the JWS before the ledger grant, and in the native
151
+ plugin never granting anything on finish.
152
+ `src/commerce-reconciler-storekit-smoke.test.ts` shows the reconciler over
153
+ the fake plugin: a grant finishes with the ledger entry, a rejection and an
154
+ outage never finish, a failed finish is retried, another account's
155
+ transaction is never submitted, a finish whose answer was lost ends
156
+ `finished` on the next run, a transaction listed again is finished again
157
+ without a second verify, and an Ask to Buy purchase finishes once approved.
141
158
 
142
159
  The conformance test runs every store vector whose profile the App Store
143
160
  supports against a fake plugin, directly and through the gateway bridge path,
@@ -145,6 +162,45 @@ and lists each skipped vector with its reason (host or server finish,
145
162
  quantity, server-push, an asynchronous store error event StoreKit never
146
163
  emits, and a pending transaction state StoreKit never reports).
147
164
 
165
+ ### Purchase sheet facts
166
+
167
+ The provider offers `CommerceProvider.presentation` (see "Store sheet facts"
168
+ in the `@mpgd/adapter-capacitor` README), so the gateway keeps the game
169
+ paused, and the game runtime keeps ads out, for the life of the purchase
170
+ sheet rather than until a result event. The native plugin reports `open`
171
+ right before OpenIAP calls `Product.purchase()` and `closed` when that call
172
+ returns or throws, before it answers; a call it refuses earlier (invalid
173
+ input, provider not ready, product unavailable) is `not-started`. An Ask to
174
+ Buy (`pending`) is `closed` when the call returns; its purchase still arrives
175
+ later through `transactionUpdated`. Facts reach JavaScript as
176
+ `purchasePresentation` events (retained, keyed by a random id per call) and
177
+ in the `presentation` field of the `purchase` answer or rejection data. The
178
+ adapter adds a 15-minute safety timeout.
179
+
180
+ Apple documents that `purchase(options:)` "displays the confirmation sheet"
181
+ and returns a `PurchaseResult`
182
+ ([purchase(options:)](https://developer.apple.com/documentation/storekit/product/purchase(options:)),
183
+ [purchase(confirmIn:options:)](https://developer.apple.com/documentation/storekit/product/purchase(confirmin:options:)-6dj6y)),
184
+ but not that it returns only once the sheet is dismissed. `closed` therefore
185
+ means "the purchase call ended"; that the sheet is gone by then is unverified
186
+ until device testing.
187
+
188
+ `getPresentationSupport` declares the facts. A web bundle running on a native
189
+ build from before them (a live update) gets `UNIMPLEMENTED` there: while an
190
+ adapter follows the facts, the store then reads `configuration-required` (the
191
+ shop sells nothing and the reconciler spends no idempotency key), and a
192
+ purchase that still gets through is refused with `CONFIGURATION_REQUIRED` /
193
+ `STOREKIT_PRESENTATION_UNSUPPORTED` instead of opening a sheet the game would
194
+ wait on; recovery is unaffected. `purchasePresentation: false` on the
195
+ provider keeps the result-based pause of earlier releases.
196
+
197
+ Device acceptance (not run; no device was available): the sheet cancelled, a
198
+ completed purchase, an Ask to Buy request, the app backgrounded with the
199
+ sheet up and resumed, and an iPad (sheet as a form sheet). Each must pause
200
+ the game from `open` to `closed`, and `closed` must not arrive while the sheet
201
+ is still visible. The steps and expected signals are in the kit's
202
+ [purchase device acceptance checklist](https://github.com/imjlk/mpgd-kit/blob/main/docs/PURCHASE_DEVICE_ACCEPTANCE.md).
203
+
148
204
  ### Generated games
149
205
 
150
206
  Games generated by `mpgd game create` (and the kit's private starter) ship
@@ -211,6 +267,44 @@ separately on the server. `restorePurchases` invokes the user-initiated
211
267
  entitlements. Subscriptions are not supported by this package; they require a
212
268
  separate subscription status and renewal flow.
213
269
 
270
+ A finish whose answer is lost after StoreKit applied it (the app is closed
271
+ between the native finish and the journal write) leaves the record
272
+ `finishing`. The reconciler's next finish of a record that was already
273
+ `finishing` carries `retry: true`, and on a retry the SDK reads the
274
+ consumable missing from `getTransactions` as finished: StoreKit keeps a
275
+ transaction in
276
+ [`Transaction.unfinished`](https://developer.apple.com/documentation/storekit/transaction/unfinished)
277
+ until `finish()`, and a finished consumable is in neither that list nor
278
+ `currentEntitlements`, so the SDK answers `finished: true` without a native
279
+ call and the record ends `finished`. The first finish after the grant never
280
+ does: right after a purchase the list may not carry the new transaction yet,
281
+ so a missing one stays `{ finished: false }` and the reconciler retries (by
282
+ then StoreKit lists it and it is finished natively). A caller that never
283
+ sets `retry` (an older reconciler) keeps the earlier behaviour. The
284
+ reconciler finishes only records holding a ledger grant, and nothing is
285
+ verified again. A durable missing from the list is never answered finished
286
+ (a finished durable stays listed with `unfinished: false`, so a missing one
287
+ is revoked or unverifiable), nor is an entry of that id in a malformed
288
+ shape. The kind of a missing transaction is the caller's `isConsumable`
289
+ (the list no longer says, and reading `Transaction.all` would need a native
290
+ change); a durable the catalog maps as a consumable never reaches a grant
291
+ through this SDK, which excludes kind mismatches (`kind-mismatch`).
292
+
293
+ Should StoreKit list the consumable again (an entry the native read skipped
294
+ as unverifiable), the reconciler finishes it again with the journaled ledger
295
+ entry, once per session, without a second verify. If the finished record has
296
+ already left the journal (the generated game's journal keeps terminal
297
+ records for 30 days), the transaction is verified under its derived key; the
298
+ reference backend answers that with this player's recorded grant
299
+ (`alreadyProcessed: true`, the same ledger entry, no second grant) because
300
+ the App Store entry is a `client`-finish consumable, and the reconciler then
301
+ finishes it. Another player's ask under another key stays refused
302
+ (`EVIDENCE_ALREADY_PROCESSED`), so nothing is granted twice. Ask to Buy is
303
+ unaffected: a deferred purchase has no transaction
304
+ until it is approved, so its `launched` record holds no ledger grant and is
305
+ never finished; the approved transaction arrives through `transactionUpdated`
306
+ and is verified and finished once.
307
+
214
308
  ### Migrating from v1
215
309
 
216
310
  Commerce v2 PR C4 removed the v1 provider and its recovery driver.
@@ -1,5 +1,6 @@
1
1
  import { type CapacitorCommerceServiceProvider, type StoreCommerceProductMapping, type StoreCommerceProvider, type StoreSdkErrorCodeMap } from '@mpgd/adapter-capacitor';
2
2
  import { appStoreSignedTransactionEvidenceSchema } from './commerce-profile.js';
3
+ import { type StorePurchaseGate } from './purchase-gate-support.js';
3
4
  import { type CapacitorStoreKitDiagnostic, type CapacitorStoreKitStoreSdkPlugin } from './store-sdk.js';
4
5
  export { appStoreSignedTransactionEvidenceSchema };
5
6
  /** Payload of an `app-store.signed-transaction.v1` envelope. */
@@ -14,9 +15,18 @@ export interface CreateCapacitorStoreKitCommerceProviderInput {
14
15
  readonly products: readonly StoreCommerceProductMapping[];
15
16
  /**
16
17
  * Authenticated, stable UUID app account token of the current player. The
17
- * backend's `resolveAppAccountToken` must resolve the same value.
18
+ * backend's `resolveAppAccountToken` must resolve the same value. It also
19
+ * filters recovery and finishing to this player, so it must keep answering
20
+ * while sales are paused; pause sales with `purchaseGate` instead.
18
21
  */
19
22
  readonly getAppAccountToken: () => string | Promise<string>;
23
+ /**
24
+ * Optional sales switch, asked before a new purchase opens the StoreKit
25
+ * sheet and never by recovery, restore, or finishing, which keep using
26
+ * `getAppAccountToken` alone; a refusal is `ITEM_UNAVAILABLE` with
27
+ * `PURCHASES_PAUSED` (see `StorePurchaseGate` in `@mpgd/adapter-capacitor`).
28
+ */
29
+ readonly purchaseGate?: StorePurchaseGate;
20
30
  /** Recorded in the evidence payload when known; the backend checks the signed value anyway. */
21
31
  readonly bundleId?: string;
22
32
  readonly sdk?: CapacitorStoreKitStoreSdkPlugin;
@@ -29,6 +39,16 @@ export interface CreateCapacitorStoreKitCommerceProviderInput {
29
39
  * types that disagree with the catalog kind. Diagnostics only.
30
40
  */
31
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;
32
52
  }
33
53
  /**
34
54
  * Commerce v2 `CommerceProvider` for the App Store: `createStoreCommerceProvider`
@@ -1,6 +1,7 @@
1
1
  import { createCommerceServiceProvider, createStoreCommerceProvider, } from '@mpgd/adapter-capacitor';
2
2
  import { CommerceError } from '@mpgd/platform/commerce';
3
3
  import { appStoreSignedTransactionEvidenceSchema, appStoreStoreId, storeKitCommerceAuthority, } from './commerce-profile.js';
4
+ import { refuseNewPurchasesUnlessGated, resolvePurchaseGateSupport, } from './purchase-gate-support.js';
4
5
  import { capacitorStoreKitErrorCodeMap, createCapacitorStoreKitStoreSdk, readAppAccountToken, } from './store-sdk.js';
5
6
  // The profile constants live in the SDK-free `commerce-profile.ts` so a tools
6
7
  // project can read them without loading Capacitor; they stay exported here.
@@ -31,6 +32,7 @@ export function createCapacitorStoreKitCommerceProvider(input) {
31
32
  throw new TypeError('StoreKit bundleId must be a non-empty string when provided.');
32
33
  }
33
34
  const bundleId = input.bundleId;
35
+ const gate = resolvePurchaseGateSupport(input.purchaseGate);
34
36
  const sdk = createCapacitorStoreKitStoreSdk({
35
37
  products: input.products.map((product) => ({
36
38
  sku: product.storeSku,
@@ -40,8 +42,11 @@ export function createCapacitorStoreKitCommerceProvider(input) {
40
42
  ...(input.sdk === undefined ? {} : { sdk: input.sdk }),
41
43
  ...(input.isIos === undefined ? {} : { isIos: input.isIos }),
42
44
  ...(input.onDiagnostic === undefined ? {} : { onDiagnostic: input.onDiagnostic }),
45
+ ...(input.purchasePresentation === undefined
46
+ ? {}
47
+ : { purchasePresentation: input.purchasePresentation }),
43
48
  });
44
- return createStoreCommerceProvider({
49
+ return refuseNewPurchasesUnlessGated(createStoreCommerceProvider({
45
50
  sdk,
46
51
  store: appStoreStoreId,
47
52
  products: input.products,
@@ -63,6 +68,9 @@ export function createCapacitorStoreKitCommerceProvider(input) {
63
68
  value = await input.getAppAccountToken();
64
69
  }
65
70
  catch (error) {
71
+ if (error instanceof CommerceError) {
72
+ throw error;
73
+ }
66
74
  throw new CommerceError('ACCOUNT_BINDING_REQUIRED', {
67
75
  message: 'The StoreKit app account token could not be resolved.',
68
76
  storeCode: 'STOREKIT_ACCOUNT_LOOKUP_FAILED',
@@ -86,9 +94,10 @@ export function createCapacitorStoreKitCommerceProvider(input) {
86
94
  });
87
95
  }
88
96
  },
97
+ ...(gate.purchaseGate === undefined ? {} : { purchaseGate: gate.purchaseGate }),
89
98
  authority: storeKitCommerceAuthority,
90
99
  mapError: { ...capacitorStoreKitErrorCodeMap, ...input.mapError },
91
- });
100
+ }), gate);
92
101
  }
93
102
  /**
94
103
  * The commerce provider wrapped as a `CapacitorServiceProvider` for
@@ -26,12 +26,38 @@ 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;
34
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
+ }
35
61
  export interface CapacitorStoreKitPlugin {
36
62
  getProducts(input: {
37
63
  readonly productIds: readonly string[];
@@ -41,7 +67,15 @@ export interface CapacitorStoreKitPlugin {
41
67
  purchase(input: {
42
68
  readonly productId: string;
43
69
  readonly appAccountToken: string;
70
+ /** Asks for this call's sheet facts (`purchasePresentation`); 1-64 of `[A-Za-z0-9-]`. */
71
+ readonly presentationId?: string;
44
72
  }): Promise<StoreKitPurchaseOutcome>;
73
+ /**
74
+ * Declares the sheet facts the native code reports. A native build from
75
+ * before them answers `UNIMPLEMENTED`; optional so a plugin double without
76
+ * it still type-checks, and then the SDK refuses purchases that need facts.
77
+ */
78
+ getPresentationSupport?(): Promise<StoreKitPresentationSupport>;
45
79
  /** Includes unfinished consumables and current non-consumable entitlements. */
46
80
  getTransactions(): Promise<{
47
81
  readonly transactions: readonly StoreKitTransaction[];
@@ -69,4 +103,6 @@ export interface CapacitorStoreKitPlugin {
69
103
  readonly finished: boolean;
70
104
  }>;
71
105
  addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
106
+ /** Sheet facts of `purchase` calls that sent a `presentationId`. */
107
+ addListener(eventName: 'purchasePresentation', listener: (event: StoreKitPurchasePresentationEvent) => void): Promise<PluginListenerHandle>;
72
108
  }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from './commerce-provider.js';
3
3
  export * from './definitions.js';
4
4
  export * from './plugin.js';
5
5
  export * from './store-sdk.js';
6
+ export { purchaseGateUnsupportedStoreCode, type StorePurchaseGate, type StorePurchaseGateDecision, type StorePurchaseGateRequest, } from './purchase-gate-support.js';
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export * from './commerce-provider.js';
3
3
  export * from './definitions.js';
4
4
  export * from './plugin.js';
5
5
  export * from './store-sdk.js';
6
+ export { purchaseGateUnsupportedStoreCode, } from './purchase-gate-support.js';
@@ -0,0 +1,50 @@
1
+ import { CommerceError, type CommerceLogicalProductId, type CommerceProvider, type PurchaseKind, type PurchaseSource, type StoreId } from '@mpgd/platform/commerce';
2
+ /** What a purchase gate is asked about: one new purchase, before any store UI opens. */
3
+ export interface StorePurchaseGateRequest {
4
+ readonly store: StoreId;
5
+ readonly logicalId: CommerceLogicalProductId;
6
+ readonly storeSku: string;
7
+ readonly kind: PurchaseKind;
8
+ readonly source: PurchaseSource;
9
+ }
10
+ /** A purchase gate's answer; see `StorePurchaseGateDecision` in `@mpgd/adapter-capacitor`. */
11
+ export type StorePurchaseGateDecision = {
12
+ readonly allowed: true;
13
+ } | {
14
+ readonly allowed: false;
15
+ readonly storeCode?: string;
16
+ readonly message?: string;
17
+ readonly retryable?: boolean;
18
+ };
19
+ /** The game's sales switch for new purchases; see `StorePurchaseGate` in `@mpgd/adapter-capacitor`. */
20
+ export type StorePurchaseGate = (request: StorePurchaseGateRequest) => StorePurchaseGateDecision | Promise<StorePurchaseGateDecision>;
21
+ /** `storeCode` of every new purchase refused because the adapter cannot run the gate. */
22
+ export declare const purchaseGateUnsupportedStoreCode: 'PURCHASE_GATE_UNSUPPORTED';
23
+ /**
24
+ * Whether the installed `@mpgd/adapter-capacitor` runs `purchaseGate`. An
25
+ * adapter from before the gate ignores the option and would keep selling, so
26
+ * the probe reads an export the gate release added (`purchasesPausedStoreCode`)
27
+ * through the module namespace, where a missing export is `undefined` rather
28
+ * than a link error.
29
+ */
30
+ export declare function adapterSupportsPurchaseGate(adapterExports?: object): boolean;
31
+ export interface PurchaseGateSupport {
32
+ /** The gate to hand the adapter, when it runs one. */
33
+ readonly purchaseGate?: StorePurchaseGate;
34
+ /**
35
+ * Set when a gate was given but the adapter cannot run it: the plugin then
36
+ * answers every new purchase itself (`refuseNewPurchasesUnlessGated`).
37
+ */
38
+ readonly refuseNewPurchases: boolean;
39
+ }
40
+ /** Fails closed: a gate the adapter would ignore refuses every new purchase instead. */
41
+ export declare function resolvePurchaseGateSupport(purchaseGate: StorePurchaseGate | undefined, supported?: boolean): PurchaseGateSupport;
42
+ export declare function createPurchaseGateUnsupportedError(): CommerceError;
43
+ /**
44
+ * Refuses every new purchase at the plugin boundary when the adapter cannot
45
+ * run the configured gate, before the adapter's `requestPurchase` (which
46
+ * would open the store) is reached. The refusal is a `failed` result, not a
47
+ * throw, so `PURCHASE_GATE_UNSUPPORTED` survives the bridge whatever the
48
+ * adapter version. Recovery, finishing, restore, and events are untouched.
49
+ */
50
+ export declare function refuseNewPurchasesUnlessGated<TProvider extends CommerceProvider>(provider: TProvider, support: PurchaseGateSupport): TProvider;
@@ -0,0 +1,50 @@
1
+ import * as adapterCapacitor from '@mpgd/adapter-capacitor';
2
+ import { CommerceError, } from '@mpgd/platform/commerce';
3
+ /** `storeCode` of every new purchase refused because the adapter cannot run the gate. */
4
+ export const purchaseGateUnsupportedStoreCode = 'PURCHASE_GATE_UNSUPPORTED';
5
+ /**
6
+ * Whether the installed `@mpgd/adapter-capacitor` runs `purchaseGate`. An
7
+ * adapter from before the gate ignores the option and would keep selling, so
8
+ * the probe reads an export the gate release added (`purchasesPausedStoreCode`)
9
+ * through the module namespace, where a missing export is `undefined` rather
10
+ * than a link error.
11
+ */
12
+ export function adapterSupportsPurchaseGate(adapterExports = adapterCapacitor) {
13
+ return typeof adapterExports.purchasesPausedStoreCode
14
+ === 'string';
15
+ }
16
+ /** Fails closed: a gate the adapter would ignore refuses every new purchase instead. */
17
+ export function resolvePurchaseGateSupport(purchaseGate, supported = adapterSupportsPurchaseGate()) {
18
+ if (purchaseGate === undefined) {
19
+ return { refuseNewPurchases: false };
20
+ }
21
+ return supported ? { purchaseGate, refuseNewPurchases: false } : { refuseNewPurchases: true };
22
+ }
23
+ export function createPurchaseGateUnsupportedError() {
24
+ return new CommerceError('ITEM_UNAVAILABLE', {
25
+ message: 'A purchase gate is configured but @mpgd/adapter-capacitor is too old to run it; '
26
+ + 'new purchases are refused until the adapter is upgraded.',
27
+ storeCode: purchaseGateUnsupportedStoreCode,
28
+ retryable: false,
29
+ });
30
+ }
31
+ /**
32
+ * Refuses every new purchase at the plugin boundary when the adapter cannot
33
+ * run the configured gate, before the adapter's `requestPurchase` (which
34
+ * would open the store) is reached. The refusal is a `failed` result, not a
35
+ * throw, so `PURCHASE_GATE_UNSUPPORTED` survives the bridge whatever the
36
+ * adapter version. Recovery, finishing, restore, and events are untouched.
37
+ */
38
+ export function refuseNewPurchasesUnlessGated(provider, support) {
39
+ if (!support.refuseNewPurchases) {
40
+ return provider;
41
+ }
42
+ return Object.assign(provider, {
43
+ async requestPurchase() {
44
+ return {
45
+ outcome: 'failed',
46
+ error: createPurchaseGateUnsupportedError().toCommerceErrorInfo(),
47
+ };
48
+ },
49
+ });
50
+ }
@@ -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'>;
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
package/dist/store-sdk.js CHANGED
@@ -2,6 +2,15 @@ import { Capacitor } from '@capacitor/core';
2
2
  import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
3
3
  import { appStoreStoreId, storeKitStoreSdkCapabilities } from './commerce-profile.js';
4
4
  import { CapacitorStoreKit } from './plugin.js';
5
+ import { resolveStoreKitNativePurchasePresentation, } from './purchase-presentation.js';
6
+ /**
7
+ * The code of a purchase refused because the native plugin in the app binary
8
+ * reports no sheet facts (a web bundle newer than its native build). The
9
+ * provider offers `presentation`, so a purchase it could not follow is not
10
+ * sold; rebuilding the app with this package's native code, or
11
+ * `purchasePresentation: false`, sells again. Recovery is unaffected.
12
+ */
13
+ export const storeKitPresentationUnsupportedCode = 'STOREKIT_PRESENTATION_UNSUPPORTED';
5
14
  /**
6
15
  * Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
7
16
  * StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
@@ -23,6 +32,10 @@ import { CapacitorStoreKit } from './plugin.js';
23
32
  */
24
33
  export function createCapacitorStoreKitStoreSdk(input) {
25
34
  const sdk = input.sdk ?? CapacitorStoreKit;
35
+ // Sheet facts of `purchase` calls that carry a presentation id.
36
+ const presentation = input.purchasePresentation === false
37
+ ? undefined
38
+ : resolveStoreKitNativePurchasePresentation(sdk);
26
39
  const isIos = input.isIos ?? (() => Capacitor.getPlatform() === 'ios');
27
40
  const onDiagnostic = input.onDiagnostic ?? (() => undefined);
28
41
  if (typeof input.getAppAccountToken !== 'function') {
@@ -259,9 +272,33 @@ export function createCapacitorStoreKitStoreSdk(input) {
259
272
  /**
260
273
  * Mirrors the native finish gate: the transaction must be listed, not
261
274
  * revoked, and bound to the session app account token. A missing list is
262
- * an invalid response, not a quiet "not finished". Two exceptions for a
263
- * listed, non-revoked durable, the only kind the restore read admits from
264
- * another token:
275
+ * an invalid response, not a quiet "not finished".
276
+ *
277
+ * On a retried finish (`retry`: the caller asked to finish this purchase
278
+ * before, in an earlier attempt or session), a consumable the list no
279
+ * longer carries is already finished: StoreKit keeps a transaction in
280
+ * `Transaction.unfinished` until `finish()`, and a finished consumable is in
281
+ * neither that list nor `currentEntitlements`. That is the lost
282
+ * acknowledgement: an earlier finish reached StoreKit but its answer never
283
+ * reached the journal (the app was closed between the two), and the record
284
+ * would otherwise stay `finishing` forever. A first finish never reads a
285
+ * missing transaction that way: right after the grant the list may not
286
+ * carry the new transaction yet, so it stays refused and the caller retries.
287
+ * Nothing native is called, and an entry of that id the native side reports
288
+ * in any shape keeps it refused. If the transaction is listed again later
289
+ * (an unverifiable entry the native read skipped, or a list read before
290
+ * StoreKit carried it), the reconciler finishes it again without a second
291
+ * verify. A durable missing from the list is refused: a durable stays in
292
+ * `currentEntitlements` after its finish, so a missing one is revoked or
293
+ * unverifiable, never a lost acknowledgement. The kind of a missing
294
+ * transaction is the caller's `isConsumable` (the list no longer says), so a
295
+ * catalog that maps a durable as a consumable would be answered finished
296
+ * here; the SDK never admits such a transaction (`kind-mismatch`), so the
297
+ * reconciler cannot hold a grant for it from this SDK, and the answer
298
+ * neither grants nor takes anything.
299
+ *
300
+ * Two exceptions for a listed, non-revoked durable, the only kind the
301
+ * restore read admits from another token:
265
302
  *
266
303
  * - StoreKit already finished it (`unfinished: false`): nothing is left to
267
304
  * finish whatever its token, so a restore shared to this player ends
@@ -279,13 +316,17 @@ export function createCapacitorStoreKitStoreSdk(input) {
279
316
  * A consumable of another token, or a transaction bound to no token, is
280
317
  * never finished here.
281
318
  */
282
- const finishPermitted = async (transactionId, appAccountToken) => {
319
+ const finishPermitted = async (transactionId, appAccountToken, lostAcknowledgement) => {
283
320
  const response = await sdk.getTransactions();
284
321
  if (!isRecord(response) || !Array.isArray(response.transactions)) {
285
322
  throw evidenceInvalid('StoreKit returned an invalid transaction list.');
286
323
  }
287
- const transaction = response.transactions.find((candidate) => isValidStoreKitTransaction(candidate) && candidate.transactionId === transactionId);
288
- if (transaction === undefined || transaction.revokedAt !== undefined) {
324
+ const entries = response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
325
+ const transaction = entries.find(isValidStoreKitTransaction);
326
+ if (transaction === undefined) {
327
+ return { verdict: entries.length === 0 && lostAcknowledgement ? 'finished' : 'refused' };
328
+ }
329
+ if (transaction.revokedAt !== undefined) {
289
330
  return { verdict: 'refused' };
290
331
  }
291
332
  if (transaction.type === 'non_consumable' && transaction.unfinished === false) {
@@ -304,6 +345,7 @@ export function createCapacitorStoreKitStoreSdk(input) {
304
345
  };
305
346
  return {
306
347
  store: appStoreStoreId,
348
+ ...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
307
349
  capabilities: storeKitStoreSdkCapabilities,
308
350
  async fetchProducts(request) {
309
351
  requireIos();
@@ -326,7 +368,33 @@ export function createCapacitorStoreKitStoreSdk(input) {
326
368
  requireIos();
327
369
  const appAccountToken = readAppAccountToken(request.accountBinding);
328
370
  const expected = await assertSellable(request.sku);
329
- const outcome = await sdk.purchase({ productId: request.sku, appAccountToken });
371
+ // Only an adapter that reads sheet facts sends an id (one from before
372
+ // them never does, and the purchase runs as in earlier releases).
373
+ const presentationId = presentation === undefined ? undefined : request.presentationId;
374
+ if (presentation !== undefined && presentationId !== undefined) {
375
+ if (!(await presentation.supported())) {
376
+ throw storeKitError(storeKitPresentationUnsupportedCode, 'The app was built with a StoreKit plugin that reports no purchase sheet facts; '
377
+ + "rebuild it with this package's native code.", false);
378
+ }
379
+ presentation.begin(presentationId);
380
+ }
381
+ let outcome;
382
+ try {
383
+ outcome = await sdk.purchase({
384
+ productId: request.sku,
385
+ appAccountToken,
386
+ ...(presentationId === undefined ? {} : { presentationId }),
387
+ });
388
+ }
389
+ catch (error) {
390
+ if (presentationId !== undefined) {
391
+ presentation?.answered(presentationId, error);
392
+ }
393
+ throw error;
394
+ }
395
+ if (presentationId !== undefined) {
396
+ presentation?.answered(presentationId, outcome);
397
+ }
330
398
  if (!isRecord(outcome)) {
331
399
  throw evidenceInvalid('StoreKit returned an invalid purchase result.');
332
400
  }
@@ -409,8 +477,10 @@ export function createCapacitorStoreKitStoreSdk(input) {
409
477
  const promise = (async () => {
410
478
  // Same match the native plugin applies: missing, revoked, unbound, or
411
479
  // another account never reaches `transaction.finish()`, except a
412
- // durable of another token (see `finishPermitted`).
413
- const permitted = await finishPermitted(transactionId, appAccountToken);
480
+ // durable of another token (see `finishPermitted`). On a retry, a
481
+ // consumable that left the unfinished list is already finished (a
482
+ // lost acknowledgement).
483
+ const permitted = await finishPermitted(transactionId, appAccountToken, request.isConsumable === true && request.retry === true);
414
484
  if (permitted.verdict !== 'finish') {
415
485
  return { finished: permitted.verdict === 'finished' };
416
486
  }
@@ -464,6 +534,12 @@ export function createCapacitorStoreKitStoreSdk(input) {
464
534
  if (productIds.length === 0) {
465
535
  return 'configuration-required';
466
536
  }
537
+ // An adapter that reads sheet facts over a native build without them
538
+ // would refuse every purchase: report the store as needing a rebuild,
539
+ // so the shop sells nothing and no idempotency key is spent.
540
+ if (presentation !== undefined && presentation.followed() && !(await presentation.supported())) {
541
+ return 'configuration-required';
542
+ }
467
543
  try {
468
544
  // Probe batch by batch until one configured product is sold; only a
469
545
  // catalog with no sellable product at all is a configuration problem.
@@ -513,6 +589,7 @@ export const storeKitStoreSdkErrorCodeMap = Object.freeze({
513
589
  STOREKIT_FINISH_REQUIRES_GRANT: 'CONFIGURATION_REQUIRED',
514
590
  STOREKIT_EVIDENCE_INVALID: 'INVALID_RESPONSE',
515
591
  STOREKIT_TRANSACTION_UNVERIFIED: 'INVALID_RESPONSE',
592
+ STOREKIT_PRESENTATION_UNSUPPORTED: 'CONFIGURATION_REQUIRED',
516
593
  });
517
594
  /** The default table plus every StoreKit code; what the provider uses. */
518
595
  export const capacitorStoreKitErrorCodeMap = Object.freeze({
@@ -18,7 +18,8 @@ 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)
22
23
  ]
23
24
 
24
25
  private let connection = OpenIapAppleConnection()
@@ -79,25 +80,57 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
79
80
  }
80
81
  }
81
82
 
83
+ /// Declares the sheet facts this native code reports
84
+ /// (`purchasePresentation` events and the `presentation` field of
85
+ /// `purchase` answers). A build without this method answers
86
+ /// `UNIMPLEMENTED`, and the TypeScript SDK then sells nothing that would
87
+ /// wait for facts.
88
+ @objc func getPresentationSupport(_ call: CAPPluginCall) {
89
+ call.resolve(["purchasePresentation": MpgdPurchaseSheet.protocolVersion])
90
+ }
91
+
82
92
  @objc func purchase(_ call: CAPPluginCall) {
93
+ // Correlates this call's sheet facts; sent only by a TypeScript SDK
94
+ // that reads them. Without it the call reports no facts, as before.
95
+ let presentationId = call.getString("presentationId")
96
+ if presentationId != nil && !MpgdPurchaseSheet.isValidPresentationId(presentationId) {
97
+ call.reject("The presentation id is invalid.", "STOREKIT_INVALID_PURCHASE", nil,
98
+ ["presentation": MpgdPurchaseSheet.Fact.notStarted.rawValue])
99
+ return
100
+ }
101
+ let sheet = presentationId.map { id in
102
+ MpgdPurchaseSheet(presentationId: id) { [weak self] id, fact in
103
+ self?.notifyPresentation(id, fact)
104
+ }
105
+ }
83
106
  guard let productId = call.getString("productId"),
84
107
  StoreKitTransactionPolicy.isValidProductId(productId),
85
108
  let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
86
109
  call.getString("appAccountToken"), allowNil: true) else {
87
- call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
110
+ sheet?.end()
111
+ call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE",
112
+ nil, Self.presentationData(sheet))
113
+ return
114
+ }
115
+ if let message = connection.providerError {
116
+ sheet?.end()
117
+ call.reject(message, "STOREKIT_CONFIGURATION_ERROR", nil, Self.presentationData(sheet))
88
118
  return
89
119
  }
90
- guard providerReady(call) else { return }
91
120
  Task { @MainActor [module] in
92
121
  do {
93
122
  let result = try await module.fetchProducts(ProductRequest(skus: [productId], type: .inApp))
94
123
  guard let product = Self.iosProducts(result).first(where: { $0.id == productId }),
95
124
  MpgdOneTimeType(productType: product.typeIOS) != nil else {
96
- call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
125
+ sheet?.end()
126
+ call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE",
127
+ nil, Self.presentationData(sheet))
97
128
  return
98
129
  }
99
130
  } catch {
100
- call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
131
+ sheet?.end()
132
+ call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error,
133
+ Self.presentationData(sheet))
101
134
  return
102
135
  }
103
136
  let request = RequestPurchaseProps(
@@ -109,40 +142,68 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
109
142
  ))),
110
143
  type: .inApp
111
144
  )
145
+ // `open` right before OpenIAP calls `Product.purchase()`, which
146
+ // presents the sheet; `closed` once that call returned or threw,
147
+ // before the answer, which carries the same fact.
148
+ sheet?.open()
112
149
  do {
113
150
  let outcome = try await module.requestPurchase(request)
151
+ sheet?.end()
114
152
  guard case .purchase(let purchased?) = outcome,
115
153
  case .purchaseIos(let ios) = purchased,
116
154
  let transaction = StoreKitTransactionPolicy.transaction(from: ios),
117
155
  StoreKitTransactionPolicy.matchesPurchaseRequest(
118
156
  transaction, productId: productId, appAccountToken: accountToken) else {
119
157
  call.reject("StoreKit transaction failed verification or account binding.",
120
- "STOREKIT_TRANSACTION_UNVERIFIED")
158
+ "STOREKIT_TRANSACTION_UNVERIFIED", nil, Self.presentationData(sheet))
121
159
  return
122
160
  }
123
- call.resolve([
161
+ call.resolve(Self.withPresentation([
124
162
  "status": "purchased",
125
163
  "transaction": StoreKitTransactionPolicy.payload(transaction)
126
- ])
164
+ ], sheet))
127
165
  } catch let error as PurchaseError {
166
+ sheet?.end()
128
167
  switch StoreKitTransactionPolicy.purchaseFailure(error.code) {
129
168
  case .cancelled:
130
- call.resolve(["status": "cancelled"])
169
+ call.resolve(Self.withPresentation(["status": "cancelled"], sheet))
131
170
  case .pending:
132
171
  // Ask to Buy: the approved transaction arrives through
133
- // `transactionUpdated` or `getTransactions` later.
134
- call.resolve(["status": "pending"])
172
+ // `transactionUpdated` or `getTransactions` later. The
173
+ // sheet itself is done.
174
+ call.resolve(Self.withPresentation(["status": "pending"], sheet))
135
175
  case .rejected(let code, let message):
136
- call.reject(message, code, error)
176
+ call.reject(message, code, error, Self.presentationData(sheet))
137
177
  }
138
178
  } catch {
179
+ sheet?.end()
139
180
  // The purchase sheet may have completed before an error reached us.
140
181
  call.reject("StoreKit purchase result is uncertain; requery transactions.",
141
- "STOREKIT_PURCHASE_UNCERTAIN", error)
182
+ "STOREKIT_PURCHASE_UNCERTAIN", error, Self.presentationData(sheet))
142
183
  }
143
184
  }
144
185
  }
145
186
 
187
+ /// Retained until a listener consumes it; the random id keeps a fact of
188
+ /// an earlier web session from matching a new call.
189
+ private func notifyPresentation(_ presentationId: String, _ fact: MpgdPurchaseSheet.Fact) {
190
+ notifyListeners("purchasePresentation",
191
+ data: ["presentationId": presentationId, "state": fact.rawValue],
192
+ retainUntilConsumed: true)
193
+ }
194
+
195
+ private static func presentationData(_ sheet: MpgdPurchaseSheet?) -> [String: Any]? {
196
+ guard let sheet else { return nil }
197
+ return ["presentation": sheet.answer]
198
+ }
199
+
200
+ private static func withPresentation(_ answer: [String: Any], _ sheet: MpgdPurchaseSheet?) -> [String: Any] {
201
+ guard let sheet else { return answer }
202
+ var answered = answer
203
+ answered["presentation"] = sheet.answer
204
+ return answered
205
+ }
206
+
146
207
  @objc func getTransactions(_ call: CAPPluginCall) {
147
208
  guard providerReady(call) else { return }
148
209
  Task {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-storekit",
3
- "version": "0.4.5",
3
+ "version": "0.6.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.16.0",
42
- "@mpgd/bridge": "^0.16.0",
43
- "@mpgd/platform": "^0.32.0"
41
+ "@mpgd/adapter-capacitor": "^0.18.0",
42
+ "@mpgd/bridge": "^0.18.0",
43
+ "@mpgd/platform": "^0.34.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
47
- "ttsc": "0.32.1",
47
+ "ttsc": "0.34.2",
48
48
  "typescript": "7.0.2",
49
- "@mpgd/adapter-capacitor": "0.16.3",
50
- "@mpgd/bridge": "0.16.4",
51
- "@mpgd/game-services": "0.30.5",
52
- "@mpgd/platform": "0.32.0"
49
+ "@mpgd/adapter-capacitor": "0.18.0",
50
+ "@mpgd/bridge": "0.18.0",
51
+ "@mpgd/platform": "0.34.0",
52
+ "@mpgd/game-services": "0.32.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
  }