@mpgd/adapter-capacitor 0.16.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,6 +133,44 @@ normalizes every SDK failure through `mapError` into a `CommerceError` whose
133
133
  `storeCode` keeps the store's own code. It never grants and never sets
134
134
  `settlement`; the backend ledger decides.
135
135
 
136
+ `purchaseGate` is an optional sales switch for new purchases, separate from
137
+ the account binding (and from StoreKit's `appAccountToken`). It is asked once
138
+ per `requestPurchase`, after the product, availability, and kind checks and
139
+ before `accountBinding`, with `{ store, logicalId, storeSku, kind, source }`,
140
+ and answers `{ allowed: true }` or `{ allowed: false, storeCode?, message?,
141
+ retryable? }`. A refusal never opens the store UI and is answered as data:
142
+ `{ outcome: 'failed', error: { code: 'ITEM_UNAVAILABLE', storeCode:
143
+ 'PURCHASES_PAUSED' } }` (or the gate's own `storeCode`), `retryable` false
144
+ unless the gate says otherwise. A gate that could not answer refuses too,
145
+ with `PURCHASE_GATE_FAILED` and always retryable, whether it threw, returned
146
+ something malformed, or did not answer within `purchaseGateTimeoutMs` (three
147
+ seconds): the sales flag is unknown rather than closed. An answer that
148
+ arrives after the timeout is ignored and never opens the store. A thrown
149
+ `CommerceError` keeps its own code, except a code that claims a launched or
150
+ cancelled sheet (`OUTCOME_UNKNOWN`, `ALREADY_OWNED`, `USER_CANCELLED`), which
151
+ becomes `PURCHASE_GATE_FAILED` because nothing was launched. Over an SDK
152
+ with sheet facts (below), a refusal is reported `not-started` before the
153
+ answer, so the reconciler keeps a retryable refusal's key reusable and the
154
+ same key asks again; without sheet facts the reconciler journals the refused
155
+ attempt as terminal `failed`, and a later try uses a new idempotency key. The gate runs while the gateway already holds the
156
+ purchase's external activity (the game is paused, with no sheet shown), so it
157
+ should answer from a flag the game already holds rather than a slow network
158
+ call; the timeout bounds that pause. Recovery, restore, finishing, events, `fetchProducts`, and
159
+ `getAvailablePurchases` never
160
+ ask the gate, so purchases already paid for keep reconciling while new sales
161
+ are refused. A failed or missing account binding is a `failed` result too,
162
+ not a throw: a thrown `CommerceError` keeps its code (with the same exception
163
+ for launched or cancelled codes), and anything else is
164
+ `ACCOUNT_BINDING_REQUIRED` with `ACCOUNT_BINDING_LOOKUP_FAILED`, retryable.
165
+ Over the bridge a thrown error keeps only `code`, `message`, and
166
+ `retryable`, while a result keeps `storeCode`, so the game can tell
167
+ `PURCHASES_PAUSED` from `PLAY_BILLING_ACCOUNT_LOOKUP_FAILED`. The gate is not
168
+ reflected in `getCapabilities()`: `availability` describes the store and
169
+ gates the reconciler (a paused gate there would read as `STORE_UNAVAILABLE`
170
+ and lose the reason), and a capability read stays free of server round trips.
171
+ A shop hides its buy buttons from the same server flag it hands the gate,
172
+ for example the sales flag of its account binding response.
173
+
136
174
  Two failure classes are not terminal. When `requestPurchase` throws or
137
175
  returns a code that normalizes to `OUTCOME_UNKNOWN` (for example
138
176
  `PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`,
@@ -146,6 +184,50 @@ cannot advertise one the SDK lacks: `restore: true` without
146
184
  `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
147
185
  `sdk.finishTransaction`, is rejected when the provider is created.
148
186
 
187
+ ### Store sheet facts
188
+
189
+ A `StoreSdk` that declares `purchasePresentation` reports, for a
190
+ `requestPurchase` call that carries a `presentationId`, when the store sheet
191
+ opens and when it is gone (`open`, then `closed`), or that none opened
192
+ (`not-started`). The Play Billing and StoreKit SDKs declare it by default.
193
+ Only then does the provider offer `CommerceProvider.presentation`, so a new
194
+ adapter over an SDK from before the facts keeps the behavior below, and an
195
+ SDK never receives a `presentationId` from an adapter that predates them.
196
+
197
+ The provider answers every `requestPurchase` with facts keyed by its
198
+ idempotency key, numbered with one increasing `sequence`:
199
+
200
+ - `not-started`, delivered before the answer, for a call that opened no
201
+ sheet: a purchase gate or account binding refusal, an unavailable store,
202
+ any throw, the SDK's own `not-started`, or an SDK that settled without a
203
+ fact.
204
+ - `unknown` when the SDK answers while its native flow still runs and has
205
+ observed nothing (a call timeout): the call keeps waiting for the SDK's
206
+ `open`, `closed`, or `not-started`, bounded by the safety timeout below,
207
+ instead of reading the answer as either.
208
+ - Otherwise `open`, then exactly one `closed`: the SDK's, or after
209
+ `purchasePresentationTimeoutMs` (15 minutes, `reason: 'timeout'`), or on
210
+ `dispose()` (`reason: 'disposed'`). A `not-started` after `open` becomes
211
+ `closed`. A result, a pending purchase, an Ask to Buy, or a purchase event
212
+ is never a `closed` fact.
213
+
214
+ `createCommerceProviderBridge` serves the facts through
215
+ `subscribePresentation` as `{ protocol: 'mpgd.commerce.presentation.v1',
216
+ sequence, event }` envelopes, apart from the commerce envelopes of
217
+ `subscribe`, so a commerce listener (and the host's buffering of purchase
218
+ events for its first listener) is unchanged. `createBridgeCommerceProvider`
219
+ offers `presentation` only when its input has `subscribePresentation`; it
220
+ validates and renumbers the facts and adds `not-started` for a request the
221
+ bridge refused before the host's provider ran. The gateway passes
222
+ `subscribePresentation` only from a provider bridge that has it. With facts,
223
+ the game runtime's purchase coordination turns on by itself
224
+ (`createGamePlatformRuntime`: `createCoordinatedPlatformGateway` with an ad
225
+ provider, `createCoordinatedCommerceProvider` without one), and the gateway
226
+ keeps a purchase's pause (`beginExternalActivity()`) until the sheet's
227
+ `closed` or `not-started`, whatever the answer, with
228
+ `presentedPurchaseActivityTimeoutMs` as a safety net: an Ask to Buy whose
229
+ sheet closed holds nothing.
230
+
149
231
  `createCommerceServiceProvider({ id, commerce })` wraps the provider as a
150
232
  `CapacitorServiceProvider` whose `commerce` field the registry recognizes. It
151
233
  declares no `features` and no `methods`: the six `commerce.v2.*` bridge methods
@@ -169,11 +251,12 @@ read per snapshot (`getCapabilitySnapshot` returns `{ availability, commerce }`,
169
251
  three-second timeout, shape validated): a stalled, failing, or invalid
170
252
  provider yields a degraded block (`createDegradedCommerceCapabilities`:
171
253
  `availability: 'temporarily-unavailable'`, nothing sold) and never rejects the
172
- capability read. A `launched` purchase keeps `beginExternalActivity()` open
173
- after the request returns, since the store UI is still up: the gateway releases
174
- it when the provider reports that product (`purchaseUpdated` or
175
- `purchaseError`), after `launchedPurchaseActivityTimeoutMs` (two minutes), or
176
- on `lifecycle.dispose`. The registry does not gate a commerce method by a
254
+ capability read. Without sheet facts, a `launched` purchase keeps
255
+ `beginExternalActivity()` open after the request returns, since the store UI
256
+ may still be up: the gateway releases it when the provider reports that
257
+ product (`purchaseUpdated` or `purchaseError`), after
258
+ `launchedPurchaseActivityTimeoutMs` (two minutes), or on `lifecycle.dispose`.
259
+ With facts, the sheet's own `closed` ends the pause (see above). The registry does not gate a commerce method by a
177
260
  snapshot: the provider reads its own `availability` and fails closed before any
178
261
  store UI opens. A `commerce.v2.*` request that reaches a transport without a
179
262
  provider still answers `COMMERCE_V2_UNAVAILABLE`.
@@ -1,4 +1,4 @@
1
- import { type BridgeCommerceEvent, type BridgeCommerceV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeCommerceEvent, type BridgeCommercePresentationEvent, type BridgeCommerceV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
3
3
  import type { CapacitorCommerceServiceProvider } from './providers.js';
4
4
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
@@ -9,6 +9,16 @@ export interface CommerceProviderBridge {
9
9
  request(input: BridgeRequest): Promise<BridgeResponse>;
10
10
  /** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
11
11
  subscribe(listener: (event: BridgeCommerceEvent) => void): () => void;
12
+ /**
13
+ * Present when the provider has `presentation`: delivers its purchase
14
+ * presentation facts as `{ protocol: 'mpgd.commerce.presentation.v1',
15
+ * sequence, event }`, `sequence` from 0 per bridge. Facts are never
16
+ * buffered, and a fact of a call that opened no sheet reaches a listener
17
+ * before that call's answer. Kept apart from `subscribe` so a commerce
18
+ * listener sees only commerce envelopes, and a bridge without it tells a
19
+ * consumer that its host has no facts.
20
+ */
21
+ subscribePresentation?(listener: (event: BridgeCommercePresentationEvent) => void): () => void;
12
22
  }
13
23
  export interface CreateCommerceProviderBridgeInput {
14
24
  readonly provider: CommerceProvider;
@@ -45,11 +55,32 @@ export interface CreateBridgeCommerceProviderInput {
45
55
  /** Sends one validated bridge request; the gateway's `request` fits. */
46
56
  readonly request: <TData>(method: BridgeCommerceV2Method, payload: unknown) => Promise<TData>;
47
57
  readonly subscribe: (listener: (event: BridgeCommerceEvent) => void) => () => void;
58
+ /**
59
+ * The bridge host accepts `FinishTransactionInput.retry` (it is built on the
60
+ * same contract and its provider declares `acceptsFinishRetry`). Absent, the
61
+ * provider does not declare it and callers never send `retry`.
62
+ */
63
+ readonly acceptsFinishRetry?: boolean;
64
+ /**
65
+ * The host's purchase presentation facts
66
+ * (`CommerceProviderBridge.subscribePresentation`), each fact of a call
67
+ * that opened no sheet delivered before that call's answer. Only with it
68
+ * does this provider offer `presentation`: a host built before the
69
+ * protocol, or one whose provider has no facts, leaves it out, and the
70
+ * consumer keeps its behavior without them.
71
+ */
72
+ readonly subscribePresentation?: (listener: (event: BridgeCommercePresentationEvent) => void) => () => void;
48
73
  }
49
74
  /**
50
75
  * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
51
76
  * the event envelope. Results are validated again on this side, so a transport
52
77
  * that forgets to is still fail-closed; bridge errors become `CommerceError`s
53
78
  * with the bridge code kept as `storeCode` when it is not already a v2 code.
79
+ *
80
+ * With `subscribePresentation`, it offers `presentation`: the host's facts,
81
+ * validated and numbered again on this side, plus `not-started`, delivered
82
+ * before the answer, for a `requestPurchase` that settles without any fact
83
+ * (the bridge refused it before the host's provider ran). Every call it
84
+ * forwards then ends with a fact.
54
85
  */
55
86
  export declare function createBridgeCommerceProvider(input: CreateBridgeCommerceProviderInput): CommerceProvider;
@@ -1,6 +1,6 @@
1
- import { bridgeCommerceEventProtocol, 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, assertCommerceEventEnvelope, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, } from '@mpgd/platform/commerce';
1
+ import { bridgeCommerceEventProtocol, bridgeCommercePresentationEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
+ import { createPurchasePresentationRelay, PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, assertCommerceEventEnvelope, assertCommercePresentationEventEnvelope, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, } from '@mpgd/platform/commerce';
4
4
  import { namesOtherProtocol } from './bridge-events.js';
5
5
  import { normalizeStoreSdkError } from './store-sdk.js';
6
6
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
@@ -19,8 +19,11 @@ export const commerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_METHOD_UN
19
19
  export function createCommerceProviderBridge(input) {
20
20
  const { provider } = input;
21
21
  const listeners = new Set();
22
+ const presentationListeners = new Set();
22
23
  let sequence = 0;
24
+ let presentationSequence = 0;
23
25
  let detach;
26
+ let detachPresentation;
24
27
  const deliver = (event) => {
25
28
  const envelope = { protocol: commerceProtocol, sequence, event };
26
29
  sequence += 1;
@@ -28,6 +31,35 @@ export function createCommerceProviderBridge(input) {
28
31
  listener(envelope);
29
32
  }
30
33
  };
34
+ const deliverPresentation = (event) => {
35
+ let envelope;
36
+ try {
37
+ envelope = assertCommercePresentationEventEnvelope({
38
+ protocol: bridgeCommercePresentationEventProtocol,
39
+ sequence: presentationSequence,
40
+ event,
41
+ });
42
+ }
43
+ catch (error) {
44
+ // A fact the contract rejects is not delivered; the provider's own
45
+ // safety timeout still ends a sheet it never reports closed.
46
+ console.warn('A commerce provider reported an invalid presentation fact.', error);
47
+ return;
48
+ }
49
+ presentationSequence += 1;
50
+ for (const entry of [...presentationListeners]) {
51
+ if (!presentationListeners.has(entry)) {
52
+ continue;
53
+ }
54
+ try {
55
+ entry.listener(envelope);
56
+ }
57
+ catch (error) {
58
+ // A failing listener must not keep a `closed` fact from the others.
59
+ console.warn('A purchase presentation bridge listener failed.', error);
60
+ }
61
+ }
62
+ };
31
63
  const serve = async (request) => {
32
64
  const { payload } = request;
33
65
  switch (request.method) {
@@ -101,6 +133,22 @@ export function createCommerceProviderBridge(input) {
101
133
  detach = undefined;
102
134
  };
103
135
  },
136
+ ...(provider.presentation === undefined ? {} : {
137
+ subscribePresentation(listener) {
138
+ const entry = { listener };
139
+ presentationListeners.add(entry);
140
+ if (presentationListeners.size === 1) {
141
+ detachPresentation = provider.presentation?.subscribe(deliverPresentation);
142
+ }
143
+ return () => {
144
+ if (!presentationListeners.delete(entry) || presentationListeners.size > 0) {
145
+ return;
146
+ }
147
+ detachPresentation?.();
148
+ detachPresentation = undefined;
149
+ };
150
+ },
151
+ }),
104
152
  };
105
153
  }
106
154
  /**
@@ -164,6 +212,12 @@ function isTypiaGuardError(error) {
164
212
  * the event envelope. Results are validated again on this side, so a transport
165
213
  * that forgets to is still fail-closed; bridge errors become `CommerceError`s
166
214
  * with the bridge code kept as `storeCode` when it is not already a v2 code.
215
+ *
216
+ * With `subscribePresentation`, it offers `presentation`: the host's facts,
217
+ * validated and numbered again on this side, plus `not-started`, delivered
218
+ * before the answer, for a `requestPurchase` that settles without any fact
219
+ * (the bridge refused it before the host's provider ran). Every call it
220
+ * forwards then ends with a fact.
167
221
  */
168
222
  export function createBridgeCommerceProvider(input) {
169
223
  const send = async (method, payload) => {
@@ -184,8 +238,9 @@ export function createBridgeCommerceProvider(input) {
184
238
  });
185
239
  }
186
240
  };
187
- return {
241
+ const provider = {
188
242
  store: input.store,
243
+ ...(input.acceptsFinishRetry === true ? { acceptsFinishRetry: true } : {}),
189
244
  async getCapabilities() {
190
245
  return (await send('commerce.v2.getCapabilities', { protocol: commerceProtocol }))
191
246
  .capabilities;
@@ -231,6 +286,43 @@ export function createBridgeCommerceProvider(input) {
231
286
  });
232
287
  },
233
288
  };
289
+ const subscribePresentation = input.subscribePresentation;
290
+ return subscribePresentation === undefined
291
+ ? provider
292
+ : withBridgePresentation(provider, subscribePresentation);
293
+ }
294
+ /**
295
+ * `presentation` over the host's facts: validated, then relayed by the
296
+ * platform's `createPurchasePresentationRelay` (a superseded fact dropped,
297
+ * the rest numbered again, `not-started` added for a call that settled
298
+ * without a fact). One attachment to the host serves every listener.
299
+ */
300
+ function withBridgePresentation(provider, subscribePresentation) {
301
+ const relay = createPurchasePresentationRelay({
302
+ subscribe: (listener) => subscribePresentation((envelope) => {
303
+ if (namesOtherProtocol(envelope, bridgeCommercePresentationEventProtocol)) {
304
+ return;
305
+ }
306
+ let event;
307
+ try {
308
+ event = assertCommercePresentationEventEnvelope(envelope).event;
309
+ }
310
+ catch (error) {
311
+ console.warn('The bridge delivered an invalid purchase presentation fact.', error);
312
+ return;
313
+ }
314
+ listener(event);
315
+ }),
316
+ }, {
317
+ onListenerError: (error) => {
318
+ console.warn('A purchase presentation listener failed.', error);
319
+ },
320
+ });
321
+ return {
322
+ ...provider,
323
+ presentation: relay.presentation,
324
+ requestPurchase: (request) => relay.track(request.idempotencyKey, () => provider.requestPurchase(request)),
325
+ };
234
326
  }
235
327
  function toCommerceError(error) {
236
328
  if (error instanceof CommerceError) {
package/dist/index.d.ts CHANGED
@@ -3,9 +3,10 @@ import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type Capacit
3
3
  import { type CapacitorCrashReportingOptions } from './crash-reporting.js';
4
4
  import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
5
5
  export { capacitorCrashlyticsPluginName, capacitorCrashReportingProvider, createCapacitorCrashReporter, createLocalCrashCollectionPreferenceStore, type CrashCollectionPreferenceStore, type CapacitorCrashlyticsPlugin, type CapacitorCrashReporterOptions, type CapacitorCrashReportingOptions, } from './crash-reporting.js';
6
+ export { createNativePurchasePresentation, purchasePresentationTimeoutMs, type NativePurchasePresentation, type NativePurchasePresentationPlugin, } from './purchase-presentation.js';
6
7
  export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, createDegradedNotificationCapabilities, type CapacitorCommerceServiceProvider, type CapacitorNotificationServiceProvider, type CapacitorNotificationsV2Service, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
7
8
  export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
8
- export { createStoreCommerceProvider, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, } from './store-commerce-provider.js';
9
+ export { accountBindingLookupFailedStoreCode, createStoreCommerceProvider, purchaseGateFailedStoreCode, purchaseGateTimeoutMs, purchasesPausedStoreCode, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, type StorePurchaseGate, type StorePurchaseGateDecision, type StorePurchaseGateRequest, } from './store-commerce-provider.js';
9
10
  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';
10
11
  export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
11
12
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
@@ -13,9 +14,17 @@ export { createCapacitorViewport, type CapacitorViewportBaseState, type Capacito
13
14
  /**
14
15
  * Upper bound on how long a `launched` commerce v2 purchase keeps the game
15
16
  * paused when no purchase event for the product arrives (for example a store
16
- * sheet dismissed without a callback).
17
+ * sheet dismissed without a callback). Only for a provider without sheet
18
+ * facts; see `presentedPurchaseActivityTimeoutMs`.
17
19
  */
18
20
  export declare const launchedPurchaseActivityTimeoutMs = 120000;
21
+ /**
22
+ * Safety bound on how long a purchase whose provider reports sheet facts
23
+ * keeps the game paused after the purchase call answered with the sheet
24
+ * still open. The sheet's `closed` fact normally ends the pause; the
25
+ * provider's own `purchasePresentationTimeoutMs` reports one first.
26
+ */
27
+ export declare const presentedPurchaseActivityTimeoutMs: number;
19
28
  export declare function createCapacitorPlatformGateway(input: {
20
29
  readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
21
30
  readonly appVersion: string;
package/dist/index.js CHANGED
@@ -4,15 +4,17 @@ import { PlatformOperationError, } from '@mpgd/platform';
4
4
  import { withShareIntentCheck } from '@mpgd/platform/routes';
5
5
  import { createCapacitorAppEvents, } from './app-events.js';
6
6
  import { namesOtherProtocol } from './bridge-events.js';
7
+ import { purchasePresentationTimeoutMs } from './purchase-presentation.js';
7
8
  import { createBridgeCommerceProvider } from './commerce-bridge.js';
8
9
  import { createCapacitorGatewayCrashReporting, } from './crash-reporting.js';
9
10
  import { assertProviderResponseData, createCapacitorProviderRegistry, } from './providers.js';
10
11
  import { assertNotificationsV2Served, externalActivityMethods } from './request-policy.js';
11
12
  import { createCapacitorViewport } from './viewport.js';
12
13
  export { capacitorCrashlyticsPluginName, capacitorCrashReportingProvider, createCapacitorCrashReporter, createLocalCrashCollectionPreferenceStore, } from './crash-reporting.js';
14
+ export { createNativePurchasePresentation, purchasePresentationTimeoutMs, } from './purchase-presentation.js';
13
15
  export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, createDegradedNotificationCapabilities, } from './providers.js';
14
16
  export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, } from './commerce-bridge.js';
15
- export { createStoreCommerceProvider, recoverableErrorCodes, } from './store-commerce-provider.js';
17
+ export { accountBindingLookupFailedStoreCode, createStoreCommerceProvider, purchaseGateFailedStoreCode, purchaseGateTimeoutMs, purchasesPausedStoreCode, recoverableErrorCodes, } from './store-commerce-provider.js';
16
18
  export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
17
19
  export { createCapacitorAppEvents, } from './app-events.js';
18
20
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, } from './native-http.js';
@@ -23,9 +25,17 @@ function isRecord(value) {
23
25
  /**
24
26
  * Upper bound on how long a `launched` commerce v2 purchase keeps the game
25
27
  * paused when no purchase event for the product arrives (for example a store
26
- * sheet dismissed without a callback).
28
+ * sheet dismissed without a callback). Only for a provider without sheet
29
+ * facts; see `presentedPurchaseActivityTimeoutMs`.
27
30
  */
28
31
  export const launchedPurchaseActivityTimeoutMs = 120_000;
32
+ /**
33
+ * Safety bound on how long a purchase whose provider reports sheet facts
34
+ * keeps the game paused after the purchase call answered with the sheet
35
+ * still open. The sheet's `closed` fact normally ends the pause; the
36
+ * provider's own `purchasePresentationTimeoutMs` reports one first.
37
+ */
38
+ export const presentedPurchaseActivityTimeoutMs = purchasePresentationTimeoutMs;
29
39
  const safeFallbackMethods = new Set([
30
40
  'identity.getPlayer',
31
41
  'identity.getSession',
@@ -59,10 +69,13 @@ export function createCapacitorPlatformGateway(input) {
59
69
  // other provider method, so the bridge envelope, response validation, and
60
70
  // external activity bookkeeping are exercised in process exactly as over a
61
71
  // transport.
72
+ const subscribePresentation = commerceProvider?.bridge.subscribePresentation?.bind(commerceProvider.bridge);
62
73
  const commerce = commerceProvider === undefined
63
74
  ? undefined
64
75
  : createBridgeCommerceProvider({
65
76
  store: commerceProvider.commerce.store,
77
+ // The host below is this package's, on the same contract.
78
+ acceptsFinishRetry: commerceProvider.commerce.acceptsFinishRetry === true,
66
79
  request: (method, payload) => request(method, payload),
67
80
  // The provider's channel (`NativeBridge`) may carry other protocols.
68
81
  // Only an envelope that names another protocol is dropped; anything
@@ -73,6 +86,10 @@ export function createCapacitorPlatformGateway(input) {
73
86
  listener(envelope);
74
87
  }
75
88
  }),
89
+ // Facts only from a bridge that serves them (`createCommerceServiceProvider`
90
+ // over a provider with `presentation`); otherwise `commerce` has no
91
+ // `presentation` and purchases keep the result-based pause below.
92
+ ...(subscribePresentation === undefined ? {} : { subscribePresentation }),
76
93
  });
77
94
  const notificationsProvider = providers.notifications;
78
95
  // Served through the same request path as every other provider method, as
@@ -135,6 +152,22 @@ export function createCapacitorPlatformGateway(input) {
135
152
  ? lifecycle.beginExternalActivity?.()
136
153
  : undefined;
137
154
  let holdActivity = false;
155
+ // Watched before the request is sent: a provider reports a sheet that
156
+ // never opened before it answers.
157
+ const presentation = releaseExternalActivity !== undefined
158
+ && method === 'commerce.v2.requestPurchase'
159
+ ? watchPurchasePresentation(payload)
160
+ : undefined;
161
+ // Released once: by the sheet's own end (which may come while the call
162
+ // still runs, for example a safety-timeout `closed`), or after the call.
163
+ let released = false;
164
+ const release = () => {
165
+ if (!released) {
166
+ released = true;
167
+ releaseExternalActivity?.();
168
+ }
169
+ };
170
+ presentation?.onEnd(release);
138
171
  try {
139
172
  const provider = leaderboardRoute === 'remote'
140
173
  && (method === 'leaderboard.submitScore' || method === 'leaderboard.open')
@@ -146,11 +179,15 @@ export function createCapacitorPlatformGateway(input) {
146
179
  const data = await sendRequest(method, payload, provider);
147
180
  if (releaseExternalActivity !== undefined
148
181
  && method === 'commerce.v2.requestPurchase'
149
- && isRecord(data) && data.outcome === 'launched') {
150
- // The store UI is still open: stay paused until this product's
151
- // purchase event arrives or the safety timeout ends.
182
+ && isRecord(data) && data.outcome === 'launched'
183
+ && presentation?.reported() !== true) {
184
+ // No sheet facts for this purchase: the store UI may still be
185
+ // open, so stay paused until this product's purchase event
186
+ // arrives or the safety timeout ends. With facts, the sheet's
187
+ // own `closed` ends the pause (below), and a pending purchase
188
+ // whose sheet closed holds nothing.
152
189
  holdActivity = true;
153
- holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
190
+ holdLaunchedPurchaseActivity(release, isRecord(payload) ? payload.logicalId : undefined);
154
191
  }
155
192
  return data;
156
193
  }
@@ -166,10 +203,106 @@ export function createCapacitorPlatformGateway(input) {
166
203
  return await sendRequest(method, payload, undefined);
167
204
  }
168
205
  finally {
169
- if (!holdActivity) {
170
- releaseExternalActivity?.();
206
+ if (holdActivity) {
207
+ presentation?.dispose();
208
+ }
209
+ else if (!released && presentation?.open() === true) {
210
+ // The sheet is still up, whatever the answer (a result, a pending
211
+ // purchase, or a throw): stay paused until its `closed` fact.
212
+ holdPresentedPurchaseActivity(release, presentation);
213
+ }
214
+ else {
215
+ presentation?.dispose();
216
+ release();
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Follows one purchase's presentation facts (by its idempotency key) from
222
+ * `commerce.presentation`: the bridge's facts already validated, with a
223
+ * superseded or replayed one dropped, so a stale fact cannot end the hold
224
+ * while the sheet is up. Undefined when the bridge serves no facts or the
225
+ * payload has no key.
226
+ */
227
+ function watchPurchasePresentation(payload) {
228
+ const idempotencyKey = isRecord(payload) ? payload.idempotencyKey : undefined;
229
+ const facts = commerce?.presentation;
230
+ if (facts === undefined || typeof idempotencyKey !== 'string') {
231
+ return undefined;
232
+ }
233
+ // `held`: the sheet is (`open`) or may be (`unknown`) up; `opened`: seen open.
234
+ let held = false;
235
+ let opened = false;
236
+ let ended = false;
237
+ let onEnd;
238
+ let unsubscribe;
239
+ let disposed = false;
240
+ const dispose = () => {
241
+ disposed = true;
242
+ onEnd = undefined;
243
+ unsubscribe?.();
244
+ unsubscribe = undefined;
245
+ };
246
+ const subscription = facts.subscribe((event) => {
247
+ if (event.idempotencyKey !== idempotencyKey || ended) {
248
+ return;
249
+ }
250
+ const { state } = event;
251
+ if (state === 'open' || state === 'unknown') {
252
+ // `unknown`: the sheet may still be up; hold as for an open one.
253
+ held = true;
254
+ opened ||= state === 'open';
171
255
  }
256
+ else if (state === 'closed' || (state === 'not-started' && !opened)) {
257
+ // A sheet seen open ends only with `closed` (its provider turns a
258
+ // later `not-started` into `closed`); a `not-started` then belongs
259
+ // to another call of the same key.
260
+ ended = true;
261
+ const end = onEnd;
262
+ dispose();
263
+ end?.();
264
+ }
265
+ });
266
+ if (disposed) {
267
+ subscription();
268
+ }
269
+ else {
270
+ unsubscribe = subscription;
172
271
  }
272
+ return {
273
+ reported: () => held || ended,
274
+ open: () => held && !ended,
275
+ onEnd(callback) {
276
+ if (ended) {
277
+ callback();
278
+ }
279
+ else {
280
+ onEnd = callback;
281
+ }
282
+ },
283
+ dispose,
284
+ };
285
+ }
286
+ /**
287
+ * Keeps a purchase's external activity open while its store sheet is up:
288
+ * until the sheet's `closed` fact, the safety timeout, or dispose. A
289
+ * result, a pending purchase, or a purchase event does not end it.
290
+ */
291
+ function holdPresentedPurchaseActivity(release, presentation) {
292
+ let done = false;
293
+ const finish = () => {
294
+ if (done) {
295
+ return;
296
+ }
297
+ done = true;
298
+ launchedPurchaseHolds.delete(finish);
299
+ clearTimeout(timer);
300
+ presentation.dispose();
301
+ release();
302
+ };
303
+ launchedPurchaseHolds.add(finish);
304
+ const timer = setTimeout(finish, presentedPurchaseActivityTimeoutMs);
305
+ presentation.onEnd(finish);
173
306
  }
174
307
  /**
175
308
  * Keeps the external activity of a `launched` purchase open until the
@@ -1,4 +1,4 @@
1
- import { type BridgeEvent, type BridgeMethod, type BridgeNotificationsEvent, type BridgeNotificationsV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeCommercePresentationEvent, type BridgeEvent, type BridgeMethod, type BridgeNotificationsEvent, type BridgeNotificationsV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import type { AdProvider } from '@mpgd/platform/ads';
3
3
  import { type CommerceCapabilities, type NotificationSubscriptionAdapter, type PlatformProviderAvailability, type PlatformProviderFeature } from '@mpgd/platform';
4
4
  import { type CommerceProvider } from '@mpgd/platform/commerce';
@@ -86,7 +86,15 @@ export interface CapacitorNotificationServiceProvider extends CapacitorServicePr
86
86
  /** The provider the registry serves commerce v2 from. */
87
87
  export interface CapacitorCommerceServiceProvider extends CapacitorServiceProvider {
88
88
  readonly commerce: CommerceProvider;
89
- readonly bridge: NativeBridge & Required<Pick<NativeBridge, 'subscribe'>>;
89
+ readonly bridge: NativeBridge & Required<Pick<NativeBridge, 'subscribe'>> & {
90
+ /**
91
+ * The provider's purchase presentation facts
92
+ * (`CommerceProviderBridge.subscribePresentation`). Only with it does
93
+ * `gateway.commerce` offer `presentation`, and the gateway keep a
94
+ * purchase's pause until the sheet's `closed` fact.
95
+ */
96
+ subscribePresentation?(listener: (event: BridgeCommercePresentationEvent) => void): () => void;
97
+ };
90
98
  }
91
99
  export interface CapacitorProviderCapabilitySnapshot {
92
100
  readonly availability: Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
@@ -0,0 +1,55 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ import type { StoreSdkPurchasePresentation } from './store-sdk.js';
3
+ /**
4
+ * Upper bound on how long one store sheet counts as open (or possibly open)
5
+ * without a native `closed` fact. Past it the provider reports `closed` with
6
+ * reason `timeout`, so a lost native observation can pause the game, but
7
+ * never forever. It matches the purchase flow timeout of the OpenIAP Google
8
+ * core (15 minutes).
9
+ */
10
+ export declare const purchasePresentationTimeoutMs: number;
11
+ /** A store plugin's native surface for sheet facts (Play Billing, StoreKit). */
12
+ export interface NativePurchasePresentationPlugin {
13
+ addListener(eventName: 'purchasePresentation', listener: (event: unknown) => void): Promise<PluginListenerHandle>;
14
+ readonly getPresentationSupport?: () => Promise<unknown>;
15
+ }
16
+ /** What a store plugin's `StoreSdk` uses to report its native sheet facts. */
17
+ export interface NativePurchasePresentation {
18
+ /** The SDK's `purchasePresentation` declaration. */
19
+ readonly purchasePresentation: StoreSdkPurchasePresentation;
20
+ /**
21
+ * Whether the native code in the app reports facts (`getPresentationSupport`).
22
+ * A build from before them answers `UNIMPLEMENTED`, which is remembered;
23
+ * any other failure is asked again next time.
24
+ */
25
+ supported(): Promise<boolean>;
26
+ /**
27
+ * Someone follows the facts: an adapter that reads them subscribes when it
28
+ * builds its provider (one from before them never does).
29
+ */
30
+ followed(): boolean;
31
+ /** Follows facts for a `purchase` call about to be sent with this id. */
32
+ begin(presentationId: string): void;
33
+ /**
34
+ * Applies the sheet state a `purchase` answer, or a rejection's `data`,
35
+ * carried (`presentation`): `open`, `closed`, `not-started`, or
36
+ * `requested` (native code still runs the flow and has observed nothing
37
+ * yet; its facts follow as events).
38
+ */
39
+ answered(presentationId: string, answerOrError: unknown): void;
40
+ }
41
+ /**
42
+ * Sheet facts by presentation id from the plugin's `purchasePresentation`
43
+ * events and the `presentation` field of its `purchase` answers, whichever
44
+ * arrives first; each call gets at most one `open` and one terminal fact.
45
+ * A `requested` answer is reported as `unknown`: the sheet may still come,
46
+ * so the provider keeps the call waiting for the native `open`, `closed`, or
47
+ * `not-started` instead of reading the answer as a sheet that never opened
48
+ * or inventing one that did. The native listener is attached with the first
49
+ * subscriber and removed with the last.
50
+ *
51
+ * Exported for the store plugins, which peer on this package. A plugin reads
52
+ * it through the module namespace, so with an adapter from before it the
53
+ * plugin declares no facts (and that adapter never asks for them).
54
+ */
55
+ export declare function createNativePurchasePresentation(native: NativePurchasePresentationPlugin): NativePurchasePresentation;