@mpgd/capacitor-storekit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/commerce-provider.d.ts +10 -0
- package/dist/commerce-provider.js +3 -0
- package/dist/definitions.d.ts +37 -1
- package/dist/purchase-presentation.d.ts +27 -0
- package/dist/purchase-presentation.js +9 -0
- package/dist/store-sdk.d.ts +25 -2
- package/dist/store-sdk.js +48 -1
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +74 -13
- package/ios/Sources/MpgdStoreKitBridge/PurchaseSheet.swift +62 -0
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -162,6 +162,45 @@ and lists each skipped vector with its reason (host or server finish,
|
|
|
162
162
|
quantity, server-push, an asynchronous store error event StoreKit never
|
|
163
163
|
emits, and a pending transaction state StoreKit never reports).
|
|
164
164
|
|
|
165
|
+
### Purchase sheet facts
|
|
166
|
+
|
|
167
|
+
The provider offers `CommerceProvider.presentation` (see "Store sheet facts"
|
|
168
|
+
in the `@mpgd/adapter-capacitor` README), so the gateway keeps the game
|
|
169
|
+
paused, and the game runtime keeps ads out, for the life of the purchase
|
|
170
|
+
sheet rather than until a result event. The native plugin reports `open`
|
|
171
|
+
right before OpenIAP calls `Product.purchase()` and `closed` when that call
|
|
172
|
+
returns or throws, before it answers; a call it refuses earlier (invalid
|
|
173
|
+
input, provider not ready, product unavailable) is `not-started`. An Ask to
|
|
174
|
+
Buy (`pending`) is `closed` when the call returns; its purchase still arrives
|
|
175
|
+
later through `transactionUpdated`. Facts reach JavaScript as
|
|
176
|
+
`purchasePresentation` events (retained, keyed by a random id per call) and
|
|
177
|
+
in the `presentation` field of the `purchase` answer or rejection data. The
|
|
178
|
+
adapter adds a 15-minute safety timeout.
|
|
179
|
+
|
|
180
|
+
Apple documents that `purchase(options:)` "displays the confirmation sheet"
|
|
181
|
+
and returns a `PurchaseResult`
|
|
182
|
+
([purchase(options:)](https://developer.apple.com/documentation/storekit/product/purchase(options:)),
|
|
183
|
+
[purchase(confirmIn:options:)](https://developer.apple.com/documentation/storekit/product/purchase(confirmin:options:)-6dj6y)),
|
|
184
|
+
but not that it returns only once the sheet is dismissed. `closed` therefore
|
|
185
|
+
means "the purchase call ended"; that the sheet is gone by then is unverified
|
|
186
|
+
until device testing.
|
|
187
|
+
|
|
188
|
+
`getPresentationSupport` declares the facts. A web bundle running on a native
|
|
189
|
+
build from before them (a live update) gets `UNIMPLEMENTED` there: while an
|
|
190
|
+
adapter follows the facts, the store then reads `configuration-required` (the
|
|
191
|
+
shop sells nothing and the reconciler spends no idempotency key), and a
|
|
192
|
+
purchase that still gets through is refused with `CONFIGURATION_REQUIRED` /
|
|
193
|
+
`STOREKIT_PRESENTATION_UNSUPPORTED` instead of opening a sheet the game would
|
|
194
|
+
wait on; recovery is unaffected. `purchasePresentation: false` on the
|
|
195
|
+
provider keeps the result-based pause of earlier releases.
|
|
196
|
+
|
|
197
|
+
Device acceptance (not run; no device was available): the sheet cancelled, a
|
|
198
|
+
completed purchase, an Ask to Buy request, the app backgrounded with the
|
|
199
|
+
sheet up and resumed, and an iPad (sheet as a form sheet). Each must pause
|
|
200
|
+
the game from `open` to `closed`, and `closed` must not arrive while the sheet
|
|
201
|
+
is still visible. The steps and expected signals are in the kit's
|
|
202
|
+
[purchase device acceptance checklist](https://github.com/imjlk/mpgd-kit/blob/main/docs/PURCHASE_DEVICE_ACCEPTANCE.md).
|
|
203
|
+
|
|
165
204
|
### Generated games
|
|
166
205
|
|
|
167
206
|
Games generated by `mpgd game create` (and the kit's private starter) ship
|
|
@@ -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,12 +26,38 @@ export interface StoreKitTransaction {
|
|
|
26
26
|
*/
|
|
27
27
|
readonly unfinished?: boolean;
|
|
28
28
|
}
|
|
29
|
-
export type StoreKitPurchaseOutcome = {
|
|
29
|
+
export type StoreKitPurchaseOutcome = ({
|
|
30
30
|
readonly status: 'purchased';
|
|
31
31
|
readonly transaction: StoreKitTransaction;
|
|
32
32
|
} | {
|
|
33
33
|
readonly status: 'pending' | 'cancelled';
|
|
34
|
+
}) & {
|
|
35
|
+
/**
|
|
36
|
+
* The purchase sheet when a `purchase` call with a `presentationId`
|
|
37
|
+
* answered (also in a rejection's `data`): `closed` once
|
|
38
|
+
* `Product.purchase()` returned or threw, `not-started` when the plugin
|
|
39
|
+
* refused before calling it.
|
|
40
|
+
*/
|
|
41
|
+
readonly presentation?: StoreKitPurchaseAnswerPresentation;
|
|
34
42
|
};
|
|
43
|
+
export type StoreKitPurchasePresentationState = 'open' | 'closed' | 'not-started';
|
|
44
|
+
export type StoreKitPurchaseAnswerPresentation = StoreKitPurchasePresentationState | 'requested';
|
|
45
|
+
/**
|
|
46
|
+
* A sheet fact for the `purchase` call that sent `presentationId`: `open`
|
|
47
|
+
* right before `Product.purchase()`, `closed` when it returns or throws,
|
|
48
|
+
* `not-started` when the plugin refused before it. Apple documents that the
|
|
49
|
+
* call presents the confirmation sheet, not that it returns only once the
|
|
50
|
+
* sheet is gone; see the README.
|
|
51
|
+
*/
|
|
52
|
+
export interface StoreKitPurchasePresentationEvent {
|
|
53
|
+
readonly presentationId: string;
|
|
54
|
+
readonly state: StoreKitPurchasePresentationState;
|
|
55
|
+
readonly reason?: 'interrupted';
|
|
56
|
+
}
|
|
57
|
+
/** What `getPresentationSupport` declares; `purchasePresentation` is the facts protocol version. */
|
|
58
|
+
export interface StoreKitPresentationSupport {
|
|
59
|
+
readonly purchasePresentation: number;
|
|
60
|
+
}
|
|
35
61
|
export interface CapacitorStoreKitPlugin {
|
|
36
62
|
getProducts(input: {
|
|
37
63
|
readonly productIds: readonly string[];
|
|
@@ -41,7 +67,15 @@ export interface CapacitorStoreKitPlugin {
|
|
|
41
67
|
purchase(input: {
|
|
42
68
|
readonly productId: string;
|
|
43
69
|
readonly appAccountToken: string;
|
|
70
|
+
/** Asks for this call's sheet facts (`purchasePresentation`); 1-64 of `[A-Za-z0-9-]`. */
|
|
71
|
+
readonly presentationId?: string;
|
|
44
72
|
}): Promise<StoreKitPurchaseOutcome>;
|
|
73
|
+
/**
|
|
74
|
+
* Declares the sheet facts the native code reports. A native build from
|
|
75
|
+
* before them answers `UNIMPLEMENTED`; optional so a plugin double without
|
|
76
|
+
* it still type-checks, and then the SDK refuses purchases that need facts.
|
|
77
|
+
*/
|
|
78
|
+
getPresentationSupport?(): Promise<StoreKitPresentationSupport>;
|
|
45
79
|
/** Includes unfinished consumables and current non-consumable entitlements. */
|
|
46
80
|
getTransactions(): Promise<{
|
|
47
81
|
readonly transactions: readonly StoreKitTransaction[];
|
|
@@ -69,4 +103,6 @@ export interface CapacitorStoreKitPlugin {
|
|
|
69
103
|
readonly finished: boolean;
|
|
70
104
|
}>;
|
|
71
105
|
addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
|
|
106
|
+
/** Sheet facts of `purchase` calls that sent a `presentationId`. */
|
|
107
|
+
addListener(eventName: 'purchasePresentation', listener: (event: StoreKitPurchasePresentationEvent) => void): Promise<PluginListenerHandle>;
|
|
72
108
|
}
|
|
@@ -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'>;
|
|
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
|
package/dist/store-sdk.js
CHANGED
|
@@ -2,6 +2,15 @@ import { Capacitor } from '@capacitor/core';
|
|
|
2
2
|
import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
|
|
3
3
|
import { appStoreStoreId, storeKitStoreSdkCapabilities } from './commerce-profile.js';
|
|
4
4
|
import { CapacitorStoreKit } from './plugin.js';
|
|
5
|
+
import { resolveStoreKitNativePurchasePresentation, } from './purchase-presentation.js';
|
|
6
|
+
/**
|
|
7
|
+
* The code of a purchase refused because the native plugin in the app binary
|
|
8
|
+
* reports no sheet facts (a web bundle newer than its native build). The
|
|
9
|
+
* provider offers `presentation`, so a purchase it could not follow is not
|
|
10
|
+
* sold; rebuilding the app with this package's native code, or
|
|
11
|
+
* `purchasePresentation: false`, sells again. Recovery is unaffected.
|
|
12
|
+
*/
|
|
13
|
+
export const storeKitPresentationUnsupportedCode = 'STOREKIT_PRESENTATION_UNSUPPORTED';
|
|
5
14
|
/**
|
|
6
15
|
* Capacitor StoreKit `StoreSdk`: the commerce v2 surface over the native
|
|
7
16
|
* StoreKit 2 collector (RFC B3). The App Store profile is consumable plus
|
|
@@ -23,6 +32,10 @@ import { CapacitorStoreKit } from './plugin.js';
|
|
|
23
32
|
*/
|
|
24
33
|
export function createCapacitorStoreKitStoreSdk(input) {
|
|
25
34
|
const sdk = input.sdk ?? CapacitorStoreKit;
|
|
35
|
+
// Sheet facts of `purchase` calls that carry a presentation id.
|
|
36
|
+
const presentation = input.purchasePresentation === false
|
|
37
|
+
? undefined
|
|
38
|
+
: resolveStoreKitNativePurchasePresentation(sdk);
|
|
26
39
|
const isIos = input.isIos ?? (() => Capacitor.getPlatform() === 'ios');
|
|
27
40
|
const onDiagnostic = input.onDiagnostic ?? (() => undefined);
|
|
28
41
|
if (typeof input.getAppAccountToken !== 'function') {
|
|
@@ -332,6 +345,7 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
332
345
|
};
|
|
333
346
|
return {
|
|
334
347
|
store: appStoreStoreId,
|
|
348
|
+
...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
|
|
335
349
|
capabilities: storeKitStoreSdkCapabilities,
|
|
336
350
|
async fetchProducts(request) {
|
|
337
351
|
requireIos();
|
|
@@ -354,7 +368,33 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
354
368
|
requireIos();
|
|
355
369
|
const appAccountToken = readAppAccountToken(request.accountBinding);
|
|
356
370
|
const expected = await assertSellable(request.sku);
|
|
357
|
-
|
|
371
|
+
// Only an adapter that reads sheet facts sends an id (one from before
|
|
372
|
+
// them never does, and the purchase runs as in earlier releases).
|
|
373
|
+
const presentationId = presentation === undefined ? undefined : request.presentationId;
|
|
374
|
+
if (presentation !== undefined && presentationId !== undefined) {
|
|
375
|
+
if (!(await presentation.supported())) {
|
|
376
|
+
throw storeKitError(storeKitPresentationUnsupportedCode, 'The app was built with a StoreKit plugin that reports no purchase sheet facts; '
|
|
377
|
+
+ "rebuild it with this package's native code.", false);
|
|
378
|
+
}
|
|
379
|
+
presentation.begin(presentationId);
|
|
380
|
+
}
|
|
381
|
+
let outcome;
|
|
382
|
+
try {
|
|
383
|
+
outcome = await sdk.purchase({
|
|
384
|
+
productId: request.sku,
|
|
385
|
+
appAccountToken,
|
|
386
|
+
...(presentationId === undefined ? {} : { presentationId }),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (presentationId !== undefined) {
|
|
391
|
+
presentation?.answered(presentationId, error);
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
if (presentationId !== undefined) {
|
|
396
|
+
presentation?.answered(presentationId, outcome);
|
|
397
|
+
}
|
|
358
398
|
if (!isRecord(outcome)) {
|
|
359
399
|
throw evidenceInvalid('StoreKit returned an invalid purchase result.');
|
|
360
400
|
}
|
|
@@ -494,6 +534,12 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
494
534
|
if (productIds.length === 0) {
|
|
495
535
|
return 'configuration-required';
|
|
496
536
|
}
|
|
537
|
+
// An adapter that reads sheet facts over a native build without them
|
|
538
|
+
// would refuse every purchase: report the store as needing a rebuild,
|
|
539
|
+
// so the shop sells nothing and no idempotency key is spent.
|
|
540
|
+
if (presentation !== undefined && presentation.followed() && !(await presentation.supported())) {
|
|
541
|
+
return 'configuration-required';
|
|
542
|
+
}
|
|
497
543
|
try {
|
|
498
544
|
// Probe batch by batch until one configured product is sold; only a
|
|
499
545
|
// catalog with no sellable product at all is a configuration problem.
|
|
@@ -543,6 +589,7 @@ export const storeKitStoreSdkErrorCodeMap = Object.freeze({
|
|
|
543
589
|
STOREKIT_FINISH_REQUIRES_GRANT: 'CONFIGURATION_REQUIRED',
|
|
544
590
|
STOREKIT_EVIDENCE_INVALID: 'INVALID_RESPONSE',
|
|
545
591
|
STOREKIT_TRANSACTION_UNVERIFIED: 'INVALID_RESPONSE',
|
|
592
|
+
STOREKIT_PRESENTATION_UNSUPPORTED: 'CONFIGURATION_REQUIRED',
|
|
546
593
|
});
|
|
547
594
|
/** The default table plus every StoreKit code; what the provider uses. */
|
|
548
595
|
export const capacitorStoreKitErrorCodeMap = Object.freeze({
|
|
@@ -18,7 +18,8 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
18
18
|
CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
|
|
19
19
|
CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
|
|
20
20
|
CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
|
|
21
|
-
CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
|
|
21
|
+
CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "getPresentationSupport", returnType: CAPPluginReturnPromise)
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
private let connection = OpenIapAppleConnection()
|
|
@@ -79,25 +80,57 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/// Declares the sheet facts this native code reports
|
|
84
|
+
/// (`purchasePresentation` events and the `presentation` field of
|
|
85
|
+
/// `purchase` answers). A build without this method answers
|
|
86
|
+
/// `UNIMPLEMENTED`, and the TypeScript SDK then sells nothing that would
|
|
87
|
+
/// wait for facts.
|
|
88
|
+
@objc func getPresentationSupport(_ call: CAPPluginCall) {
|
|
89
|
+
call.resolve(["purchasePresentation": MpgdPurchaseSheet.protocolVersion])
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
@objc func purchase(_ call: CAPPluginCall) {
|
|
93
|
+
// Correlates this call's sheet facts; sent only by a TypeScript SDK
|
|
94
|
+
// that reads them. Without it the call reports no facts, as before.
|
|
95
|
+
let presentationId = call.getString("presentationId")
|
|
96
|
+
if presentationId != nil && !MpgdPurchaseSheet.isValidPresentationId(presentationId) {
|
|
97
|
+
call.reject("The presentation id is invalid.", "STOREKIT_INVALID_PURCHASE", nil,
|
|
98
|
+
["presentation": MpgdPurchaseSheet.Fact.notStarted.rawValue])
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
let sheet = presentationId.map { id in
|
|
102
|
+
MpgdPurchaseSheet(presentationId: id) { [weak self] id, fact in
|
|
103
|
+
self?.notifyPresentation(id, fact)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
83
106
|
guard let productId = call.getString("productId"),
|
|
84
107
|
StoreKitTransactionPolicy.isValidProductId(productId),
|
|
85
108
|
let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
|
|
86
109
|
call.getString("appAccountToken"), allowNil: true) else {
|
|
87
|
-
|
|
110
|
+
sheet?.end()
|
|
111
|
+
call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE",
|
|
112
|
+
nil, Self.presentationData(sheet))
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if let message = connection.providerError {
|
|
116
|
+
sheet?.end()
|
|
117
|
+
call.reject(message, "STOREKIT_CONFIGURATION_ERROR", nil, Self.presentationData(sheet))
|
|
88
118
|
return
|
|
89
119
|
}
|
|
90
|
-
guard providerReady(call) else { return }
|
|
91
120
|
Task { @MainActor [module] in
|
|
92
121
|
do {
|
|
93
122
|
let result = try await module.fetchProducts(ProductRequest(skus: [productId], type: .inApp))
|
|
94
123
|
guard let product = Self.iosProducts(result).first(where: { $0.id == productId }),
|
|
95
124
|
MpgdOneTimeType(productType: product.typeIOS) != nil else {
|
|
96
|
-
|
|
125
|
+
sheet?.end()
|
|
126
|
+
call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE",
|
|
127
|
+
nil, Self.presentationData(sheet))
|
|
97
128
|
return
|
|
98
129
|
}
|
|
99
130
|
} catch {
|
|
100
|
-
|
|
131
|
+
sheet?.end()
|
|
132
|
+
call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error,
|
|
133
|
+
Self.presentationData(sheet))
|
|
101
134
|
return
|
|
102
135
|
}
|
|
103
136
|
let request = RequestPurchaseProps(
|
|
@@ -109,40 +142,68 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
109
142
|
))),
|
|
110
143
|
type: .inApp
|
|
111
144
|
)
|
|
145
|
+
// `open` right before OpenIAP calls `Product.purchase()`, which
|
|
146
|
+
// presents the sheet; `closed` once that call returned or threw,
|
|
147
|
+
// before the answer, which carries the same fact.
|
|
148
|
+
sheet?.open()
|
|
112
149
|
do {
|
|
113
150
|
let outcome = try await module.requestPurchase(request)
|
|
151
|
+
sheet?.end()
|
|
114
152
|
guard case .purchase(let purchased?) = outcome,
|
|
115
153
|
case .purchaseIos(let ios) = purchased,
|
|
116
154
|
let transaction = StoreKitTransactionPolicy.transaction(from: ios),
|
|
117
155
|
StoreKitTransactionPolicy.matchesPurchaseRequest(
|
|
118
156
|
transaction, productId: productId, appAccountToken: accountToken) else {
|
|
119
157
|
call.reject("StoreKit transaction failed verification or account binding.",
|
|
120
|
-
"STOREKIT_TRANSACTION_UNVERIFIED")
|
|
158
|
+
"STOREKIT_TRANSACTION_UNVERIFIED", nil, Self.presentationData(sheet))
|
|
121
159
|
return
|
|
122
160
|
}
|
|
123
|
-
call.resolve([
|
|
161
|
+
call.resolve(Self.withPresentation([
|
|
124
162
|
"status": "purchased",
|
|
125
163
|
"transaction": StoreKitTransactionPolicy.payload(transaction)
|
|
126
|
-
])
|
|
164
|
+
], sheet))
|
|
127
165
|
} catch let error as PurchaseError {
|
|
166
|
+
sheet?.end()
|
|
128
167
|
switch StoreKitTransactionPolicy.purchaseFailure(error.code) {
|
|
129
168
|
case .cancelled:
|
|
130
|
-
call.resolve(["status": "cancelled"])
|
|
169
|
+
call.resolve(Self.withPresentation(["status": "cancelled"], sheet))
|
|
131
170
|
case .pending:
|
|
132
171
|
// Ask to Buy: the approved transaction arrives through
|
|
133
|
-
// `transactionUpdated` or `getTransactions` later.
|
|
134
|
-
|
|
172
|
+
// `transactionUpdated` or `getTransactions` later. The
|
|
173
|
+
// sheet itself is done.
|
|
174
|
+
call.resolve(Self.withPresentation(["status": "pending"], sheet))
|
|
135
175
|
case .rejected(let code, let message):
|
|
136
|
-
call.reject(message, code, error)
|
|
176
|
+
call.reject(message, code, error, Self.presentationData(sheet))
|
|
137
177
|
}
|
|
138
178
|
} catch {
|
|
179
|
+
sheet?.end()
|
|
139
180
|
// The purchase sheet may have completed before an error reached us.
|
|
140
181
|
call.reject("StoreKit purchase result is uncertain; requery transactions.",
|
|
141
|
-
"STOREKIT_PURCHASE_UNCERTAIN", error)
|
|
182
|
+
"STOREKIT_PURCHASE_UNCERTAIN", error, Self.presentationData(sheet))
|
|
142
183
|
}
|
|
143
184
|
}
|
|
144
185
|
}
|
|
145
186
|
|
|
187
|
+
/// Retained until a listener consumes it; the random id keeps a fact of
|
|
188
|
+
/// an earlier web session from matching a new call.
|
|
189
|
+
private func notifyPresentation(_ presentationId: String, _ fact: MpgdPurchaseSheet.Fact) {
|
|
190
|
+
notifyListeners("purchasePresentation",
|
|
191
|
+
data: ["presentationId": presentationId, "state": fact.rawValue],
|
|
192
|
+
retainUntilConsumed: true)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static func presentationData(_ sheet: MpgdPurchaseSheet?) -> [String: Any]? {
|
|
196
|
+
guard let sheet else { return nil }
|
|
197
|
+
return ["presentation": sheet.answer]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private static func withPresentation(_ answer: [String: Any], _ sheet: MpgdPurchaseSheet?) -> [String: Any] {
|
|
201
|
+
guard let sheet else { return answer }
|
|
202
|
+
var answered = answer
|
|
203
|
+
answered["presentation"] = sheet.answer
|
|
204
|
+
return answered
|
|
205
|
+
}
|
|
206
|
+
|
|
146
207
|
@objc func getTransactions(_ call: CAPPluginCall) {
|
|
147
208
|
guard providerReady(call) else { return }
|
|
148
209
|
Task {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The purchase sheet facts of one `purchase` call that sent a presentation
|
|
4
|
+
/// id. The plugin reports `open` right before OpenIAP calls
|
|
5
|
+
/// `Product.purchase()` and `closed` when that returns or throws; a call it
|
|
6
|
+
/// refuses before then is `not-started`. Each sheet reports at most one
|
|
7
|
+
/// `open` and exactly one terminal fact, whatever order the plugin calls
|
|
8
|
+
/// these in. Apple documents that `Product.purchase()` presents the
|
|
9
|
+
/// confirmation sheet, not that it returns only once the sheet is dismissed,
|
|
10
|
+
/// so `closed` is "the purchase call ended" until device testing proves more.
|
|
11
|
+
public final class MpgdPurchaseSheet {
|
|
12
|
+
public enum Fact: String, Sendable {
|
|
13
|
+
case open
|
|
14
|
+
case closed
|
|
15
|
+
case notStarted = "not-started"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// The facts protocol version `getPresentationSupport` declares.
|
|
19
|
+
public static let protocolVersion = 1
|
|
20
|
+
|
|
21
|
+
public let presentationId: String
|
|
22
|
+
private let emit: (String, Fact) -> Void
|
|
23
|
+
private var opened = false
|
|
24
|
+
public private(set) var ended: Fact?
|
|
25
|
+
|
|
26
|
+
public init(presentationId: String, emit: @escaping (String, Fact) -> Void) {
|
|
27
|
+
self.presentationId = presentationId
|
|
28
|
+
self.emit = emit
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The `presentation` value of the call's answer: the terminal fact, or
|
|
32
|
+
/// `open` while the sheet is up, or `requested` before anything happened.
|
|
33
|
+
public var answer: String {
|
|
34
|
+
if let ended { return ended.rawValue }
|
|
35
|
+
return opened ? Fact.open.rawValue : "requested"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public func open() {
|
|
39
|
+
guard ended == nil, !opened else { return }
|
|
40
|
+
opened = true
|
|
41
|
+
emit(presentationId, .open)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The purchase call ended: `closed` after `open`, otherwise `not-started`.
|
|
45
|
+
public func end() {
|
|
46
|
+
guard ended == nil else { return }
|
|
47
|
+
let fact: Fact = opened ? .closed : .notStarted
|
|
48
|
+
ended = fact
|
|
49
|
+
emit(presentationId, fact)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// 1-64 characters of `[A-Za-z0-9-]`, as the TypeScript SDK sends (a UUID).
|
|
53
|
+
public static func isValidPresentationId(_ value: String?) -> Bool {
|
|
54
|
+
guard let value, !value.isEmpty, value.count <= 64 else { return false }
|
|
55
|
+
return value.unicodeScalars.allSatisfy { scalar in
|
|
56
|
+
(scalar.value >= 0x30 && scalar.value <= 0x39)
|
|
57
|
+
|| (scalar.value >= 0x41 && scalar.value <= 0x5A)
|
|
58
|
+
|| (scalar.value >= 0x61 && scalar.value <= 0x7A)
|
|
59
|
+
|| scalar == "-"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/capacitor-storekit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,18 +38,18 @@
|
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
|
-
"@mpgd/adapter-capacitor": "^0.
|
|
42
|
-
"@mpgd/bridge": "^0.
|
|
43
|
-
"@mpgd/platform": "^0.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.18.0",
|
|
42
|
+
"@mpgd/bridge": "^0.18.0",
|
|
43
|
+
"@mpgd/platform": "^0.34.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@capacitor/core": "8.5.2",
|
|
47
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.18.0",
|
|
50
|
+
"@mpgd/bridge": "0.18.0",
|
|
51
|
+
"@mpgd/platform": "0.34.0",
|
|
52
|
+
"@mpgd/game-services": "0.32.0"
|
|
53
53
|
},
|
|
54
54
|
"main": "./dist/index.js",
|
|
55
55
|
"types": "./dist/index.d.ts",
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"scripts": {
|
|
60
60
|
"check": "ttsc --noEmit",
|
|
61
61
|
"lint": "ttsc --noEmit",
|
|
62
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/openiap-engine-swap.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-restore-reinstall.test.ts"
|
|
62
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/openiap-engine-swap.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-restore-reinstall.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/purchase-presentation.test.ts"
|
|
63
63
|
}
|
|
64
64
|
}
|