@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 +122 -9
- package/dist/commerce-provider.d.ts +10 -0
- package/dist/commerce-provider.js +3 -0
- package/dist/definitions.d.ts +93 -11
- package/dist/native-support.d.ts +17 -0
- package/dist/native-support.js +37 -0
- package/dist/purchase-presentation.d.ts +27 -0
- package/dist/purchase-presentation.js +9 -0
- package/dist/store-sdk.d.ts +31 -2
- package/dist/store-sdk.js +193 -35
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +127 -28
- package/ios/Sources/MpgdStoreKitBridge/PurchaseSheet.swift +62 -0
- package/ios/Sources/MpgdStoreKitBridge/StoreKitTransactionPolicy.swift +89 -0
- package/package.json +9 -9
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
|
|
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
|
|
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
|
|
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.
|
|
221
|
-
|
|
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,
|
package/dist/definitions.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
+
}
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -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):
|
|
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
|
|
262
|
-
* an invalid response, not a quiet "not
|
|
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
|
-
*
|
|
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
|
|
304
|
-
*
|
|
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
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
||
|
|
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
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
448
|
-
|
|
449
|
-
ledgerEntryId,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
call.
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
in: await RecoveryReadShim.
|
|
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
|
|
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.
|
|
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.
|
|
42
|
-
"@mpgd/bridge": "^0.
|
|
43
|
-
"@mpgd/platform": "^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.
|
|
50
|
-
"@mpgd/bridge": "0.
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/
|
|
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
|
}
|