@mpgd/capacitor-storekit 0.6.0 → 0.7.1
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 +83 -9
- package/dist/definitions.d.ts +56 -10
- package/dist/native-support.d.ts +17 -0
- package/dist/native-support.js +37 -0
- package/dist/store-sdk.d.ts +7 -1
- package/dist/store-sdk.js +145 -34
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +54 -16
- package/ios/Sources/MpgdStoreKitBridge/StoreKitTransactionPolicy.swift +89 -0
- package/package.json +7 -7
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`
|
|
@@ -240,7 +247,7 @@ existing ledger grant, and calls `finishTransaction` only after a verified
|
|
|
240
247
|
response with a ledger entry id. The StoreKit SDK and the native plugin both
|
|
241
248
|
refuse to call `transaction.finish()` on a transaction whose app account
|
|
242
249
|
token is missing or does not match the token they are given; the SDK gives the
|
|
243
|
-
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
|
|
244
251
|
still present it with a forged ledger entry id; that remaining hole needs a
|
|
245
252
|
signed server finish capability. A listed, non-revoked durable StoreKit
|
|
246
253
|
already finished (`unfinished: false`) answers `{ finished: true }` without a
|
|
@@ -256,8 +263,38 @@ durable takes nothing from the earlier guest: StoreKit keeps it in
|
|
|
256
263
|
(`unfinished: false`). The native plugin is unchanged and still filters
|
|
257
264
|
nothing by token on reads. A webview caller can therefore finish another
|
|
258
265
|
token's unfinished durable, which, for the same reason, grants or removes
|
|
259
|
-
nothing.
|
|
260
|
-
|
|
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
|
|
261
298
|
transactions are not finished: the client cannot prove content delivery for
|
|
262
299
|
them, so persist that terminal result in the game-owned operation journal and
|
|
263
300
|
do not run an automatic retry loop for it. An unknown backend result stays
|
|
@@ -305,6 +342,43 @@ until it is approved, so its `launched` record holds no ledger grant and is
|
|
|
305
342
|
never finished; the approved transaction arrives through `transactionUpdated`
|
|
306
343
|
and is verified and finished once.
|
|
307
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
|
+
|
|
308
382
|
### Migrating from v1
|
|
309
383
|
|
|
310
384
|
Commerce v2 PR C4 removed the v1 provider and its recovery driver.
|
package/dist/definitions.d.ts
CHANGED
|
@@ -58,6 +58,45 @@ export interface StoreKitPurchasePresentationEvent {
|
|
|
58
58
|
export interface StoreKitPresentationSupport {
|
|
59
59
|
readonly purchasePresentation: number;
|
|
60
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;
|
|
99
|
+
};
|
|
61
100
|
export interface CapacitorStoreKitPlugin {
|
|
62
101
|
getProducts(input: {
|
|
63
102
|
readonly productIds: readonly string[];
|
|
@@ -76,6 +115,13 @@ export interface CapacitorStoreKitPlugin {
|
|
|
76
115
|
* it still type-checks, and then the SDK refuses purchases that need facts.
|
|
77
116
|
*/
|
|
78
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>;
|
|
79
125
|
/** Includes unfinished consumables and current non-consumable entitlements. */
|
|
80
126
|
getTransactions(): Promise<{
|
|
81
127
|
readonly transactions: readonly StoreKitTransaction[];
|
|
@@ -85,21 +131,21 @@ export interface CapacitorStoreKitPlugin {
|
|
|
85
131
|
readonly synced: boolean;
|
|
86
132
|
}>;
|
|
87
133
|
/**
|
|
88
|
-
* Call only after the authenticated backend confirms the ledger grant
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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`.
|
|
91
141
|
* The StoreKit SDK injects the session token and applies the same match
|
|
92
142
|
* before this call, including for `commerce.v2.finishTransaction`. Games
|
|
93
143
|
* must not call this plugin method directly. The token match is not a
|
|
94
144
|
* signed server capability: a caller who can read `getTransactions` can
|
|
95
|
-
* 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.
|
|
96
147
|
*/
|
|
97
|
-
finishTransaction(input: {
|
|
98
|
-
readonly transactionId: string;
|
|
99
|
-
readonly ledgerEntryId: string;
|
|
100
|
-
/** Session app account token; must match the verified transaction. */
|
|
101
|
-
readonly appAccountToken: string;
|
|
102
|
-
}): Promise<{
|
|
148
|
+
finishTransaction(input: StoreKitFinishTransactionInput): Promise<{
|
|
103
149
|
readonly finished: boolean;
|
|
104
150
|
}>;
|
|
105
151
|
addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
|
|
@@ -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
|
+
}
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { storeKitStoreSdkCapabilities } from './commerce-profile.js';
|
|
|
5
5
|
import { type StoreKitSdkPurchasePresentation } from './purchase-presentation.js';
|
|
6
6
|
export type { StoreKitSdkPurchasePresentation, StoreKitSdkPurchasePresentationEvent, } from './purchase-presentation.js';
|
|
7
7
|
/** The plugin surface the commerce v2 SDK drives; `CapacitorStoreKit` by default. */
|
|
8
|
-
export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener' | 'getPresentationSupport'>;
|
|
8
|
+
export type CapacitorStoreKitStoreSdkPlugin = Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction' | 'addListener' | 'getPresentationSupport' | 'getFinishSupport'>;
|
|
9
9
|
/**
|
|
10
10
|
* The code of a purchase refused because the native plugin in the app binary
|
|
11
11
|
* reports no sheet facts (a web bundle newer than its native build). The
|
|
@@ -102,6 +102,12 @@ export { storeKitStoreSdkCapabilities };
|
|
|
102
102
|
export declare const storeKitStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
|
|
103
103
|
/** The default table plus every StoreKit code; what the provider uses. */
|
|
104
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:";
|
|
105
111
|
/** Normalizes a required `app-account-token` binding to the lowercase UUID StoreKit expects. */
|
|
106
112
|
export declare function readAppAccountToken(binding: StorePurchaseAccountBinding | undefined): string;
|
|
107
113
|
/** Maps a verified StoreKit transaction to the SDK purchase: the JWS is the token. */
|
package/dist/store-sdk.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
5
6
|
import { resolveStoreKitNativePurchasePresentation, } from './purchase-presentation.js';
|
|
6
7
|
/**
|
|
@@ -266,13 +267,27 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
266
267
|
}
|
|
267
268
|
return expected;
|
|
268
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);
|
|
269
283
|
// Overlapping finishes for the same transaction and ledger entry share one
|
|
270
284
|
// native call; the v2 reconciler is the gate that authorizes the call.
|
|
271
285
|
const inFlightFinishes = new Map();
|
|
272
286
|
/**
|
|
273
287
|
* Mirrors the native finish gate: the transaction must be listed, not
|
|
274
|
-
* revoked, and bound to the session app account token
|
|
275
|
-
* 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".
|
|
276
291
|
*
|
|
277
292
|
* On a retried finish (`retry`: the caller asked to finish this purchase
|
|
278
293
|
* before, in an earlier attempt or session), a consumable the list no
|
|
@@ -297,8 +312,8 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
297
312
|
* reconciler cannot hold a grant for it from this SDK, and the answer
|
|
298
313
|
* neither grants nor takes anything.
|
|
299
314
|
*
|
|
300
|
-
*
|
|
301
|
-
* 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:
|
|
302
317
|
*
|
|
303
318
|
* - StoreKit already finished it (`unfinished: false`): nothing is left to
|
|
304
319
|
* finish whatever its token, so a restore shared to this player ends
|
|
@@ -312,16 +327,24 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
312
327
|
* finishing it takes nothing from any player: the earlier guest's next
|
|
313
328
|
* read still lists it (`unfinished: false`) for its own verification. It
|
|
314
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.
|
|
315
342
|
*
|
|
316
|
-
* A
|
|
317
|
-
*
|
|
343
|
+
* A revoked finish (`revoked`, see `revokedFinishPermitted`) has its own
|
|
344
|
+
* gate; an ordinary finish of a revoked transaction stays refused.
|
|
318
345
|
*/
|
|
319
346
|
const finishPermitted = async (transactionId, appAccountToken, lostAcknowledgement) => {
|
|
320
|
-
const
|
|
321
|
-
if (!isRecord(response) || !Array.isArray(response.transactions)) {
|
|
322
|
-
throw evidenceInvalid('StoreKit returned an invalid transaction list.');
|
|
323
|
-
}
|
|
324
|
-
const entries = response.transactions.filter((candidate) => isRecord(candidate) && candidate.transactionId === transactionId);
|
|
347
|
+
const entries = await listedEntries(transactionId);
|
|
325
348
|
const transaction = entries.find(isValidStoreKitTransaction);
|
|
326
349
|
if (transaction === undefined) {
|
|
327
350
|
return { verdict: entries.length === 0 && lostAcknowledgement ? 'finished' : 'refused' };
|
|
@@ -334,17 +357,61 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
334
357
|
}
|
|
335
358
|
const bound = transaction.appAccountToken?.toLowerCase();
|
|
336
359
|
if (bound === appAccountToken) {
|
|
337
|
-
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 } };
|
|
367
|
+
}
|
|
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.');
|
|
338
377
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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';
|
|
343
403
|
}
|
|
344
|
-
return
|
|
404
|
+
return transaction.revokedAt !== undefined
|
|
405
|
+
&& transaction.type === 'consumable'
|
|
406
|
+
&& kinds.get(transaction.productId) === 'consumable'
|
|
407
|
+
&& transaction.appAccountToken?.toLowerCase() === appAccountToken
|
|
408
|
+
? 'finish'
|
|
409
|
+
: 'refused';
|
|
345
410
|
};
|
|
346
411
|
return {
|
|
347
412
|
store: appStoreStoreId,
|
|
413
|
+
// `finishTransaction` reads `revoked` (see `revokedFinishPermitted`).
|
|
414
|
+
acceptsRevokedFinish: true,
|
|
348
415
|
...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
|
|
349
416
|
capabilities: storeKitStoreSdkCapabilities,
|
|
350
417
|
async fetchProducts(request) {
|
|
@@ -463,38 +530,70 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
463
530
|
requireIos();
|
|
464
531
|
// The session token is the binding. A bridge or plugin caller cannot
|
|
465
532
|
// substitute another account's token on this path: the only other
|
|
466
|
-
//
|
|
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.
|
|
467
535
|
const appAccountToken = await Promise.resolve(resolveAppAccountToken());
|
|
468
|
-
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;
|
|
469
541
|
if (typeof transactionId !== 'string' || !transactionPattern.test(transactionId)
|
|
470
|
-
||
|
|
542
|
+
|| (given === undefined
|
|
543
|
+
? !revoked
|
|
544
|
+
: typeof given !== 'string' || given.trim() === '')) {
|
|
471
545
|
throw storeKitError('STOREKIT_FINISH_REQUIRES_GRANT', 'StoreKit finish requires a transaction id and the ledger entry that granted it.', false);
|
|
472
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}`;
|
|
473
554
|
const active = inFlightFinishes.get(transactionId);
|
|
474
|
-
if (active !== undefined
|
|
555
|
+
if (active !== undefined
|
|
556
|
+
&& active.ledgerEntryId === ledgerEntryId
|
|
557
|
+
&& active.revoked === revoked) {
|
|
475
558
|
return active.promise;
|
|
476
559
|
}
|
|
477
560
|
const promise = (async () => {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
481
581
|
// consumable that left the unfinished list is already finished (a
|
|
482
582
|
// lost acknowledgement).
|
|
483
583
|
const permitted = await finishPermitted(transactionId, appAccountToken, request.isConsumable === true && request.retry === true);
|
|
484
584
|
if (permitted.verdict !== 'finish') {
|
|
485
585
|
return { finished: permitted.verdict === 'finished' };
|
|
486
586
|
}
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
ledgerEntryId,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
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
|
+
}));
|
|
496
595
|
})();
|
|
497
|
-
inFlightFinishes.set(transactionId, { ledgerEntryId, promise });
|
|
596
|
+
inFlightFinishes.set(transactionId, { ledgerEntryId, revoked, promise });
|
|
498
597
|
try {
|
|
499
598
|
return await promise;
|
|
500
599
|
}
|
|
@@ -597,6 +696,12 @@ export const capacitorStoreKitErrorCodeMap = Object.freeze({
|
|
|
597
696
|
...storeKitStoreSdkErrorCodeMap,
|
|
598
697
|
});
|
|
599
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:';
|
|
600
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;
|
|
601
706
|
const nilUuid = '00000000-0000-0000-0000-000000000000';
|
|
602
707
|
const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
@@ -676,6 +781,12 @@ function isValidStoreKitProduct(value) {
|
|
|
676
781
|
&& typeof value.formattedPrice === 'string'
|
|
677
782
|
&& typeof value.currencyCode === 'string' && value.currencyCode.length > 0;
|
|
678
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
|
+
}
|
|
679
790
|
function storeKitError(code, message, retryable) {
|
|
680
791
|
return Object.assign(new Error(message), { code, retryable });
|
|
681
792
|
}
|
|
@@ -19,7 +19,8 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
19
19
|
CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
|
|
20
20
|
CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
|
|
21
21
|
CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise),
|
|
22
|
-
CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise)
|
|
22
|
+
CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "getFinishSupport", returnType: CAPPluginReturnPromise)
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
private let connection = OpenIapAppleConnection()
|
|
@@ -89,6 +90,15 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
89
90
|
call.resolve(["purchasePresentation": MpgdPurchaseSheet.protocolVersion])
|
|
90
91
|
}
|
|
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
|
+
|
|
92
102
|
@objc func purchase(_ call: CAPPluginCall) {
|
|
93
103
|
// Correlates this call's sheet facts; sent only by a TypeScript SDK
|
|
94
104
|
// that reads them. Without it the call reports no facts, as before.
|
|
@@ -241,11 +251,15 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
241
251
|
// SDK supplies the session token and applies the same match before
|
|
242
252
|
// calling. A signed server finish capability is still required to stop
|
|
243
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.
|
|
244
257
|
guard let transactionId = call.getString("transactionId"), UInt64(transactionId) != nil,
|
|
245
258
|
let ledgerEntryId = call.getString("ledgerEntryId"),
|
|
246
259
|
!ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
247
|
-
let
|
|
248
|
-
call.
|
|
260
|
+
let binding = StoreKitTransactionPolicy.finishBinding(
|
|
261
|
+
appAccountToken: call.options["appAccountToken"],
|
|
262
|
+
withoutAppAccountToken: call.options["withoutAppAccountToken"]) else {
|
|
249
263
|
call.reject(
|
|
250
264
|
"A transaction ID, confirmed ledger entry, and app account token are required.",
|
|
251
265
|
"STOREKIT_FINISH_REQUIRES_GRANT"
|
|
@@ -253,28 +267,52 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
253
267
|
return
|
|
254
268
|
}
|
|
255
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
|
|
256
279
|
Task { [module] in
|
|
257
280
|
do {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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`.
|
|
268
298
|
candidate = StoreKitTransactionPolicy.finishable(
|
|
269
299
|
transactionId: transactionId,
|
|
270
|
-
|
|
271
|
-
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
|
+
}
|
|
272
308
|
}
|
|
273
309
|
guard let match = candidate else {
|
|
274
310
|
call.resolve(["finished": false])
|
|
275
311
|
return
|
|
276
312
|
}
|
|
277
|
-
// 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.
|
|
278
316
|
try await module.finishTransaction(
|
|
279
317
|
purchase: .purchaseIos(StoreKitTransactionPolicy.finishInput(match)),
|
|
280
318
|
isConsumable: match.type == .consumable)
|
|
@@ -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.1",
|
|
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.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.19.0",
|
|
42
42
|
"@mpgd/bridge": "^0.18.0",
|
|
43
|
-
"@mpgd/platform": "^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.18.
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/
|
|
49
|
+
"@mpgd/adapter-capacitor": "0.19.1",
|
|
50
|
+
"@mpgd/bridge": "0.18.1",
|
|
51
|
+
"@mpgd/game-services": "0.33.1",
|
|
52
|
+
"@mpgd/platform": "0.35.0"
|
|
53
53
|
},
|
|
54
54
|
"main": "./dist/index.js",
|
|
55
55
|
"types": "./dist/index.d.ts",
|