@mpgd/adapter-capacitor 0.17.0 → 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
@@ -148,14 +148,11 @@ seconds): the sales flag is unknown rather than closed. An answer that
148
148
  arrives after the timeout is ignored and never opens the store. A thrown
149
149
  `CommerceError` keeps its own code, except a code that claims a launched or
150
150
  cancelled sheet (`OUTCOME_UNKNOWN`, `ALREADY_OWNED`, `USER_CANCELLED`), which
151
- becomes `PURCHASE_GATE_FAILED` because nothing was launched. The reconciler
152
- journals a refused attempt as terminal `failed` even when it is retryable:
153
- only trusted presentation facts (`not-started`) keep a key reusable, and the
154
- Capacitor bridge does not carry them yet, so a later try uses a new
155
- idempotency key. The store provider deliberately exposes no `presentation`
156
- that could only ever say `not-started`, because the game runtime's purchase
157
- coordinator would take it as a closure source and quarantine the game after
158
- every launched purchase. The gate runs while the gateway already holds the
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
159
156
  purchase's external activity (the game is paused, with no sheet shown), so it
160
157
  should answer from a flag the game already holds rather than a slow network
161
158
  call; the timeout bounds that pause. Recovery, restore, finishing, events, `fetchProducts`, and
@@ -187,6 +184,50 @@ cannot advertise one the SDK lacks: `restore: true` without
187
184
  `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
188
185
  `sdk.finishTransaction`, is rejected when the provider is created.
189
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
+
190
231
  `createCommerceServiceProvider({ id, commerce })` wraps the provider as a
191
232
  `CapacitorServiceProvider` whose `commerce` field the registry recognizes. It
192
233
  declares no `features` and no `methods`: the six `commerce.v2.*` bridge methods
@@ -210,11 +251,12 @@ read per snapshot (`getCapabilitySnapshot` returns `{ availability, commerce }`,
210
251
  three-second timeout, shape validated): a stalled, failing, or invalid
211
252
  provider yields a degraded block (`createDegradedCommerceCapabilities`:
212
253
  `availability: 'temporarily-unavailable'`, nothing sold) and never rejects the
213
- capability read. A `launched` purchase keeps `beginExternalActivity()` open
214
- after the request returns, since the store UI is still up: the gateway releases
215
- it when the provider reports that product (`purchaseUpdated` or
216
- `purchaseError`), after `launchedPurchaseActivityTimeoutMs` (two minutes), or
217
- 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
218
260
  snapshot: the provider reads its own `availability` and fails closed before any
219
261
  store UI opens. A `commerce.v2.*` request that reaches a transport without a
220
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;
@@ -51,11 +61,26 @@ export interface CreateBridgeCommerceProviderInput {
51
61
  * provider does not declare it and callers never send `retry`.
52
62
  */
53
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;
54
73
  }
55
74
  /**
56
75
  * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
57
76
  * the event envelope. Results are validated again on this side, so a transport
58
77
  * that forgets to is still fail-closed; bridge errors become `CommerceError`s
59
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.
60
85
  */
61
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,7 +238,7 @@ export function createBridgeCommerceProvider(input) {
184
238
  });
185
239
  }
186
240
  };
187
- return {
241
+ const provider = {
188
242
  store: input.store,
189
243
  ...(input.acceptsFinishRetry === true ? { acceptsFinishRetry: true } : {}),
190
244
  async getCapabilities() {
@@ -232,6 +286,43 @@ export function createBridgeCommerceProvider(input) {
232
286
  });
233
287
  },
234
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
+ };
235
326
  }
236
327
  function toCommerceError(error) {
237
328
  if (error instanceof CommerceError) {
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ 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
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';
@@ -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,12 +4,14 @@ 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
17
  export { accountBindingLookupFailedStoreCode, createStoreCommerceProvider, purchaseGateFailedStoreCode, purchaseGateTimeoutMs, purchasesPausedStoreCode, recoverableErrorCodes, } from './store-commerce-provider.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,6 +69,7 @@ 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({
@@ -75,6 +86,10 @@ export function createCapacitorPlatformGateway(input) {
75
86
  listener(envelope);
76
87
  }
77
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 }),
78
93
  });
79
94
  const notificationsProvider = providers.notifications;
80
95
  // Served through the same request path as every other provider method, as
@@ -137,6 +152,22 @@ export function createCapacitorPlatformGateway(input) {
137
152
  ? lifecycle.beginExternalActivity?.()
138
153
  : undefined;
139
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);
140
171
  try {
141
172
  const provider = leaderboardRoute === 'remote'
142
173
  && (method === 'leaderboard.submitScore' || method === 'leaderboard.open')
@@ -148,11 +179,15 @@ export function createCapacitorPlatformGateway(input) {
148
179
  const data = await sendRequest(method, payload, provider);
149
180
  if (releaseExternalActivity !== undefined
150
181
  && method === 'commerce.v2.requestPurchase'
151
- && isRecord(data) && data.outcome === 'launched') {
152
- // The store UI is still open: stay paused until this product's
153
- // 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.
154
189
  holdActivity = true;
155
- holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
190
+ holdLaunchedPurchaseActivity(release, isRecord(payload) ? payload.logicalId : undefined);
156
191
  }
157
192
  return data;
158
193
  }
@@ -168,10 +203,106 @@ export function createCapacitorPlatformGateway(input) {
168
203
  return await sendRequest(method, payload, undefined);
169
204
  }
170
205
  finally {
171
- if (!holdActivity) {
172
- 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';
173
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;
174
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);
175
306
  }
176
307
  /**
177
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;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Upper bound on how long one store sheet counts as open (or possibly open)
3
+ * without a native `closed` fact. Past it the provider reports `closed` with
4
+ * reason `timeout`, so a lost native observation can pause the game, but
5
+ * never forever. It matches the purchase flow timeout of the OpenIAP Google
6
+ * core (15 minutes).
7
+ */
8
+ export const purchasePresentationTimeoutMs = 15 * 60_000;
9
+ /**
10
+ * Sheet facts by presentation id from the plugin's `purchasePresentation`
11
+ * events and the `presentation` field of its `purchase` answers, whichever
12
+ * arrives first; each call gets at most one `open` and one terminal fact.
13
+ * A `requested` answer is reported as `unknown`: the sheet may still come,
14
+ * so the provider keeps the call waiting for the native `open`, `closed`, or
15
+ * `not-started` instead of reading the answer as a sheet that never opened
16
+ * or inventing one that did. The native listener is attached with the first
17
+ * subscriber and removed with the last.
18
+ *
19
+ * Exported for the store plugins, which peer on this package. A plugin reads
20
+ * it through the module namespace, so with an adapter from before it the
21
+ * plugin declares no facts (and that adapter never asks for them).
22
+ */
23
+ export function createNativePurchasePresentation(native) {
24
+ const listeners = new Set();
25
+ const sheets = new Map();
26
+ let handle;
27
+ let generation = 0;
28
+ let support;
29
+ const report = (presentationId, state, reason) => {
30
+ const current = sheets.get(presentationId);
31
+ if (current === undefined || current === state || (state === 'unknown' && current === 'open')) {
32
+ return;
33
+ }
34
+ if (state === 'open' || state === 'unknown') {
35
+ sheets.set(presentationId, state);
36
+ }
37
+ else {
38
+ sheets.delete(presentationId);
39
+ }
40
+ const event = {
41
+ presentationId,
42
+ state,
43
+ ...(reason === undefined ? {} : { reason }),
44
+ };
45
+ for (const entry of [...listeners]) {
46
+ if (!listeners.has(entry)) {
47
+ continue;
48
+ }
49
+ // One failing listener must not keep a later one (the commerce
50
+ // provider) from its `closed` fact.
51
+ try {
52
+ entry.listener(event);
53
+ }
54
+ catch (error) {
55
+ console.warn('A purchase presentation listener failed.', error);
56
+ }
57
+ }
58
+ };
59
+ const onNativeFact = (event) => {
60
+ if (!isRecord(event) || typeof event.presentationId !== 'string') {
61
+ return;
62
+ }
63
+ const { state, reason } = event;
64
+ if (state === 'open' || state === 'closed' || state === 'not-started') {
65
+ report(event.presentationId, state, state === 'closed' && (reason === 'interrupted' || reason === 'timeout')
66
+ ? reason
67
+ : undefined);
68
+ }
69
+ };
70
+ const attach = () => {
71
+ if (handle !== undefined) {
72
+ return;
73
+ }
74
+ const attached = generation;
75
+ let added;
76
+ try {
77
+ added = native.addListener('purchasePresentation', (event) => {
78
+ if (attached === generation) {
79
+ onNativeFact(event);
80
+ }
81
+ });
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ handle = added;
87
+ added.catch(() => {
88
+ if (handle === added) {
89
+ handle = undefined;
90
+ }
91
+ });
92
+ };
93
+ const detach = () => {
94
+ const current = handle;
95
+ if (current === undefined) {
96
+ return;
97
+ }
98
+ handle = undefined;
99
+ generation += 1;
100
+ current.then((resolved) => resolved.remove()).catch(() => undefined);
101
+ };
102
+ return {
103
+ purchasePresentation: {
104
+ subscribe(listener) {
105
+ const entry = { listener };
106
+ listeners.add(entry);
107
+ attach();
108
+ return () => {
109
+ if (listeners.delete(entry) && listeners.size === 0) {
110
+ detach();
111
+ }
112
+ };
113
+ },
114
+ },
115
+ followed() {
116
+ return listeners.size > 0;
117
+ },
118
+ supported() {
119
+ if (support !== undefined) {
120
+ return support;
121
+ }
122
+ const check = native.getPresentationSupport;
123
+ if (typeof check !== 'function') {
124
+ return Promise.resolve(false);
125
+ }
126
+ const attempt = Promise.resolve()
127
+ .then(() => check.call(native))
128
+ .then((answer) => isRecord(answer)
129
+ && typeof answer.purchasePresentation === 'number'
130
+ && answer.purchasePresentation >= 1, (error) => {
131
+ if (!isRecord(error) || error.code !== 'UNIMPLEMENTED') {
132
+ support = undefined;
133
+ }
134
+ return false;
135
+ });
136
+ support = attempt;
137
+ return attempt;
138
+ },
139
+ begin(presentationId) {
140
+ sheets.set(presentationId, 'requested');
141
+ },
142
+ answered(presentationId, answerOrError) {
143
+ const presentation = answeredPresentation(answerOrError);
144
+ if (presentation === 'open' || presentation === 'closed' || presentation === 'not-started') {
145
+ report(presentationId, presentation);
146
+ }
147
+ else if (presentation === 'requested') {
148
+ report(presentationId, 'unknown');
149
+ }
150
+ else if (sheets.get(presentationId) === 'requested') {
151
+ // No sheet state from native code that declared facts: nothing was
152
+ // seen opening, which the provider reads as not-started.
153
+ sheets.delete(presentationId);
154
+ }
155
+ },
156
+ };
157
+ }
158
+ /** The `presentation` of a `purchase` answer or of a rejection's `data`. */
159
+ function answeredPresentation(answer) {
160
+ if (!isRecord(answer)) {
161
+ return undefined;
162
+ }
163
+ if ('presentation' in answer) {
164
+ return answer.presentation;
165
+ }
166
+ return isRecord(answer.data) ? answer.data.presentation : undefined;
167
+ }
168
+ function isRecord(value) {
169
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
170
+ }
@@ -1,4 +1,4 @@
1
- import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
1
+ import { type PlatformEvidenceEnvelope } from '@mpgd/platform';
2
2
  import { type CommerceAuthorityCapabilities, type CommerceCatalogCapabilities, type CommerceErrorCode, type CommerceLifecycleCapabilities, type CommerceLogicalProductId, type CommerceProvider, type CommercePurchaseCapabilities, type PurchaseKind, type PurchaseSource, type StoreId, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
3
  import { type StoreSdk, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from './store-sdk.js';
4
4
  /** One catalog product sold through the store under `storeSku`. */
@@ -77,7 +77,10 @@ export interface CreateStoreCommerceProviderInput {
77
77
  readonly mapError?: StoreSdkErrorCodeMap;
78
78
  }
79
79
  export interface StoreCommerceProvider extends CommerceProvider {
80
- /** Detaches the SDK listeners; buffered events are dropped. */
80
+ /**
81
+ * Detaches the SDK listeners; buffered events are dropped. A store sheet
82
+ * still open gets its `closed` fact (reason `disposed`) first.
83
+ */
81
84
  dispose(): void;
82
85
  }
83
86
  /**
@@ -93,6 +96,17 @@ export declare const purchaseGateTimeoutMs = 3000;
93
96
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
94
97
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
95
98
  * It never grants and never sets `settlement`; the backend ledger decides.
99
+ *
100
+ * Over an SDK that declares `purchasePresentation`, the provider offers
101
+ * `presentation` and answers every `requestPurchase` call with facts keyed
102
+ * by its idempotency key: `not-started`, delivered before the call settles,
103
+ * for any call that opened no sheet (a purchase gate or account binding
104
+ * refusal, an unavailable store, a throw, or the SDK's own `not-started`);
105
+ * otherwise `open` and exactly one `closed` when the SDK reports the sheet
106
+ * gone, or after `purchasePresentationTimeoutMs` (reason `timeout`), or on
107
+ * `dispose` (reason `disposed`). A result, a pending purchase, or a purchase
108
+ * event never closes a sheet. An SDK without the declaration leaves the
109
+ * provider without `presentation`, as before.
96
110
  */
97
111
  export declare function createStoreCommerceProvider(input: CreateStoreCommerceProviderInput): StoreCommerceProvider;
98
112
  /**
@@ -1,4 +1,6 @@
1
+ import { createPurchasePresentationChannel, isPurchasePresentationReason, } from '@mpgd/platform';
1
2
  import { assertFinishTransactionInput, assertFinishTransactionResult, assertRequestPurchaseSupported, assertStoreProduct, assertStorePurchase, CommerceError, createCommerceEventBuffer, } from '@mpgd/platform/commerce';
3
+ import { purchasePresentationTimeoutMs, } from './purchase-presentation.js';
2
4
  import { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
3
5
  /** `storeCode` of a purchase the gate refused without naming its own. */
4
6
  export const purchasesPausedStoreCode = 'PURCHASES_PAUSED';
@@ -27,6 +29,17 @@ const availabilitySet = new Set([
27
29
  * declaration, builds `StorePurchase` evidence, buffers SDK events until the
28
30
  * first subscriber, and normalizes every SDK failure into a `CommerceError`.
29
31
  * It never grants and never sets `settlement`; the backend ledger decides.
32
+ *
33
+ * Over an SDK that declares `purchasePresentation`, the provider offers
34
+ * `presentation` and answers every `requestPurchase` call with facts keyed
35
+ * by its idempotency key: `not-started`, delivered before the call settles,
36
+ * for any call that opened no sheet (a purchase gate or account binding
37
+ * refusal, an unavailable store, a throw, or the SDK's own `not-started`);
38
+ * otherwise `open` and exactly one `closed` when the SDK reports the sheet
39
+ * gone, or after `purchasePresentationTimeoutMs` (reason `timeout`), or on
40
+ * `dispose` (reason `disposed`). A result, a pending purchase, or a purchase
41
+ * event never closes a sheet. An SDK without the declaration leaves the
42
+ * provider without `presentation`, as before.
30
43
  */
31
44
  export function createStoreCommerceProvider(input) {
32
45
  const { sdk } = input;
@@ -147,6 +160,77 @@ export function createStoreCommerceProvider(input) {
147
160
  });
148
161
  }),
149
162
  ];
163
+ // Presentation: SDK facts by presentation id, published by idempotency key.
164
+ const sdkPresentation = sdk.purchasePresentation;
165
+ const presentation = sdkPresentation === undefined
166
+ ? undefined
167
+ : createPurchasePresentationChannel({
168
+ onListenerError: (error) => {
169
+ // A failing listener must not keep a `closed` fact from the others.
170
+ console.warn('A purchase presentation listener failed.', error);
171
+ },
172
+ });
173
+ const flights = new Map();
174
+ let disposed = false;
175
+ const endFlight = (flight, state, reason) => {
176
+ if (flights.get(flight.presentationId) !== flight) {
177
+ return;
178
+ }
179
+ flights.delete(flight.presentationId);
180
+ if (flight.timer !== undefined) {
181
+ clearTimeout(flight.timer);
182
+ }
183
+ // A sheet that opened can only close; `not-started` after `open` would
184
+ // tell a consumer the sheet never showed.
185
+ const fact = flight.open ? 'closed' : state;
186
+ presentation?.publish({
187
+ idempotencyKey: flight.idempotencyKey,
188
+ state: fact,
189
+ ...(reason === undefined || fact !== 'closed' ? {} : { reason }),
190
+ });
191
+ };
192
+ const onPresentationFact = (event) => {
193
+ if (!isRecord(event) || typeof event.presentationId !== 'string') {
194
+ return;
195
+ }
196
+ const flight = flights.get(event.presentationId);
197
+ if (flight === undefined) {
198
+ // Another call's, an already ended call's, or an earlier session's fact.
199
+ return;
200
+ }
201
+ const armSafetyTimeout = () => {
202
+ flight.timer ??= setTimeout(() => endFlight(flight, 'closed', 'timeout'), purchasePresentationTimeoutMs);
203
+ };
204
+ switch (event.state) {
205
+ case 'open':
206
+ if (!flight.open) {
207
+ flight.open = true;
208
+ presentation?.publish({ idempotencyKey: flight.idempotencyKey, state: 'open' });
209
+ armSafetyTimeout();
210
+ }
211
+ return;
212
+ case 'unknown':
213
+ // The sheet may still open: the call's answer does not end it.
214
+ if (!flight.open && !flight.pending) {
215
+ flight.pending = true;
216
+ presentation?.publish({ idempotencyKey: flight.idempotencyKey, state: 'unknown' });
217
+ armSafetyTimeout();
218
+ }
219
+ return;
220
+ case 'closed':
221
+ endFlight(flight, 'closed', isPurchasePresentationReason(event.reason) ? event.reason : undefined);
222
+ return;
223
+ case 'not-started':
224
+ endFlight(flight, 'not-started');
225
+ return;
226
+ default:
227
+ // An unknown fact proves nothing; the safety timeout still bounds the sheet.
228
+ return;
229
+ }
230
+ };
231
+ if (sdkPresentation !== undefined) {
232
+ detachers.push(sdkPresentation.subscribe(onPresentationFact));
233
+ }
150
234
  const getAvailability = sdk.getAvailability;
151
235
  const readAvailability = async () => {
152
236
  if (getAvailability === undefined) {
@@ -185,10 +269,89 @@ export function createStoreCommerceProvider(input) {
185
269
  authority: { ...input.authority },
186
270
  availability: await readAvailability(),
187
271
  });
272
+ const requestStorePurchase = async (request, presentationId) => {
273
+ const mapping = byLogicalId.get(request.logicalId);
274
+ if (mapping === undefined) {
275
+ throw new CommerceError('ITEM_UNAVAILABLE', {
276
+ message: `Store ${store} does not sell ${request.logicalId}.`,
277
+ });
278
+ }
279
+ const capabilities = await getCapabilities();
280
+ assertAvailable(capabilities.availability, store);
281
+ assertRequestPurchaseSupported(capabilities, request, mapping.kind);
282
+ // Pre-launch refusals below are data, not throws: a `failed` result
283
+ // keeps its `storeCode` across the bridge, a thrown error does not.
284
+ if (input.purchaseGate !== undefined) {
285
+ const refusal = await askPurchaseGate(input.purchaseGate, {
286
+ store,
287
+ logicalId: mapping.logicalId,
288
+ storeSku: mapping.storeSku,
289
+ kind: mapping.kind,
290
+ source: request.source,
291
+ });
292
+ if (refusal !== undefined) {
293
+ return { outcome: 'failed', error: refusal };
294
+ }
295
+ }
296
+ let accountBinding;
297
+ try {
298
+ accountBinding = await input.accountBinding?.();
299
+ }
300
+ catch (error) {
301
+ return { outcome: 'failed', error: accountBindingFailure(error, store) };
302
+ }
303
+ if (input.authority.accountBinding === 'required' && accountBinding === undefined) {
304
+ return {
305
+ outcome: 'failed',
306
+ error: new CommerceError('ACCOUNT_BINDING_REQUIRED', {
307
+ message: `Store ${store} requires an account binding before a purchase.`,
308
+ }).toCommerceErrorInfo(),
309
+ };
310
+ }
311
+ const offerId = request.offerId ?? mapping.offerId;
312
+ if (disposed) {
313
+ // Its listeners are gone: a sheet opened now could never be reported.
314
+ throw new CommerceError('NOT_PREPARED', {
315
+ message: `Store ${store} was disposed before the purchase opened.`,
316
+ });
317
+ }
318
+ const flight = presentationId === undefined ? undefined : flights.get(presentationId);
319
+ if (flight !== undefined) {
320
+ flight.launched = true;
321
+ }
322
+ let result;
323
+ try {
324
+ result = await sdk.requestPurchase({
325
+ sku: mapping.storeSku,
326
+ ...(offerId === undefined ? {} : { offerId }),
327
+ ...(request.quantity === undefined ? {} : { quantity: request.quantity }),
328
+ ...(accountBinding === undefined ? {} : { accountBinding }),
329
+ ...(presentationId === undefined ? {} : { presentationId }),
330
+ });
331
+ }
332
+ catch (error) {
333
+ return failureOutcome(undefined, normalize(error).toCommerceErrorInfo());
334
+ }
335
+ if (!isRecord(result)) {
336
+ throw invalidRequestResult(store);
337
+ }
338
+ switch (result.outcome) {
339
+ case 'launched':
340
+ return { outcome: 'launched' };
341
+ case 'purchased':
342
+ return { outcome: 'purchased', purchase: toStorePurchase(result.purchase) };
343
+ case 'cancelled':
344
+ case 'failed':
345
+ return failureOutcome(result.outcome, normalize(result.error).toCommerceErrorInfo());
346
+ default:
347
+ throw invalidRequestResult(store);
348
+ }
349
+ };
188
350
  const finishTransaction = sdk.finishTransaction;
189
351
  const restorePurchases = sdk.restorePurchases;
190
352
  const provider = {
191
353
  store,
354
+ ...(presentation === undefined ? {} : { presentation: presentation.presentation }),
192
355
  // `retry` is forwarded to the SDK below.
193
356
  ...(finishTransaction === undefined ? {} : { acceptsFinishRetry: true }),
194
357
  getCapabilities,
@@ -251,70 +414,41 @@ export function createStoreCommerceProvider(input) {
251
414
  return products;
252
415
  },
253
416
  async requestPurchase(request) {
254
- const mapping = byLogicalId.get(request.logicalId);
255
- if (mapping === undefined) {
256
- throw new CommerceError('ITEM_UNAVAILABLE', {
257
- message: `Store ${store} does not sell ${request.logicalId}.`,
258
- });
417
+ if (presentation === undefined) {
418
+ return requestStorePurchase(request, undefined);
259
419
  }
260
- const capabilities = await getCapabilities();
261
- assertAvailable(capabilities.availability, store);
262
- assertRequestPurchaseSupported(capabilities, request, mapping.kind);
263
- // Pre-launch refusals below are data, not throws: a `failed` result
264
- // keeps its `storeCode` across the bridge, a thrown error does not.
265
- if (input.purchaseGate !== undefined) {
266
- const refusal = await askPurchaseGate(input.purchaseGate, {
267
- store,
268
- logicalId: mapping.logicalId,
269
- storeSku: mapping.storeSku,
270
- kind: mapping.kind,
271
- source: request.source,
272
- });
273
- if (refusal !== undefined) {
274
- return { outcome: 'failed', error: refusal };
275
- }
276
- }
277
- let accountBinding;
278
- try {
279
- accountBinding = await input.accountBinding?.();
280
- }
281
- catch (error) {
282
- return { outcome: 'failed', error: accountBindingFailure(error, store) };
283
- }
284
- if (input.authority.accountBinding === 'required' && accountBinding === undefined) {
420
+ if ([...flights.values()].some((live) => live.idempotencyKey === request.idempotencyKey)) {
421
+ // Facts are keyed by idempotency key: a second sheet for a key whose
422
+ // sheet is still in flight could not be told apart from the first,
423
+ // and its end would end the first one's pause. Answered as data and
424
+ // without a fact; the first call's facts stay the key's truth.
285
425
  return {
286
426
  outcome: 'failed',
287
- error: new CommerceError('ACCOUNT_BINDING_REQUIRED', {
288
- message: `Store ${store} requires an account binding before a purchase.`,
427
+ error: new CommerceError('IN_PROGRESS', {
428
+ message: `A purchase for ${request.idempotencyKey} is still in flight.`,
289
429
  }).toCommerceErrorInfo(),
290
430
  };
291
431
  }
292
- const offerId = request.offerId ?? mapping.offerId;
293
- let result;
432
+ const flight = {
433
+ idempotencyKey: request.idempotencyKey,
434
+ presentationId: crypto.randomUUID(),
435
+ open: false,
436
+ pending: false,
437
+ launched: false,
438
+ };
439
+ flights.set(flight.presentationId, flight);
294
440
  try {
295
- result = await sdk.requestPurchase({
296
- sku: mapping.storeSku,
297
- ...(offerId === undefined ? {} : { offerId }),
298
- ...(request.quantity === undefined ? {} : { quantity: request.quantity }),
299
- ...(accountBinding === undefined ? {} : { accountBinding }),
300
- });
301
- }
302
- catch (error) {
303
- return failureOutcome(undefined, normalize(error).toCommerceErrorInfo());
441
+ return await requestStorePurchase(request, flight.presentationId);
304
442
  }
305
- if (!isRecord(result)) {
306
- throw invalidRequestResult(store);
307
- }
308
- switch (result.outcome) {
309
- case 'launched':
310
- return { outcome: 'launched' };
311
- case 'purchased':
312
- return { outcome: 'purchased', purchase: toStorePurchase(result.purchase) };
313
- case 'cancelled':
314
- case 'failed':
315
- return failureOutcome(result.outcome, normalize(result.error).toCommerceErrorInfo());
316
- default:
317
- throw invalidRequestResult(store);
443
+ finally {
444
+ // Settled without an `open` fact: no sheet opened. Published before
445
+ // the caller sees the answer, so a consumer that frees a retryable
446
+ // refusal's key (the reconciler) reads it with the result. A call
447
+ // whose SDK reported `unknown` keeps waiting for the native verdict
448
+ // (bounded by the safety timeout, which reports `closed`).
449
+ if (!flight.open && !flight.pending) {
450
+ endFlight(flight, 'not-started');
451
+ }
318
452
  }
319
453
  },
320
454
  async getAvailablePurchases(request = {}) {
@@ -400,6 +534,15 @@ export function createStoreCommerceProvider(input) {
400
534
  for (const detach of detachers.splice(0, detachers.length)) {
401
535
  detach();
402
536
  }
537
+ // No SDK fact arrives any more, and the listeners go below: every call
538
+ // in flight gets its terminal fact now. A sheet that is (or may be)
539
+ // up is reported closed; a call still before its sheet is not-started,
540
+ // and it will not open one.
541
+ disposed = true;
542
+ for (const flight of [...flights.values()]) {
543
+ endFlight(flight, flight.open || flight.pending || flight.launched ? 'closed' : 'not-started', 'disposed');
544
+ }
545
+ presentation?.clear();
403
546
  listeners.clear();
404
547
  buffer.drain(() => undefined);
405
548
  },
@@ -1,4 +1,4 @@
1
- import type { PlatformProviderAvailability } from '@mpgd/platform';
1
+ import type { PlatformProviderAvailability, PlatformPurchasePresentationReason } from '@mpgd/platform';
2
2
  import { CommerceError, type CommerceErrorCode, type CommerceFinishOwner, type CommercePurchaseCapabilities, type FetchProductsType, type PurchaseKind, type StoreId, type StoreOffer, type StorePrice, type StorePurchaseAccountBinding, type StorePurchaseState } from '@mpgd/platform/commerce';
3
3
  /**
4
4
  * In-process surface a store plugin exposes to `createStoreCommerceProvider`.
@@ -26,6 +26,39 @@ export interface StoreSdk {
26
26
  addListener<TEvent extends StoreSdkEventName>(eventName: TEvent, listener: (event: StoreSdkEventOf<TEvent>) => void): () => void;
27
27
  /** Current readiness; omitted means the SDK is in-process and ready. */
28
28
  getAvailability?(): Promise<PlatformProviderAvailability>;
29
+ /**
30
+ * Native facts about the store's purchase sheet. Present only when the SDK
31
+ * (and the native code under it) reports them for every `requestPurchase`
32
+ * that carries a `presentationId`; `createStoreCommerceProvider` then
33
+ * offers `CommerceProvider.presentation`. An SDK without it keeps the
34
+ * provider without presentation, as before.
35
+ */
36
+ readonly purchasePresentation?: StoreSdkPurchasePresentation;
37
+ }
38
+ /**
39
+ * The purchase sheet facts of one SDK. For a `requestPurchase` call with a
40
+ * `presentationId` the SDK reports, by that id: `open` once the store sheet
41
+ * is up, then exactly one `closed` when it is gone (which may come after the
42
+ * call settled), or `not-started` when no sheet opened. A fact the call
43
+ * settles without (the sheet never opened) means `not-started`, so an SDK
44
+ * reports `open` before it settles whenever a sheet opened, and `unknown`
45
+ * before it settles when it cannot tell yet (a terminal fact then follows). A transaction
46
+ * update, a pending purchase, or a result is not a `closed` fact.
47
+ */
48
+ export interface StoreSdkPurchasePresentation {
49
+ subscribe(listener: (event: StoreSdkPurchasePresentationEvent) => void): () => void;
50
+ }
51
+ export interface StoreSdkPurchasePresentationEvent {
52
+ readonly presentationId: string;
53
+ /**
54
+ * `unknown`: the call is answering while the native flow still runs and
55
+ * nothing was observed yet; the sheet may still open, and a terminal fact
56
+ * (`closed` or `not-started`) follows. The provider keeps the call waiting
57
+ * for it instead of reading the answer as a sheet that never opened.
58
+ */
59
+ readonly state: 'open' | 'closed' | 'not-started' | 'unknown';
60
+ /** On a `closed` fact no native observation proved, why; see `PlatformPurchasePresentationReason`. */
61
+ readonly reason?: PlatformPurchasePresentationReason;
29
62
  }
30
63
  export interface StoreSdkCapabilities {
31
64
  readonly purchase: CommercePurchaseCapabilities;
@@ -55,6 +88,11 @@ export interface StoreSdkRequestPurchaseInput {
55
88
  readonly offerId?: string;
56
89
  readonly quantity?: number;
57
90
  readonly accountBinding?: StorePurchaseAccountBinding;
91
+ /**
92
+ * Correlates this call's `purchasePresentation` facts. Sent only to an SDK
93
+ * that declares `purchasePresentation`; unique per call.
94
+ */
95
+ readonly presentationId?: string;
58
96
  }
59
97
  export type StoreSdkRequestPurchaseResult = {
60
98
  readonly outcome: 'launched';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-capacitor",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Capacitor platform adapter for mpgd Android and iOS shells.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,11 +51,11 @@
51
51
  ],
52
52
  "dependencies": {
53
53
  "@capacitor/app": "8.1.1",
54
- "@mpgd/capacitor-game-services": "0.9.4",
55
- "@mpgd/game-services": "0.31.0",
56
- "@mpgd/platform": "0.33.0",
57
- "@mpgd/bridge": "0.17.0",
58
- "@mpgd/target-config": "0.24.4"
54
+ "@mpgd/capacitor-game-services": "0.9.5",
55
+ "@mpgd/game-services": "0.32.0",
56
+ "@mpgd/target-config": "0.24.5",
57
+ "@mpgd/platform": "0.34.0",
58
+ "@mpgd/bridge": "0.18.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@capacitor-community/admob": "^8.1.0",
@@ -92,7 +92,7 @@
92
92
  "ttsc": "0.34.2",
93
93
  "typescript": "7.0.2",
94
94
  "vitest": "^4.0.0",
95
- "@mpgd/game-runtime": "0.8.10"
95
+ "@mpgd/game-runtime": "0.9.0"
96
96
  },
97
97
  "main": "./dist/index.js",
98
98
  "types": "./dist/index.d.ts",