@mpgd/adapter-capacitor 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -3
- package/dist/admob.d.ts +26 -0
- package/dist/admob.js +288 -0
- package/dist/app-store-server.d.ts +12 -0
- package/dist/app-store-server.js +119 -0
- package/dist/providers.js +4 -1
- package/package.json +26 -6
package/README.md
CHANGED
|
@@ -52,14 +52,107 @@ provider never silently falls back for purchase, ad, or native leaderboard
|
|
|
52
52
|
operations; an explicit remote leaderboard route is a separate path. Errors
|
|
53
53
|
retain a stable code and retry hint. The
|
|
54
54
|
provider bridge must return a method-shaped response. In particular, an ad
|
|
55
|
-
`rewardGranted: true` result requires a backend ledger entry.
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
`rewardGranted: true` result requires a backend ledger entry. The AdMob SDK
|
|
56
|
+
provider always returns `rewardGranted: false`, even after the SDK fires a
|
|
57
|
+
reward callback. Its explicitly non-authoritative evidence envelope only lets
|
|
58
|
+
the game-services client request a grant; the server must independently verify
|
|
59
|
+
a signed SSV callback before writing its ledger. A native callback alone is
|
|
60
|
+
never a grant. Game-specific product, consent, entitlement, and
|
|
61
|
+
identity policy belongs to the consuming game and its backend.
|
|
58
62
|
|
|
59
63
|
The source tests and installed-tarball consumer validate composition, types,
|
|
60
64
|
error paths, and fallback behavior. They do not establish that any optional
|
|
61
65
|
SDK works on a physical device or that a store/ad setup is release-ready.
|
|
62
66
|
|
|
67
|
+
## App Store server-side transaction signatures
|
|
68
|
+
|
|
69
|
+
For an iOS game backend, install the optional
|
|
70
|
+
`@apple/app-store-server-library@3.1.0` peer and import
|
|
71
|
+
`createAppleSignedTransactionVerifier` from
|
|
72
|
+
`@mpgd/adapter-capacitor/app-store-server`. Supply DER-encoded Apple root
|
|
73
|
+
certificates from the [Apple PKI](https://www.apple.com/certificateauthority/),
|
|
74
|
+
the game's bundle ID, environment, and production App Apple ID. Use the
|
|
75
|
+
result as `signedTransactionVerifier` in
|
|
76
|
+
`createAppStoreGameServicesEvidenceVerifier`. Keep App Store API credentials,
|
|
77
|
+
trust roots, and this verifier on the backend, not in the Capacitor game.
|
|
78
|
+
|
|
79
|
+
The server fetches the transaction from Apple's API, verifies its JWS, checks
|
|
80
|
+
the product, bundle, environment, account token and transaction ID, then
|
|
81
|
+
writes the game-services grant. A StoreKit device callback is only provisional
|
|
82
|
+
evidence; it never grants a product. Retryable certificate-status failures
|
|
83
|
+
remain pending, while invalid signatures are rejected. Mock verification does
|
|
84
|
+
not prove a real App Store purchase or device delivery.
|
|
85
|
+
|
|
86
|
+
## Opt-in AdMob rewarded ads
|
|
87
|
+
|
|
88
|
+
Install `@capacitor-community/admob@8.1.0` in the game-owned Capacitor project,
|
|
89
|
+
configure the distinct AdMob **application ID** in AndroidManifest.xml and
|
|
90
|
+
Info.plist, and run `cap sync`. Use the [plugin installation and native setup
|
|
91
|
+
guide](https://github.com/capacitor-community/admob#installation) for the exact
|
|
92
|
+
Android and iOS keys, SKAdNetwork list, and tracking disclosure. The optional
|
|
93
|
+
`@mpgd/adapter-capacitor/admob` subpath does not load from the base adapter;
|
|
94
|
+
games that do not install AdMob retain their existing SDK-free gateway.
|
|
95
|
+
The community plugin currently targets Google Mobile Ads Android 25.4.x and
|
|
96
|
+
iOS 13.6.0. Its Android default uses a dynamic patch selector; set
|
|
97
|
+
`playServicesAdsVersion = '25.4.0'` in the game-owned `variables.gradle` to
|
|
98
|
+
keep release builds reproducible until the plugin adopts a newer tested SDK.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
|
|
102
|
+
import { createCapacitorAdMobRewardedProvider } from '@mpgd/adapter-capacitor/admob';
|
|
103
|
+
|
|
104
|
+
const admob = createCapacitorAdMobRewardedProvider({
|
|
105
|
+
adUnits: { CONTINUE_AFTER_FAIL: 'ca-app-pub-1234567890123456/1234567890' },
|
|
106
|
+
getPlayerId: () => authenticatedPlayerId,
|
|
107
|
+
});
|
|
108
|
+
const gateway = createCapacitorPlatformGateway({
|
|
109
|
+
target: 'android', appVersion: '1.0.0', buildId: 'game-build',
|
|
110
|
+
providers: [admob],
|
|
111
|
+
});
|
|
112
|
+
const consentAllowsAds = await admob.requestConsent();
|
|
113
|
+
if (!consentAllowsAds) {
|
|
114
|
+
// Keep the ad action disabled; never bypass the privacy choice.
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use target-specific ad unit IDs for Android and iOS; the logical placement
|
|
119
|
+
ID must match the game catalog and backend config. `getPlayerId()` must return
|
|
120
|
+
the same authenticated player ID as `GameServicesClient`, not a device ID or
|
|
121
|
+
an unverified local guest label. Re-check consent when the game resumes or
|
|
122
|
+
the privacy choice changes, and expose `admob.showPrivacyOptions()` from game
|
|
123
|
+
settings where required. `getCapabilities()` reports `action-required` until
|
|
124
|
+
consent is ready and `temporarily-unavailable` during a show. A timed-out
|
|
125
|
+
native load or presentation reports non-retryable `action-required` and
|
|
126
|
+
quarantines that SDK instance to avoid overlapping shows
|
|
127
|
+
while its state is unknown. Recreating the Kit provider with the same SDK does
|
|
128
|
+
not clear the quarantine; restart the native app before trying again. Do not
|
|
129
|
+
show rewarded ads through another direct SDK caller concurrently: the plugin's
|
|
130
|
+
reward and dismissal events are global, not tagged with a Kit operation ID.
|
|
131
|
+
|
|
132
|
+
`gateway.ads.preload()` validates the logical placement but deliberately does
|
|
133
|
+
not load an ad: this SDK attaches SSV data at load time, whereas the Kit's
|
|
134
|
+
operation ID exists only at show time. `gateway.ads.showRewarded()` therefore
|
|
135
|
+
loads a fresh ad with SSV `userId` and encoded player/placement/operation
|
|
136
|
+
binding, waits for native dismissal, then returns SDK reward evidence. Call
|
|
137
|
+
`GameServicesClient.claimRewardedAd()` rather than award currency from that
|
|
138
|
+
result; `rewardGranted` remains `false` in that provisional platform result.
|
|
139
|
+
If dismissal arrives without an observed reward, the provider returns
|
|
140
|
+
`pending` with the same non-authoritative binding. The backend can then look
|
|
141
|
+
for a later signed SSV callback without replaying the ad UI; a genuinely
|
|
142
|
+
skipped ad may remain pending until game-owned support or expiry policy
|
|
143
|
+
settles it. Mediation and asynchronous bridge delivery have no safe universal
|
|
144
|
+
250 ms reward-event cutoff.
|
|
145
|
+
The receiver and ledger described in [AdMob SSV](../../docs/ADMOB_SSV.md)
|
|
146
|
+
remain mandatory. A late SSV callback can leave the claim pending for
|
|
147
|
+
reconciliation. `isTesting: true` is for SDK test ads only; [the plugin notes
|
|
148
|
+
that test ads do not invoke the SSV endpoint](https://github.com/capacitor-community/admob/blob/main/docs/rewarded.md#server-side-verification),
|
|
149
|
+
so they cannot prove a production grant.
|
|
150
|
+
|
|
151
|
+
The Kit tests cover consent, per-operation binding, reward versus dismissal,
|
|
152
|
+
and bridge contracts with an injected SDK. They are not evidence of a real
|
|
153
|
+
AdMob account, live callback delivery, native device behavior, or store policy
|
|
154
|
+
approval.
|
|
155
|
+
|
|
63
156
|
`gateway.secureCredentials` uses dedicated native Keychain/Keystore methods for
|
|
64
157
|
opaque session credentials. Its absence or a native error must not be hidden by
|
|
65
158
|
writing the secret into `gateway.storage`. Keys are bounded, values are
|
package/dist/admob.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AdMobPlugin } from '@capacitor-community/admob';
|
|
2
|
+
import type { CapacitorServiceProvider } from './providers.js';
|
|
3
|
+
/** Only the AdMob subpath imports the optional native SDK. */
|
|
4
|
+
export type RewardedAdMobSdk = Pick<AdMobPlugin, 'initialize' | 'requestConsentInfo' | 'showConsentForm' | 'showPrivacyOptionsForm' | 'prepareRewardVideoAd' | 'showRewardVideoAd' | 'addListener'>;
|
|
5
|
+
export interface CreateCapacitorAdMobRewardedProviderInput {
|
|
6
|
+
/** Logical placement IDs mapped to target-specific, game-owned AdMob ad units. */
|
|
7
|
+
readonly adUnits: Readonly<Record<string, string>>;
|
|
8
|
+
/** Must resolve to the same authenticated player ID used by GameServicesClient. */
|
|
9
|
+
readonly getPlayerId: () => Promise<string> | string;
|
|
10
|
+
/** Test ads do not send Google SSV callbacks; server grants remain pending. */
|
|
11
|
+
readonly isTesting?: boolean;
|
|
12
|
+
readonly sdk?: RewardedAdMobSdk;
|
|
13
|
+
readonly showTimeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CapacitorAdMobRewardedProvider extends CapacitorServiceProvider {
|
|
16
|
+
/** Present UMP when required. Call before the gateway advertises ad readiness. */
|
|
17
|
+
requestConsent(): Promise<boolean>;
|
|
18
|
+
/** Game settings should expose this when the privacy message requires it. */
|
|
19
|
+
showPrivacyOptions(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Opt-in rewarded-only provider. The SDK's SSV options are attached when an
|
|
23
|
+
* operation is shown, never to an earlier unbound preload. A local reward
|
|
24
|
+
* callback is only a signal to ask the backend; it is not a ledger grant.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createCapacitorAdMobRewardedProvider(input: CreateCapacitorAdMobRewardedProviderInput): CapacitorAdMobRewardedProvider;
|
package/dist/admob.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { AdMob, AdmobConsentStatus, RewardAdPluginEvents, } from '@capacitor-community/admob';
|
|
2
|
+
import { admobClientRewardEvidenceSchema } from '@mpgd/game-services/admob-client-reward';
|
|
3
|
+
import { admobSsvMaximumBindingFieldLength, encodeAdMobSsvCustomData, } from '@mpgd/game-services/admob-ssv';
|
|
4
|
+
const unitPattern = /^ca-app-pub-\d+\/\d+$/u;
|
|
5
|
+
const defaultShowTimeoutMs = 180_000;
|
|
6
|
+
const loadTimeoutMs = 30_000;
|
|
7
|
+
const preflightTimeoutMs = 10_000;
|
|
8
|
+
const listenerCleanupTimeoutMs = 1_000;
|
|
9
|
+
const maximumCustomDataBytes = 1_024;
|
|
10
|
+
// The native plugin's rewarded event stream and prepared-ad table are global.
|
|
11
|
+
// Coordinate Kit providers sharing one SDK instance, even across gateways.
|
|
12
|
+
const activeSdk = new WeakSet();
|
|
13
|
+
const uncertainSdk = new WeakSet();
|
|
14
|
+
function response(input, result) {
|
|
15
|
+
return { id: input.id, ok: true, data: result };
|
|
16
|
+
}
|
|
17
|
+
function failure(input, code, retryable = false) {
|
|
18
|
+
return { id: input.id, ok: false, error: { code, message: code, retryable } };
|
|
19
|
+
}
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function isRewardItem(value) {
|
|
24
|
+
return isRecord(value)
|
|
25
|
+
&& typeof value.type === 'string'
|
|
26
|
+
&& value.type.length > 0
|
|
27
|
+
&& typeof value.amount === 'number'
|
|
28
|
+
&& Number.isFinite(value.amount)
|
|
29
|
+
&& value.amount > 0;
|
|
30
|
+
}
|
|
31
|
+
class PreflightTimeoutError extends Error {
|
|
32
|
+
}
|
|
33
|
+
async function withTimeout(promise, milliseconds, onLate) {
|
|
34
|
+
let timer;
|
|
35
|
+
let expired = false;
|
|
36
|
+
const watched = promise.then((value) => {
|
|
37
|
+
if (expired && onLate !== undefined) {
|
|
38
|
+
void Promise.resolve().then(() => onLate(value)).catch(() => undefined);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
return await Promise.race([
|
|
44
|
+
watched,
|
|
45
|
+
new Promise((_resolve, reject) => {
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
expired = true;
|
|
48
|
+
reject(new PreflightTimeoutError('AdMob preflight timed out.'));
|
|
49
|
+
}, milliseconds);
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (timer !== undefined) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Opt-in rewarded-only provider. The SDK's SSV options are attached when an
|
|
61
|
+
* operation is shown, never to an earlier unbound preload. A local reward
|
|
62
|
+
* callback is only a signal to ask the backend; it is not a ledger grant.
|
|
63
|
+
*/
|
|
64
|
+
export function createCapacitorAdMobRewardedProvider(input) {
|
|
65
|
+
const sdk = input.sdk ?? AdMob;
|
|
66
|
+
const showTimeoutMs = input.showTimeoutMs ?? defaultShowTimeoutMs;
|
|
67
|
+
if (!Number.isSafeInteger(showTimeoutMs) || showTimeoutMs < 1_000 || showTimeoutMs > 300_000) {
|
|
68
|
+
throw new Error('AdMob show timeout must be between 1 and 300 seconds.');
|
|
69
|
+
}
|
|
70
|
+
const adUnits = new Map();
|
|
71
|
+
for (const [placementId, unit] of Object.entries(input.adUnits)) {
|
|
72
|
+
if (placementId.trim() === '' || !unitPattern.test(unit)) {
|
|
73
|
+
throw new Error('AdMob rewarded placements require non-empty IDs and production-format ad units.');
|
|
74
|
+
}
|
|
75
|
+
adUnits.set(placementId, unit);
|
|
76
|
+
}
|
|
77
|
+
if (adUnits.size === 0) {
|
|
78
|
+
throw new Error('AdMob rewarded provider requires at least one ad unit.');
|
|
79
|
+
}
|
|
80
|
+
let initialized;
|
|
81
|
+
let consentTask;
|
|
82
|
+
let canRequestAds = false;
|
|
83
|
+
let privacyOptionsRequired = false;
|
|
84
|
+
const initialize = async () => {
|
|
85
|
+
if (initialized === undefined) {
|
|
86
|
+
initialized = sdk.initialize().catch((error) => {
|
|
87
|
+
initialized = undefined;
|
|
88
|
+
throw error;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
await initialized;
|
|
92
|
+
};
|
|
93
|
+
const requestConsent = () => {
|
|
94
|
+
if (consentTask === undefined) {
|
|
95
|
+
consentTask = (async () => {
|
|
96
|
+
canRequestAds = false;
|
|
97
|
+
await initialize();
|
|
98
|
+
let info = await sdk.requestConsentInfo();
|
|
99
|
+
if (info.status === AdmobConsentStatus.REQUIRED && info.isConsentFormAvailable) {
|
|
100
|
+
info = await sdk.showConsentForm();
|
|
101
|
+
}
|
|
102
|
+
canRequestAds = info.canRequestAds === true;
|
|
103
|
+
privacyOptionsRequired = info.privacyOptionsRequirementStatus === 'REQUIRED';
|
|
104
|
+
return canRequestAds;
|
|
105
|
+
})().finally(() => { consentTask = undefined; });
|
|
106
|
+
}
|
|
107
|
+
return consentTask;
|
|
108
|
+
};
|
|
109
|
+
const showRewarded = async (placementId, idempotencyKey) => {
|
|
110
|
+
const adId = adUnits.get(placementId);
|
|
111
|
+
if (adId === undefined || !canRequestAds || activeSdk.has(sdk) || uncertainSdk.has(sdk)) {
|
|
112
|
+
return { status: 'unavailable', rewardGranted: false };
|
|
113
|
+
}
|
|
114
|
+
if (idempotencyKey.trim() === ''
|
|
115
|
+
|| idempotencyKey.length > admobSsvMaximumBindingFieldLength
|
|
116
|
+
|| placementId.length > admobSsvMaximumBindingFieldLength) {
|
|
117
|
+
return { status: 'failed', rewardGranted: false };
|
|
118
|
+
}
|
|
119
|
+
activeSdk.add(sdk);
|
|
120
|
+
const handles = [];
|
|
121
|
+
let rewardEarned = false;
|
|
122
|
+
let timer;
|
|
123
|
+
let loadTimer;
|
|
124
|
+
let loadTimedOut = false;
|
|
125
|
+
let nativePreflightStarted = false;
|
|
126
|
+
try {
|
|
127
|
+
const playerId = await withTimeout(Promise.resolve().then(() => input.getPlayerId()), preflightTimeoutMs);
|
|
128
|
+
if (typeof playerId !== 'string' || playerId.trim() === ''
|
|
129
|
+
|| playerId.length > admobSsvMaximumBindingFieldLength) {
|
|
130
|
+
return { status: 'failed', rewardGranted: false };
|
|
131
|
+
}
|
|
132
|
+
const customData = encodeAdMobSsvCustomData({ playerId, placementId, idempotencyKey });
|
|
133
|
+
if (new TextEncoder().encode(customData).byteLength > maximumCustomDataBytes) {
|
|
134
|
+
return { status: 'failed', rewardGranted: false };
|
|
135
|
+
}
|
|
136
|
+
// This plugin resolves showRewardVideoAd on reward, but on Android a
|
|
137
|
+
// dismissal without reward leaves that promise unresolved. Observe the
|
|
138
|
+
// terminal native events separately, and hold lifecycle through close.
|
|
139
|
+
let finish;
|
|
140
|
+
const terminal = new Promise((resolve) => {
|
|
141
|
+
finish = resolve;
|
|
142
|
+
});
|
|
143
|
+
nativePreflightStarted = true;
|
|
144
|
+
handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Rewarded, (reward) => {
|
|
145
|
+
if (isRewardItem(reward)) {
|
|
146
|
+
rewardEarned = true;
|
|
147
|
+
}
|
|
148
|
+
}), preflightTimeoutMs, (handle) => handle.remove()));
|
|
149
|
+
handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Dismissed, () => {
|
|
150
|
+
// Let a same-turn Rewarded event settle before reading the flag.
|
|
151
|
+
queueMicrotask(() => finish('dismissed'));
|
|
152
|
+
}), preflightTimeoutMs, (handle) => handle.remove()));
|
|
153
|
+
handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.FailedToShow, () => {
|
|
154
|
+
finish('failed');
|
|
155
|
+
}), preflightTimeoutMs, (handle) => handle.remove()));
|
|
156
|
+
const loaded = await Promise.race([
|
|
157
|
+
sdk.prepareRewardVideoAd({
|
|
158
|
+
adId,
|
|
159
|
+
...(input.isTesting === undefined ? {} : { isTesting: input.isTesting }),
|
|
160
|
+
ssv: { userId: playerId, customData },
|
|
161
|
+
}),
|
|
162
|
+
new Promise((_resolve, reject) => {
|
|
163
|
+
loadTimer = setTimeout(() => {
|
|
164
|
+
loadTimedOut = true;
|
|
165
|
+
reject(new Error('AdMob load timed out.'));
|
|
166
|
+
}, loadTimeoutMs);
|
|
167
|
+
}),
|
|
168
|
+
]);
|
|
169
|
+
if (loadTimer !== undefined) {
|
|
170
|
+
clearTimeout(loadTimer);
|
|
171
|
+
loadTimer = undefined;
|
|
172
|
+
}
|
|
173
|
+
if (typeof loaded.adUnitId !== 'string' || loaded.adUnitId.length === 0) {
|
|
174
|
+
return { status: 'failed', rewardGranted: false };
|
|
175
|
+
}
|
|
176
|
+
timer = setTimeout(() => finish('timeout'), showTimeoutMs);
|
|
177
|
+
// Always observe rejection, even if a Dismissed event wins the race.
|
|
178
|
+
const showResult = sdk.showRewardVideoAd({ adId: loaded.adUnitId });
|
|
179
|
+
const onReward = (reward) => {
|
|
180
|
+
if (isRewardItem(reward)) {
|
|
181
|
+
rewardEarned = true;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const onShowError = () => finish('failed');
|
|
185
|
+
void showResult.then(onReward, onShowError);
|
|
186
|
+
const outcome = await terminal;
|
|
187
|
+
if (outcome === 'timeout') {
|
|
188
|
+
// Native presentation state is unknown: do not allow another show.
|
|
189
|
+
uncertainSdk.add(sdk);
|
|
190
|
+
}
|
|
191
|
+
let status = 'failed';
|
|
192
|
+
if (rewardEarned) {
|
|
193
|
+
status = 'completed';
|
|
194
|
+
}
|
|
195
|
+
else if (outcome === 'dismissed') {
|
|
196
|
+
// Mediation may deliver reward after dismissal. Only signed SSV can
|
|
197
|
+
// later distinguish a true skip from an earned reward.
|
|
198
|
+
status = 'pending';
|
|
199
|
+
}
|
|
200
|
+
else if (outcome === 'timeout') {
|
|
201
|
+
status = 'pending';
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
status,
|
|
205
|
+
rewardGranted: false,
|
|
206
|
+
...(status === 'completed' || status === 'pending' ? { evidence: {
|
|
207
|
+
schema: admobClientRewardEvidenceSchema,
|
|
208
|
+
payload: { adUnitId: adId },
|
|
209
|
+
} } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const uncertain = loadTimedOut
|
|
214
|
+
|| (nativePreflightStarted && error instanceof PreflightTimeoutError);
|
|
215
|
+
if (uncertain) {
|
|
216
|
+
uncertainSdk.add(sdk);
|
|
217
|
+
}
|
|
218
|
+
return { status: uncertain ? 'pending' : 'failed', rewardGranted: false };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
if (loadTimer !== undefined) {
|
|
222
|
+
clearTimeout(loadTimer);
|
|
223
|
+
}
|
|
224
|
+
if (timer !== undefined) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const removals = handles.map((handle) => {
|
|
229
|
+
const removal = Promise.resolve().then(() => handle.remove());
|
|
230
|
+
return withTimeout(removal, listenerCleanupTimeoutMs);
|
|
231
|
+
});
|
|
232
|
+
const settled = await Promise.allSettled(removals);
|
|
233
|
+
if (settled.some((result) => result.status === 'rejected')) {
|
|
234
|
+
uncertainSdk.add(sdk);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
activeSdk.delete(sdk);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
id: 'admob-rewarded',
|
|
244
|
+
features: ['rewardedAds'],
|
|
245
|
+
methods: ['ads.preload', 'ads.showRewarded'],
|
|
246
|
+
async getAvailability() {
|
|
247
|
+
if (uncertainSdk.has(sdk) || !canRequestAds) {
|
|
248
|
+
return { rewardedAds: 'action-required' };
|
|
249
|
+
}
|
|
250
|
+
return { rewardedAds: activeSdk.has(sdk) ? 'temporarily-unavailable' : 'available' };
|
|
251
|
+
},
|
|
252
|
+
requestConsent,
|
|
253
|
+
async showPrivacyOptions() {
|
|
254
|
+
if (!privacyOptionsRequired) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
canRequestAds = false;
|
|
258
|
+
await initialize();
|
|
259
|
+
try {
|
|
260
|
+
await sdk.showPrivacyOptionsForm();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await requestConsent();
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
bridge: {
|
|
267
|
+
async request(request) {
|
|
268
|
+
if (request.method === 'ads.preload') {
|
|
269
|
+
const payload = request.payload;
|
|
270
|
+
if (!isRecord(payload)
|
|
271
|
+
|| (payload.format !== undefined && payload.format !== 'rewarded')
|
|
272
|
+
|| typeof payload.placementId !== 'string'
|
|
273
|
+
|| !adUnits.has(payload.placementId)) {
|
|
274
|
+
return failure(request, 'ADMOB_PLACEMENT_INVALID');
|
|
275
|
+
}
|
|
276
|
+
// Preloading an unbound ad would omit the operation's SSV key.
|
|
277
|
+
return response(request, undefined);
|
|
278
|
+
}
|
|
279
|
+
if (request.method !== 'ads.showRewarded' || !isRecord(request.payload)
|
|
280
|
+
|| typeof request.payload.placementId !== 'string'
|
|
281
|
+
|| typeof request.payload.idempotencyKey !== 'string') {
|
|
282
|
+
return failure(request, 'ADMOB_REQUEST_INVALID');
|
|
283
|
+
}
|
|
284
|
+
return response(request, await showRewarded(request.payload.placementId, request.payload.idempotencyKey));
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AppStoreEnvironment, type AppStoreSignedTransactionVerifier } from '@mpgd/game-services/app-store-verifier';
|
|
2
|
+
export interface CreateAppleSignedTransactionVerifierOptions {
|
|
3
|
+
/** DER-encoded Apple root certificates, supplied by the server operator. */
|
|
4
|
+
readonly rootCertificates: readonly Uint8Array[];
|
|
5
|
+
readonly environment: AppStoreEnvironment;
|
|
6
|
+
readonly bundleId: string;
|
|
7
|
+
/** Required by Apple's verifier in production. */
|
|
8
|
+
readonly appAppleId?: number;
|
|
9
|
+
readonly enableOnlineChecks?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
12
|
+
export declare function createAppleSignedTransactionVerifier(options: CreateAppleSignedTransactionVerifierOptions): AppStoreSignedTransactionVerifier;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Environment, SignedDataVerifier, VerificationException, VerificationStatus, } from '@apple/app-store-server-library';
|
|
2
|
+
import { AppStoreDependencyUnavailableError, } from '@mpgd/game-services/app-store-verifier';
|
|
3
|
+
/** Verify the App Store JWS before exposing any decoded purchase fields. */
|
|
4
|
+
export function createAppleSignedTransactionVerifier(options) {
|
|
5
|
+
if (!Array.isArray(options.rootCertificates)
|
|
6
|
+
|| options.rootCertificates.length === 0
|
|
7
|
+
|| options.rootCertificates.some((certificate) => !(certificate instanceof Uint8Array)
|
|
8
|
+
|| certificate.length === 0)) {
|
|
9
|
+
throw new TypeError('Apple root certificates are required.');
|
|
10
|
+
}
|
|
11
|
+
if (options.environment !== 'Production' && options.environment !== 'Sandbox') {
|
|
12
|
+
throw new TypeError('App Store environment must be Production or Sandbox.');
|
|
13
|
+
}
|
|
14
|
+
if (options.bundleId.trim() === '') {
|
|
15
|
+
throw new TypeError('App Store bundle ID is required.');
|
|
16
|
+
}
|
|
17
|
+
if (options.environment === 'Production'
|
|
18
|
+
&& (!Number.isSafeInteger(options.appAppleId) || (options.appAppleId ?? 0) <= 0)) {
|
|
19
|
+
throw new TypeError('Production App Store verification requires appAppleId.');
|
|
20
|
+
}
|
|
21
|
+
if (options.environment === 'Production' && options.enableOnlineChecks === false) {
|
|
22
|
+
throw new TypeError('Production App Store verification requires online certificate checks.');
|
|
23
|
+
}
|
|
24
|
+
const verifier = new SignedDataVerifier(options.rootCertificates.map((certificate) => Buffer.from(certificate)), options.enableOnlineChecks ?? true, options.environment === 'Production' ? Environment.PRODUCTION : Environment.SANDBOX, options.bundleId, options.appAppleId);
|
|
25
|
+
return {
|
|
26
|
+
async verifyAndDecode(input) {
|
|
27
|
+
if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
|
|
28
|
+
return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
|
|
29
|
+
}
|
|
30
|
+
if (typeof input.signedTransaction !== 'string'
|
|
31
|
+
|| input.signedTransaction.length === 0
|
|
32
|
+
|| input.signedTransaction.length > 512 * 1024) {
|
|
33
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' };
|
|
34
|
+
}
|
|
35
|
+
input.signal.throwIfAborted();
|
|
36
|
+
let decoded;
|
|
37
|
+
try {
|
|
38
|
+
decoded = await waitForVerification(verifier.verifyAndDecodeTransaction(input.signedTransaction), input.signal);
|
|
39
|
+
input.signal.throwIfAborted();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (input.signal.aborted) {
|
|
43
|
+
throw input.signal.reason;
|
|
44
|
+
}
|
|
45
|
+
if (error instanceof VerificationException) {
|
|
46
|
+
if (error.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) {
|
|
47
|
+
throw new AppStoreDependencyUnavailableError('Apple signature checks are temporarily unavailable.', error);
|
|
48
|
+
}
|
|
49
|
+
return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_INVALID' };
|
|
50
|
+
}
|
|
51
|
+
// Only Apple's explicit retryable status is a transient dependency
|
|
52
|
+
// failure. Unexpected adapter/library faults must reach the caller.
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const payload = decodeOneTimeTransaction(decoded);
|
|
56
|
+
return payload === undefined
|
|
57
|
+
? { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' }
|
|
58
|
+
: { status: 'verified', payload };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function waitForVerification(operation, signal) {
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
let onAbort;
|
|
65
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
66
|
+
onAbort = () => reject(signal.reason ?? new Error('Verification was aborted.'));
|
|
67
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
onAbort();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
return await Promise.race([operation, aborted]);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (onAbort !== undefined) {
|
|
77
|
+
signal.removeEventListener('abort', onAbort);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function decodeOneTimeTransaction(decoded) {
|
|
82
|
+
if (!nonEmpty(decoded.transactionId)
|
|
83
|
+
|| !nonEmpty(decoded.originalTransactionId)
|
|
84
|
+
|| !nonEmpty(decoded.bundleId)
|
|
85
|
+
|| !nonEmpty(decoded.productId)
|
|
86
|
+
|| !validTimestamp(decoded.purchaseDate)
|
|
87
|
+
|| !validTimestamp(decoded.signedDate)
|
|
88
|
+
|| (decoded.environment !== 'Production' && decoded.environment !== 'Sandbox')
|
|
89
|
+
|| (decoded.type !== 'Consumable' && decoded.type !== 'Non-Consumable')
|
|
90
|
+
|| (decoded.quantity !== undefined
|
|
91
|
+
&& (!Number.isSafeInteger(decoded.quantity) || decoded.quantity <= 0))
|
|
92
|
+
|| (decoded.appAccountToken !== undefined && !nonEmpty(decoded.appAccountToken))
|
|
93
|
+
|| (decoded.revocationDate !== undefined && !validTimestamp(decoded.revocationDate))
|
|
94
|
+
|| (decoded.expiresDate !== undefined && !validTimestamp(decoded.expiresDate))
|
|
95
|
+
|| (decoded.isUpgraded !== undefined && typeof decoded.isUpgraded !== 'boolean')) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
transactionId: decoded.transactionId,
|
|
100
|
+
originalTransactionId: decoded.originalTransactionId,
|
|
101
|
+
bundleId: decoded.bundleId,
|
|
102
|
+
productId: decoded.productId,
|
|
103
|
+
purchaseDate: decoded.purchaseDate,
|
|
104
|
+
signedDate: decoded.signedDate,
|
|
105
|
+
environment: decoded.environment,
|
|
106
|
+
type: decoded.type,
|
|
107
|
+
...(decoded.quantity === undefined ? {} : { quantity: decoded.quantity }),
|
|
108
|
+
...(decoded.appAccountToken === undefined ? {} : { appAccountToken: decoded.appAccountToken }),
|
|
109
|
+
...(decoded.revocationDate === undefined ? {} : { revocationDate: decoded.revocationDate }),
|
|
110
|
+
...(decoded.expiresDate === undefined ? {} : { expiresDate: decoded.expiresDate }),
|
|
111
|
+
...(decoded.isUpgraded === undefined ? {} : { isUpgraded: decoded.isUpgraded }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function nonEmpty(value) {
|
|
115
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
116
|
+
}
|
|
117
|
+
function validTimestamp(value) {
|
|
118
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
119
|
+
}
|
package/dist/providers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAdMobClientRewardEvidence } from '@mpgd/game-services/admob-client-reward';
|
|
1
2
|
import { PlatformOperationError, } from '@mpgd/platform';
|
|
2
3
|
const featureMethods = {
|
|
3
4
|
nativeIap: [
|
|
@@ -253,11 +254,13 @@ function isValidProviderData(method, value) {
|
|
|
253
254
|
return isVoidData(value);
|
|
254
255
|
case 'ads.showRewarded':
|
|
255
256
|
return isRecord(value)
|
|
256
|
-
&& ['completed', 'skipped', 'unavailable', 'failed'].includes(value.status)
|
|
257
|
+
&& ['completed', 'pending', 'skipped', 'unavailable', 'failed'].includes(value.status)
|
|
257
258
|
&& typeof value.rewardGranted === 'boolean'
|
|
258
259
|
&& (!value.rewardGranted || (value.status === 'completed'
|
|
259
260
|
&& isString(value.ledgerEntryId)
|
|
260
261
|
&& value.ledgerEntryId.length > 0))
|
|
262
|
+
&& (value.status !== 'completed' || value.rewardGranted
|
|
263
|
+
|| value.evidence === undefined || isAdMobClientRewardEvidence(value))
|
|
261
264
|
&& (value.evidence === undefined || isEvidence(value.evidence));
|
|
262
265
|
case 'ads.showInterstitial':
|
|
263
266
|
return isRecord(value)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-capacitor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Capacitor platform adapter for mpgd Android and iOS shells.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
".": {
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./admob": {
|
|
33
|
+
"types": "./dist/admob.d.ts",
|
|
34
|
+
"default": "./dist/admob.js"
|
|
35
|
+
},
|
|
36
|
+
"./app-store-server": {
|
|
37
|
+
"types": "./dist/app-store-server.d.ts",
|
|
38
|
+
"default": "./dist/app-store-server.js"
|
|
31
39
|
}
|
|
32
40
|
},
|
|
33
41
|
"files": [
|
|
@@ -35,20 +43,32 @@
|
|
|
35
43
|
],
|
|
36
44
|
"dependencies": {
|
|
37
45
|
"@capacitor/app": "8.1.1",
|
|
46
|
+
"@mpgd/bridge": "0.10.0",
|
|
47
|
+
"@mpgd/platform": "0.14.0",
|
|
48
|
+
"@mpgd/target-config": "0.17.0",
|
|
38
49
|
"@mpgd/capacitor-game-services": "0.6.0",
|
|
39
|
-
"@mpgd/
|
|
40
|
-
"@mpgd/target-config": "0.16.0",
|
|
41
|
-
"@mpgd/bridge": "0.10.0"
|
|
50
|
+
"@mpgd/game-services": "0.17.1"
|
|
42
51
|
},
|
|
43
52
|
"peerDependencies": {
|
|
53
|
+
"@apple/app-store-server-library": "^3.1.0",
|
|
54
|
+
"@capacitor-community/admob": "^8.1.0",
|
|
44
55
|
"@capacitor/core": "^8.5.1"
|
|
45
56
|
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@apple/app-store-server-library": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@capacitor-community/admob": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
46
65
|
"devDependencies": {
|
|
66
|
+
"@apple/app-store-server-library": "3.1.0",
|
|
67
|
+
"@capacitor-community/admob": "8.1.0",
|
|
47
68
|
"@capacitor/core": "8.5.2",
|
|
48
69
|
"ttsc": "0.30.4",
|
|
49
70
|
"typescript": "7.0.2",
|
|
50
|
-
"vitest": "^4.0.0"
|
|
51
|
-
"@mpgd/game-services": "0.16.0"
|
|
71
|
+
"vitest": "^4.0.0"
|
|
52
72
|
},
|
|
53
73
|
"main": "./dist/index.js",
|
|
54
74
|
"types": "./dist/index.d.ts",
|