@mpgd/adapter-capacitor 0.8.0 → 0.9.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 CHANGED
@@ -64,6 +64,113 @@ The source tests and installed-tarball consumer validate composition, types,
64
64
  error paths, and fallback behavior. They do not establish that any optional
65
65
  SDK works on a physical device or that a store/ad setup is release-ready.
66
66
 
67
+ ## Commerce v2 providers
68
+
69
+ Commerce v2 ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md), PR B1) adds
70
+ a second commerce surface next to the v1 `commerce.*` bridge methods. A store
71
+ plugin exposes an in-process `StoreSdk`, the adapter turns it into a
72
+ `CommerceProvider` from `@mpgd/platform/commerce`, and the provider registry
73
+ serves both the six `commerce.v2.*` bridge methods and the four v1 methods
74
+ from it.
75
+
76
+ `StoreSdk` is shaped like the OpenIAP Client Protocol so a future Capacitor
77
+ IAP plugin over the OpenIAP cores is a drop-in: `fetchProducts({ skus, type })`,
78
+ `requestPurchase({ sku, offerId?, quantity?, accountBinding? })` answering
79
+ `launched`, `purchased`, `cancelled`, or `failed`, `getAvailablePurchases`
80
+ listing unfinished store purchases, optional `finishTransaction` and
81
+ `restorePurchases`, `addListener('purchaseUpdated' | 'purchaseError')`, an
82
+ optional `getAvailability`, and a static `capabilities` declaration (purchase
83
+ kinds, quantity, pending, restore, finish owner). The SDK speaks store SKUs
84
+ and its own error codes only. B2 adapts `@mpgd/capacitor-play-billing` and B3
85
+ adapts `@mpgd/capacitor-storekit` to this interface; B1 ships no plugin
86
+ change.
87
+
88
+ ```ts
89
+ import {
90
+ createCapacitorPlatformGateway,
91
+ createCommerceServiceProvider,
92
+ createStoreCommerceProvider,
93
+ type StoreSdk,
94
+ } from '@mpgd/adapter-capacitor';
95
+
96
+ export function createStoreGateway(sdk: StoreSdk) {
97
+ const commerce = createStoreCommerceProvider({
98
+ sdk,
99
+ products: [
100
+ { logicalId: 'gems_small', storeSku: 'gems.small', kind: 'consumable' },
101
+ { logicalId: 'remove_ads', storeSku: 'remove.ads', kind: 'durable' },
102
+ ],
103
+ evidenceSchema: 'google-play.product-purchase.v2',
104
+ evidencePayload: (purchase) => ({ purchaseToken: purchase.token }),
105
+ accountBinding: async () => ({ kind: 'obfuscated-account-id', value: 'player-1' }),
106
+ authority: { evidence: 'client-token', accountBinding: 'required' },
107
+ mapError: { PLAY_BILLING_OFFER_REQUIRED: 'CONFIGURATION_REQUIRED' },
108
+ });
109
+ return createCapacitorPlatformGateway({
110
+ target: 'android',
111
+ appVersion: '1.0.0',
112
+ buildId: 'game-build',
113
+ providers: [createCommerceServiceProvider({ id: 'play-billing', commerce })],
114
+ });
115
+ }
116
+ ```
117
+
118
+ `createStoreCommerceProvider` maps logical ids to store SKUs both ways (an
119
+ unknown SKU is still reported, with `logicalId` undefined; an unknown logical
120
+ id fails with `ITEM_UNAVAILABLE`), rejects unsupported kinds and quantities
121
+ with `assertRequestPurchaseSupported` before any store UI opens, refuses to
122
+ open the store while `getAvailability` is not `available`, builds every
123
+ `StorePurchase` with the store `token` and the `{ schema: evidenceSchema,
124
+ payload }` envelope, buffers SDK events until the first `subscribe` with
125
+ `createCommerceEventBuffer`, composes `CommerceCapabilities` from the SDK
126
+ declaration plus `capabilities` overrides and a fresh `availability` read
127
+ (three-second timeout, degraded to `temporarily-unavailable`), validates every
128
+ SDK answer with the platform typia asserts (`INVALID_RESPONSE` otherwise), and
129
+ normalizes every SDK failure through `mapError` into a `CommerceError` whose
130
+ `storeCode` keeps the store's own code. It never grants and never sets
131
+ `settlement`; the backend ledger decides.
132
+
133
+ Two failure classes are not terminal. When `requestPurchase` throws or
134
+ returns a code that normalizes to `OUTCOME_UNKNOWN` (for example
135
+ `PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`,
136
+ `STOREKIT_PURCHASE_UNCERTAIN`) or `ALREADY_OWNED`, an unfinished purchase may
137
+ exist, so the provider answers `{ outcome: 'launched' }` instead of `failed`
138
+ and does not forward a `purchaseError` event carrying those codes
139
+ (`recoverableErrorCodes`). The attempt stays open and the reconciler resolves
140
+ it from `getAvailablePurchases`; the store UI is never reopened blindly. A
141
+ `capabilities.lifecycle` override may hide `restore` or `finish`, but it
142
+ cannot advertise one the SDK lacks: `restore: true` without
143
+ `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
144
+ `sdk.finishTransaction`, is rejected when the provider is created.
145
+
146
+ `createCommerceServiceProvider` wraps the provider as a `CapacitorServiceProvider`
147
+ whose `commerce` field the registry recognizes: the ten commerce bridge
148
+ methods are implied (declaring a v1 `commerce.*` method alongside is an
149
+ error), `nativeIap` and `subscriptionIap` readiness derive from
150
+ `commerce.getCapabilities()`, and the in-process bridge delivers events as
151
+ `{ protocol: 'mpgd.commerce.v2', sequence, event }` with a per-session
152
+ sequence starting at 0. v1 callers keep working: `gateway.commerce` is served
153
+ by `toCommerceAdapter(provider)`, so a `launched` purchase is `pending`, a
154
+ synchronous purchase is `completed` with evidence and no entitlements, and
155
+ `restore` returns no entitlements. The gateway exposes the v2 surface as
156
+ `gateway.commerceV2` (served through the same validated bridge path, which
157
+ the conformance test exercises for every store vector) and adds a `commerce`
158
+ block to `getCapabilities()` from which `nativeIap` and `subscriptionIap` are
159
+ derived. The block comes from the registry's one bounded read per snapshot
160
+ (`getCapabilitySnapshot`, three-second timeout, shape validated): a stalled,
161
+ failing, or invalid provider yields a degraded block
162
+ (`createDegradedCommerceCapabilities`: `availability:
163
+ 'temporarily-unavailable'`, nothing sold, `nativeIap: false`) and never
164
+ rejects the capability read. A `launched` v2 purchase keeps
165
+ `beginExternalActivity()` open after the request returns, since the store UI
166
+ is still up: the gateway releases it when the provider reports that product
167
+ (`purchaseUpdated` or `purchaseError`), after
168
+ `launchedPurchaseActivityTimeoutMs` (two minutes), or on `lifecycle.dispose`;
169
+ the v1 `commerce.purchase` path has no event to wait for and releases when
170
+ its request returns. Without a v2 provider nothing changes: `commerceV2` is
171
+ absent and a `commerce.v2.*` request still answers `COMMERCE_V2_UNAVAILABLE`
172
+ as the transport left it.
173
+
67
174
  ## App Store server-side transaction signatures
68
175
 
69
176
  The server-side verifier moved to `@mpgd/game-services/app-store-server`
@@ -0,0 +1,59 @@
1
+ import { type BridgeCommerceV2Method, type BridgeEvent, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
+ import { type PlatformProviderFeature } from '@mpgd/platform';
3
+ import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
4
+ import type { CapacitorCommerceServiceProvider } from './providers.js';
5
+ /** Bridge error for a commerce v2 payload the typia assert rejected. */
6
+ export declare const commerceV2InvalidPayloadBridgeErrorCode: 'COMMERCE_V2_INVALID_PAYLOAD';
7
+ /** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
8
+ export declare const commerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE_V2_METHOD_UNSUPPORTED';
9
+ export interface CommerceProviderBridge {
10
+ request(input: BridgeRequest): Promise<BridgeResponse>;
11
+ /** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
12
+ subscribe(listener: (event: BridgeEvent) => void): () => void;
13
+ }
14
+ export interface CreateCommerceProviderBridgeInput {
15
+ readonly provider: CommerceProvider;
16
+ /** Serves the methods the provider does not own, for example a plugin's ad methods. */
17
+ readonly fallback?: {
18
+ request(input: BridgeRequest): Promise<BridgeResponse>;
19
+ };
20
+ }
21
+ /**
22
+ * In-process `NativeBridge` over a commerce v2 provider: the six
23
+ * `commerce.v2.*` methods validate their payload and result with the
24
+ * `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
25
+ * served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
26
+ * with a per-session sequence. A thrown failure crosses the bridge as
27
+ * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
28
+ * keeps its `storeCode` because it is data, not an error.
29
+ */
30
+ export declare function createCommerceProviderBridge(input: CreateCommerceProviderBridgeInput): CommerceProviderBridge;
31
+ export type CapacitorCommerceFeature = Extract<PlatformProviderFeature, 'nativeIap' | 'subscriptionIap'>;
32
+ export interface CreateCommerceServiceProviderInput {
33
+ readonly id: string;
34
+ readonly commerce: CommerceProvider;
35
+ /** Commerce features the provider serves. Defaults to `['nativeIap']`. */
36
+ readonly features?: readonly CapacitorCommerceFeature[];
37
+ }
38
+ /**
39
+ * Registry entry for a commerce v2 provider: the implied commerce bridge
40
+ * methods are served in process by `createCommerceProviderBridge`, and the
41
+ * registry derives `nativeIap` and `subscriptionIap` readiness from
42
+ * `commerce.getCapabilities()`.
43
+ */
44
+ export declare function createCommerceServiceProvider(input: CreateCommerceServiceProviderInput): CapacitorCommerceServiceProvider;
45
+ /** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
46
+ export declare function assertCommerceBridgeResult<TMethod extends BridgeCommerceV2Method>(method: TMethod, data: unknown): CommerceBridgeMethods[TMethod]['result'];
47
+ export interface CreateBridgeCommerceProviderInput {
48
+ readonly store: StoreId;
49
+ /** Sends one validated bridge request; the gateway's `request` fits. */
50
+ readonly request: <TData>(method: BridgeCommerceV2Method, payload: unknown) => Promise<TData>;
51
+ readonly subscribe: (listener: (event: BridgeEvent) => void) => () => void;
52
+ }
53
+ /**
54
+ * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
55
+ * the event envelope. Results are validated again on this side, so a transport
56
+ * that forgets to is still fail-closed; bridge errors become `CommerceError`s
57
+ * with the bridge code kept as `storeCode` when it is not already a v2 code.
58
+ */
59
+ export declare function createBridgeCommerceProvider(input: CreateBridgeCommerceProviderInput): CommerceProvider;
@@ -0,0 +1,265 @@
1
+ import { assertBridgeEvent, bridgeEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
+ import { PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, toCommerceAdapter, } from '@mpgd/platform/commerce';
4
+ import { normalizeStoreSdkError } from './store-sdk.js';
5
+ /** Bridge error for a commerce v2 payload the typia assert rejected. */
6
+ export const commerceV2InvalidPayloadBridgeErrorCode = 'COMMERCE_V2_INVALID_PAYLOAD';
7
+ /** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
8
+ export const commerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_METHOD_UNSUPPORTED';
9
+ const v1CommerceMethods = Object.freeze([
10
+ 'commerce.getProducts',
11
+ 'commerce.purchase',
12
+ 'commerce.restore',
13
+ 'commerce.getEntitlements',
14
+ ]);
15
+ /**
16
+ * In-process `NativeBridge` over a commerce v2 provider: the six
17
+ * `commerce.v2.*` methods validate their payload and result with the
18
+ * `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
19
+ * served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
20
+ * with a per-session sequence. A thrown failure crosses the bridge as
21
+ * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
22
+ * keeps its `storeCode` because it is data, not an error.
23
+ */
24
+ export function createCommerceProviderBridge(input) {
25
+ const { provider } = input;
26
+ const adapter = toCommerceAdapter(provider);
27
+ const listeners = new Set();
28
+ let sequence = 0;
29
+ let detach;
30
+ const deliver = (event) => {
31
+ const envelope = { protocol: commerceProtocol, sequence, event };
32
+ sequence += 1;
33
+ for (const listener of [...listeners]) {
34
+ listener(envelope);
35
+ }
36
+ };
37
+ const serve = async (request) => {
38
+ const { payload } = request;
39
+ switch (request.method) {
40
+ case 'commerce.v2.getCapabilities': {
41
+ assertCommerceBridgeGetCapabilitiesPayload(payload);
42
+ return assertResult('commerce.v2.getCapabilities', {
43
+ protocolVersion: commerceProtocolVersion,
44
+ store: provider.store,
45
+ capabilities: await provider.getCapabilities(),
46
+ });
47
+ }
48
+ case 'commerce.v2.fetchProducts': {
49
+ const products = await provider.fetchProducts(assertCommerceBridgeFetchProductsPayload(payload));
50
+ return assertResult('commerce.v2.fetchProducts', { products });
51
+ }
52
+ case 'commerce.v2.requestPurchase':
53
+ return assertResult('commerce.v2.requestPurchase', await provider.requestPurchase(assertCommerceBridgeRequestPurchasePayload(payload)));
54
+ case 'commerce.v2.getAvailablePurchases': {
55
+ const purchases = await provider.getAvailablePurchases(
56
+ // The payload is asserted as sent; a caller meaning the default sends `{}`.
57
+ assertCommerceBridgeGetAvailablePurchasesPayload(payload));
58
+ return assertResult('commerce.v2.getAvailablePurchases', { purchases });
59
+ }
60
+ case 'commerce.v2.finishTransaction': {
61
+ const finish = assertCommerceBridgeFinishTransactionPayload(payload);
62
+ if (provider.finishTransaction === undefined) {
63
+ throw new PlatformOperationError({
64
+ code: commerceV2MethodUnsupportedBridgeErrorCode,
65
+ message: `Store ${provider.store} does not finish transactions on the client.`,
66
+ });
67
+ }
68
+ return assertResult('commerce.v2.finishTransaction', await provider.finishTransaction(finish));
69
+ }
70
+ case 'commerce.v2.restorePurchases': {
71
+ assertCommerceBridgeRestorePurchasesPayload(payload);
72
+ if (provider.restorePurchases === undefined) {
73
+ return assertResult('commerce.v2.restorePurchases', { restored: false });
74
+ }
75
+ await provider.restorePurchases();
76
+ return assertResult('commerce.v2.restorePurchases', { restored: true });
77
+ }
78
+ case 'commerce.getProducts':
79
+ return adapter.getProducts();
80
+ case 'commerce.purchase':
81
+ return adapter.purchase(payload);
82
+ case 'commerce.restore':
83
+ if (adapter.restore === undefined) {
84
+ return { restoredEntitlements: [] };
85
+ }
86
+ return adapter.restore();
87
+ case 'commerce.getEntitlements':
88
+ return adapter.getEntitlements();
89
+ default:
90
+ throw new PlatformOperationError({
91
+ code: 'UNSUPPORTED_METHOD',
92
+ message: `${request.method} is not a commerce method.`,
93
+ });
94
+ }
95
+ };
96
+ return {
97
+ async request(request) {
98
+ const isCommerce = isBridgeCommerceV2Method(request.method)
99
+ || v1CommerceMethods.includes(request.method);
100
+ if (!isCommerce && input.fallback !== undefined) {
101
+ return input.fallback.request(request);
102
+ }
103
+ try {
104
+ return { id: request.id, ok: true, data: await serve(request) };
105
+ }
106
+ catch (error) {
107
+ return toBridgeError(request, error);
108
+ }
109
+ },
110
+ subscribe(listener) {
111
+ listeners.add(listener);
112
+ if (listeners.size === 1) {
113
+ detach = provider.subscribe(deliver);
114
+ }
115
+ return () => {
116
+ if (!listeners.delete(listener) || listeners.size > 0) {
117
+ return;
118
+ }
119
+ detach?.();
120
+ detach = undefined;
121
+ };
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * Registry entry for a commerce v2 provider: the implied commerce bridge
127
+ * methods are served in process by `createCommerceProviderBridge`, and the
128
+ * registry derives `nativeIap` and `subscriptionIap` readiness from
129
+ * `commerce.getCapabilities()`.
130
+ */
131
+ export function createCommerceServiceProvider(input) {
132
+ return {
133
+ id: input.id,
134
+ features: input.features ?? ['nativeIap'],
135
+ methods: [],
136
+ commerce: input.commerce,
137
+ bridge: createCommerceProviderBridge({ provider: input.commerce }),
138
+ async getAvailability() {
139
+ return {};
140
+ },
141
+ };
142
+ }
143
+ // Each entry is the typia assert of that method's result type; the lookup is
144
+ // typed by `assertCommerceBridgeResult`.
145
+ const resultAsserts = {
146
+ 'commerce.v2.getCapabilities': assertCommerceBridgeGetCapabilitiesResult,
147
+ 'commerce.v2.fetchProducts': assertCommerceBridgeFetchProductsResult,
148
+ 'commerce.v2.requestPurchase': assertCommerceBridgeRequestPurchaseResult,
149
+ 'commerce.v2.getAvailablePurchases': assertCommerceBridgeGetAvailablePurchasesResult,
150
+ 'commerce.v2.finishTransaction': assertCommerceBridgeFinishTransactionResult,
151
+ 'commerce.v2.restorePurchases': assertCommerceBridgeRestorePurchasesResult,
152
+ };
153
+ /** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
154
+ export function assertCommerceBridgeResult(method, data) {
155
+ return resultAsserts[method](data);
156
+ }
157
+ function assertResult(method, data) {
158
+ try {
159
+ return assertCommerceBridgeResult(method, data);
160
+ }
161
+ catch (error) {
162
+ throw new PlatformOperationError({
163
+ code: 'NATIVE_PROVIDER_INVALID_RESPONSE',
164
+ message: `Commerce provider returned invalid data for ${method}: ${error instanceof Error ? error.message : String(error)}`,
165
+ });
166
+ }
167
+ }
168
+ function toBridgeError(request, error) {
169
+ if (isTypiaGuardError(error)) {
170
+ return createBridgeError(request.id, commerceV2InvalidPayloadBridgeErrorCode, `${request.method} payload is invalid: ${error.message}`);
171
+ }
172
+ if (error instanceof PlatformOperationError) {
173
+ return createBridgeError(request.id, error.code, error.message, error.retryable);
174
+ }
175
+ const info = commerceErrorInfoOf(error);
176
+ return createBridgeError(request.id, info.code, info.message ?? `${request.method} failed.`, info.retryable);
177
+ }
178
+ function isTypiaGuardError(error) {
179
+ return error instanceof Error && error.name === 'TypeGuardError';
180
+ }
181
+ /**
182
+ * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
183
+ * the event envelope. Results are validated again on this side, so a transport
184
+ * that forgets to is still fail-closed; bridge errors become `CommerceError`s
185
+ * with the bridge code kept as `storeCode` when it is not already a v2 code.
186
+ */
187
+ export function createBridgeCommerceProvider(input) {
188
+ const send = async (method, payload) => {
189
+ let data;
190
+ try {
191
+ data = await input.request(method, payload);
192
+ }
193
+ catch (error) {
194
+ throw toCommerceError(error);
195
+ }
196
+ try {
197
+ return assertCommerceBridgeResult(method, data);
198
+ }
199
+ catch (error) {
200
+ throw new CommerceError('INVALID_RESPONSE', {
201
+ message: `Bridge returned invalid data for ${method}.`,
202
+ cause: error,
203
+ });
204
+ }
205
+ };
206
+ return {
207
+ store: input.store,
208
+ async getCapabilities() {
209
+ return (await send('commerce.v2.getCapabilities', { protocol: commerceProtocol }))
210
+ .capabilities;
211
+ },
212
+ async fetchProducts(request) {
213
+ return (await send('commerce.v2.fetchProducts', request)).products;
214
+ },
215
+ requestPurchase(request) {
216
+ return send('commerce.v2.requestPurchase', request);
217
+ },
218
+ async getAvailablePurchases(request = {}) {
219
+ return (await send('commerce.v2.getAvailablePurchases', request)).purchases;
220
+ },
221
+ finishTransaction(request) {
222
+ return send('commerce.v2.finishTransaction', request);
223
+ },
224
+ async restorePurchases() {
225
+ await send('commerce.v2.restorePurchases', { protocol: commerceProtocol });
226
+ },
227
+ subscribe(listener) {
228
+ return input.subscribe((envelope) => {
229
+ // Another protocol on a shared event channel is not ours to judge.
230
+ if (typeof envelope === 'object' && envelope !== null
231
+ && envelope.protocol !== bridgeEventProtocol) {
232
+ return;
233
+ }
234
+ let event;
235
+ try {
236
+ event = assertBridgeEvent(envelope);
237
+ }
238
+ catch (error) {
239
+ listener({
240
+ type: 'purchaseError',
241
+ error: new CommerceError('INVALID_RESPONSE', {
242
+ message: 'Bridge delivered an invalid commerce event.',
243
+ cause: error,
244
+ }).toCommerceErrorInfo(),
245
+ });
246
+ return;
247
+ }
248
+ listener(event.event);
249
+ });
250
+ },
251
+ };
252
+ }
253
+ function toCommerceError(error) {
254
+ if (error instanceof CommerceError) {
255
+ return error;
256
+ }
257
+ if (error instanceof PlatformOperationError) {
258
+ return normalizeStoreSdkError({
259
+ code: error.code,
260
+ message: error.message,
261
+ retryable: error.retryable,
262
+ });
263
+ }
264
+ return normalizeStoreSdkError(error);
265
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { type PlatformGateway, type PlatformTarget, type ViewportAdapter } from '@mpgd/platform';
2
2
  import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource } from './app-events.js';
3
3
  import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
4
- export { createCapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
4
+ export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, type CapacitorCommerceServiceProvider, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
5
+ export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CapacitorCommerceFeature, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
6
+ export { createStoreCommerceProvider, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, } from './store-commerce-provider.js';
7
+ export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkEvent, type StoreSdkEventName, type StoreSdkEventOf, type StoreSdkFetchProductsInput, type StoreSdkFinishTransactionInput, type StoreSdkFinishTransactionResult, type StoreSdkGetAvailablePurchasesInput, type StoreSdkProduct, type StoreSdkPurchase, type StoreSdkRequestPurchaseInput, type StoreSdkRequestPurchaseResult, } from './store-sdk.js';
5
8
  export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
6
9
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
7
10
  export { createCapacitorViewport, type CapacitorViewportBaseState, type CapacitorOccupiedSurfaceInput, type CapacitorViewportController, type CapacitorViewportHost, } from './viewport.js';
11
+ /**
12
+ * Upper bound on how long a `launched` commerce v2 purchase keeps the game
13
+ * paused when no purchase event for the product arrives (for example a store
14
+ * sheet dismissed without a callback).
15
+ */
16
+ export declare const launchedPurchaseActivityTimeoutMs = 120000;
8
17
  export declare function createCapacitorPlatformGateway(input: {
9
18
  readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
10
19
  readonly appVersion: string;
package/dist/index.js CHANGED
@@ -1,15 +1,21 @@
1
1
  import { decodeBridgeCredentialLoadData, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
2
2
  import { CapacitorGameServices } from '@mpgd/capacitor-game-services';
3
3
  import { PlatformOperationError, } from '@mpgd/platform';
4
+ import { deriveLegacyCommerceCapabilities } from '@mpgd/platform/commerce';
4
5
  import { createCapacitorAppEvents, } from './app-events.js';
6
+ import { createBridgeCommerceProvider } from './commerce-bridge.js';
5
7
  import { assertProviderResponseData, createCapacitorProviderRegistry, } from './providers.js';
6
8
  import { createCapacitorViewport } from './viewport.js';
7
- export { createCapacitorProviderRegistry, } from './providers.js';
9
+ export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, } from './providers.js';
10
+ export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, } from './commerce-bridge.js';
11
+ export { createStoreCommerceProvider, recoverableErrorCodes, } from './store-commerce-provider.js';
12
+ export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
8
13
  export { createCapacitorAppEvents, } from './app-events.js';
9
14
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, } from './native-http.js';
10
15
  export { createCapacitorViewport, } from './viewport.js';
11
16
  const externalActivityMethods = new Set([
12
17
  'commerce.purchase',
18
+ 'commerce.v2.requestPurchase',
13
19
  'ads.showRewarded',
14
20
  'ads.showInterstitial',
15
21
  'identity.requestUpgrade',
@@ -18,6 +24,12 @@ const externalActivityMethods = new Set([
18
24
  function isRecord(value) {
19
25
  return typeof value === 'object' && value !== null && !Array.isArray(value);
20
26
  }
27
+ /**
28
+ * Upper bound on how long a `launched` commerce v2 purchase keeps the game
29
+ * paused when no purchase event for the product arrives (for example a store
30
+ * sheet dismissed without a callback).
31
+ */
32
+ export const launchedPurchaseActivityTimeoutMs = 120_000;
21
33
  const safeFallbackMethods = new Set([
22
34
  'identity.getPlayer',
23
35
  'identity.getSession',
@@ -46,6 +58,17 @@ export function createCapacitorPlatformGateway(input) {
46
58
  const providers = createCapacitorProviderRegistry(input.providers ?? []);
47
59
  const adProvider = providers.byMethod.get('ads.showRewarded')?.adProvider
48
60
  ?? providers.byMethod.get('ads.showInterstitial')?.adProvider;
61
+ const commerceProvider = providers.commerce;
62
+ // The v2 surface is served through the same request path as every other
63
+ // provider method, so the bridge envelope, response validation, and external
64
+ // activity bookkeeping are exercised in process exactly as over a transport.
65
+ const commerceV2 = commerceProvider === undefined
66
+ ? undefined
67
+ : createBridgeCommerceProvider({
68
+ store: commerceProvider.commerce.store,
69
+ request: (method, payload) => request(method, payload),
70
+ subscribe: (listener) => commerceProvider.bridge.subscribe(listener),
71
+ });
49
72
  const lifecycle = createCapacitorAppEvents({
50
73
  target: input.target,
51
74
  ...(input.app === undefined ? {} : { app: input.app }),
@@ -59,10 +82,14 @@ export function createCapacitorPlatformGateway(input) {
59
82
  const viewport = input.viewport ?? createCapacitorViewport({
60
83
  ...(input.onAppEventError === undefined ? {} : { onError: input.onAppEventError }),
61
84
  });
85
+ const launchedPurchaseHolds = new Set();
62
86
  const gatewayLifecycle = {
63
87
  ...lifecycle,
64
88
  async dispose() {
65
89
  try {
90
+ for (const finish of [...launchedPurchaseHolds]) {
91
+ finish();
92
+ }
66
93
  await lifecycle.dispose?.();
67
94
  }
68
95
  finally {
@@ -76,6 +103,7 @@ export function createCapacitorPlatformGateway(input) {
76
103
  const releaseExternalActivity = externalActivityMethods.has(method)
77
104
  ? lifecycle.beginExternalActivity?.()
78
105
  : undefined;
106
+ let holdActivity = false;
79
107
  try {
80
108
  const provider = leaderboardRoute === 'remote'
81
109
  && (method === 'leaderboard.submitScore' || method === 'leaderboard.open')
@@ -98,7 +126,17 @@ export function createCapacitorPlatformGateway(input) {
98
126
  }
99
127
  try {
100
128
  await providers.assertMethodReady(method, payload, productType);
101
- return await sendRequest(method, payload, provider);
129
+ const data = await sendRequest(method, payload, provider);
130
+ if (releaseExternalActivity !== undefined
131
+ && method === 'commerce.v2.requestPurchase'
132
+ && isRecord(data) && data.outcome === 'launched') {
133
+ // The store UI is still open: stay paused until this product's
134
+ // purchase event arrives or the safety timeout ends. The v1 path
135
+ // has no event to wait for and releases when its request returns.
136
+ holdActivity = true;
137
+ holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
138
+ }
139
+ return data;
102
140
  }
103
141
  catch (error) {
104
142
  // Only non-mutating guest/status queries may degrade. A stale readiness
@@ -112,7 +150,53 @@ export function createCapacitorPlatformGateway(input) {
112
150
  return await sendRequest(method, payload, undefined);
113
151
  }
114
152
  finally {
115
- releaseExternalActivity?.();
153
+ if (!holdActivity) {
154
+ releaseExternalActivity?.();
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Keeps the external activity of a `launched` purchase open until the
160
+ * provider reports that product (`purchaseUpdated` or `purchaseError`, or an
161
+ * event without a logical id), the safety timeout ends, or the gateway is
162
+ * disposed. Every path releases exactly once.
163
+ */
164
+ function holdLaunchedPurchaseActivity(release, logicalId) {
165
+ let done = false;
166
+ let unsubscribe;
167
+ const finish = () => {
168
+ if (done) {
169
+ return;
170
+ }
171
+ done = true;
172
+ launchedPurchaseHolds.delete(finish);
173
+ clearTimeout(timer);
174
+ unsubscribe?.();
175
+ release();
176
+ };
177
+ launchedPurchaseHolds.add(finish);
178
+ const timer = setTimeout(finish, launchedPurchaseActivityTimeoutMs);
179
+ if (commerceProvider === undefined) {
180
+ return;
181
+ }
182
+ const subscription = commerceProvider.bridge.subscribe(({ event }) => {
183
+ if (event.type === 'subscriptionUpdated') {
184
+ return;
185
+ }
186
+ const eventLogicalId = event.type === 'purchaseUpdated'
187
+ ? event.purchase.logicalId
188
+ : event.logicalId;
189
+ if (eventLogicalId === undefined || typeof logicalId !== 'string'
190
+ || eventLogicalId === logicalId) {
191
+ finish();
192
+ }
193
+ });
194
+ // A buffered event may have finished the hold while subscribing.
195
+ if (done) {
196
+ subscription();
197
+ }
198
+ else {
199
+ unsubscribe = subscription;
116
200
  }
117
201
  }
118
202
  async function sendRequest(method, payload, provider) {
@@ -161,22 +245,28 @@ export function createCapacitorPlatformGateway(input) {
161
245
  target: input.target,
162
246
  async getCapabilities() {
163
247
  const base = await request('runtime.getCapabilities', {});
164
- const states = await providers.getAvailability();
248
+ // One bounded registry read serves the feature states and the commerce
249
+ // block, so a stalled or invalid provider degrades the snapshot instead
250
+ // of rejecting it, and the boolean flags always follow the block.
251
+ const { availability: states, commerce } = await providers.getCapabilitySnapshot();
165
252
  const rewardedAds = states.rewardedAds === 'available';
166
253
  const interstitialAds = states.interstitialAds === 'available';
167
254
  const bannerAds = states.bannerAds === 'available';
255
+ const legacy = commerce === undefined ? undefined : deriveLegacyCommerceCapabilities(commerce);
168
256
  return {
169
257
  ...base,
170
- nativeIap: states.nativeIap === 'available',
171
- subscriptionIap: states.subscriptionIap === 'available',
258
+ nativeIap: legacy?.nativeIap ?? states.nativeIap === 'available',
259
+ subscriptionIap: legacy?.subscriptionIap ?? states.subscriptionIap === 'available',
172
260
  nativeAds: rewardedAds || interstitialAds || bannerAds,
173
261
  rewardedAds,
174
262
  interstitialAds,
175
263
  bannerAds,
176
264
  nativeLeaderboard: states.nativeLeaderboard === 'available',
177
265
  providerAvailability: states,
266
+ ...(commerce === undefined ? {} : { commerce }),
178
267
  };
179
268
  },
269
+ ...(commerceV2 === undefined ? {} : { commerceV2 }),
180
270
  identity: {
181
271
  getPlayer: () => request('identity.getPlayer', {}),
182
272
  getSession: () => request('identity.getSession', {}),