@mpgd/adapter-ait 0.12.6 → 0.13.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/dist/ad-provider.d.ts +18 -0
- package/dist/ad-provider.js +295 -0
- package/dist/authority-fetch.d.ts +13 -0
- package/dist/authority-fetch.js +81 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.js +115 -201
- package/dist/index.d.ts +3 -0
- package/dist/index.js +43 -1
- package/dist/lifecycle.d.ts +1 -0
- package/dist/lifecycle.js +81 -34
- package/package.json +6 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
|
|
2
|
+
import type { AitHostDependencies } from './host.js';
|
|
3
|
+
import type { AitAdPlacementType } from './ad-config.js';
|
|
4
|
+
/** SDK event interpretation only. Game execution and backend grants are separate owners. */
|
|
5
|
+
export declare function createAitAdProvider(input: {
|
|
6
|
+
readonly dependencies: Pick<AitHostDependencies, 'showFullScreenAd'>;
|
|
7
|
+
readonly groups: ReadonlyMap<string, string>;
|
|
8
|
+
readonly types: ReadonlyMap<string, AitAdPlacementType>;
|
|
9
|
+
readonly isSupported: () => boolean;
|
|
10
|
+
readonly prepare: (group: string) => Promise<void>;
|
|
11
|
+
readonly consume: (group: string) => boolean;
|
|
12
|
+
readonly requestTimeoutMs: number;
|
|
13
|
+
readonly startTimeoutMs: number;
|
|
14
|
+
readonly displayTimeoutMs: number;
|
|
15
|
+
readonly onLegacyPhase?: (active: boolean) => void;
|
|
16
|
+
}): AdProvider & {
|
|
17
|
+
showLegacy(request: AdShowInput): Promise<AdShowResult>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { adProtocol, adProtocolVersion, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, } from '@mpgd/platform/ads';
|
|
2
|
+
const nativeOwners = new WeakMap();
|
|
3
|
+
/** SDK event interpretation only. Game execution and backend grants are separate owners. */
|
|
4
|
+
export function createAitAdProvider(input) {
|
|
5
|
+
const id = 'apps-in-toss-ads';
|
|
6
|
+
const sdk = input.dependencies.showFullScreenAd;
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
function availability(request) {
|
|
9
|
+
if (!input.isSupported()) {
|
|
10
|
+
return { state: 'unsupported', reason: 'unsupported' };
|
|
11
|
+
}
|
|
12
|
+
if (input.types.has(request.placementId) && input.types.get(request.placementId) !== request.format) {
|
|
13
|
+
return {
|
|
14
|
+
state: 'unsupported',
|
|
15
|
+
reason: 'unsupported',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (!input.groups.has(request.placementId) || !input.types.has(request.placementId)) {
|
|
19
|
+
return {
|
|
20
|
+
state: 'configuration-required',
|
|
21
|
+
reason: 'configuration-required',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const owner = nativeOwners.get(sdk);
|
|
25
|
+
if (owner?.state === 'unknown') {
|
|
26
|
+
return {
|
|
27
|
+
state: 'action-required',
|
|
28
|
+
reason: 'action-required',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (owner !== undefined) {
|
|
32
|
+
return { state: 'temporarily-unavailable', reason: 'busy' };
|
|
33
|
+
}
|
|
34
|
+
return { state: 'available' };
|
|
35
|
+
}
|
|
36
|
+
async function show(supplied, legacy) {
|
|
37
|
+
const request = assertAdShowInput(supplied);
|
|
38
|
+
if (request.idempotencyKey.length > 256 || /[\p{Cc}\p{Cf}]/u.test(request.idempotencyKey)) {
|
|
39
|
+
throw new TypeError('AIT ad correlation must contain 1 to 256 visible characters.');
|
|
40
|
+
}
|
|
41
|
+
const makeResult = (state) => assertAdShowResult({ providerId: id, invocationId: request.invocationId, format: request.format, ...state });
|
|
42
|
+
const noStart = (outcome, reason) => makeResult({
|
|
43
|
+
outcome,
|
|
44
|
+
presentation: 'not-started',
|
|
45
|
+
eligibility: request.format === 'rewarded' ? 'not-earned' : 'not-applicable',
|
|
46
|
+
...(reason === undefined ? {} : { reason }),
|
|
47
|
+
});
|
|
48
|
+
const available = availability(request);
|
|
49
|
+
if (available.state !== 'available') {
|
|
50
|
+
return noStart('unavailable', available.reason);
|
|
51
|
+
}
|
|
52
|
+
const group = input.groups.get(request.placementId);
|
|
53
|
+
if (group === undefined) {
|
|
54
|
+
return noStart('unavailable', 'configuration-required');
|
|
55
|
+
}
|
|
56
|
+
const owned = { state: 'requested' };
|
|
57
|
+
nativeOwners.set(sdk, owned);
|
|
58
|
+
const legacyPhase = (active) => {
|
|
59
|
+
if (!legacy) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
input.onLegacyPhase?.(active);
|
|
64
|
+
}
|
|
65
|
+
catch { /* Isolate lifecycle observation. */ }
|
|
66
|
+
};
|
|
67
|
+
const release = () => {
|
|
68
|
+
if (nativeOwners.get(sdk) === owned) {
|
|
69
|
+
nativeOwners.delete(sdk);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
await input.prepare(group);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
release();
|
|
77
|
+
return noStart('failed', 'transient-failure');
|
|
78
|
+
}
|
|
79
|
+
if (!input.consume(group)) {
|
|
80
|
+
release();
|
|
81
|
+
return noStart('failed', 'transient-failure');
|
|
82
|
+
}
|
|
83
|
+
let started = false;
|
|
84
|
+
let closed = false;
|
|
85
|
+
let earned;
|
|
86
|
+
let sequence = 0;
|
|
87
|
+
let cleanup;
|
|
88
|
+
let cleaned = false;
|
|
89
|
+
let timer;
|
|
90
|
+
let displayTimerArmed = false;
|
|
91
|
+
let delivered = false;
|
|
92
|
+
let resolve;
|
|
93
|
+
const result = new Promise((done) => {
|
|
94
|
+
resolve = done;
|
|
95
|
+
});
|
|
96
|
+
const startedAt = Date.now();
|
|
97
|
+
const emit = (event) => {
|
|
98
|
+
const safe = assertAdPresentationEvent({
|
|
99
|
+
...event,
|
|
100
|
+
providerId: id,
|
|
101
|
+
invocationId: request.invocationId,
|
|
102
|
+
sequence: ++sequence,
|
|
103
|
+
});
|
|
104
|
+
for (const listener of [...listeners]) {
|
|
105
|
+
try {
|
|
106
|
+
void Promise.resolve(listener(safe)).catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
catch { /* Keep native terminal observation alive. */ }
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const clean = () => {
|
|
112
|
+
if (cleaned || cleanup === undefined || !closed || request.format === 'rewarded' && earned === undefined) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
cleaned = true;
|
|
116
|
+
try {
|
|
117
|
+
cleanup();
|
|
118
|
+
}
|
|
119
|
+
catch { /* Cleanup does not change native closure. */ }
|
|
120
|
+
};
|
|
121
|
+
const finish = (value) => {
|
|
122
|
+
if (!delivered) {
|
|
123
|
+
delivered = true;
|
|
124
|
+
resolve(value);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const eligibility = () => {
|
|
128
|
+
if (request.format === 'interstitial') {
|
|
129
|
+
return 'not-applicable';
|
|
130
|
+
}
|
|
131
|
+
return earned === undefined ? 'unknown' : 'eligible';
|
|
132
|
+
};
|
|
133
|
+
const uncertain = () => {
|
|
134
|
+
if (closed || owned.state === 'unknown') {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
owned.state = 'unknown';
|
|
138
|
+
emit({ type: 'unknown' });
|
|
139
|
+
finish(makeResult({
|
|
140
|
+
outcome: 'pending',
|
|
141
|
+
presentation: 'unknown',
|
|
142
|
+
eligibility: eligibility(),
|
|
143
|
+
reason: 'outcome-unknown',
|
|
144
|
+
...(earned === undefined ? {} : { evidence: earned }),
|
|
145
|
+
}));
|
|
146
|
+
};
|
|
147
|
+
const close = (failed) => {
|
|
148
|
+
if (closed) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
closed = true;
|
|
152
|
+
if (timer !== undefined) {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
release();
|
|
156
|
+
legacyPhase(false);
|
|
157
|
+
emit(failed ? { type: 'failed', reason: 'transient-failure' } : { type: 'closed' });
|
|
158
|
+
if (failed && !started && earned === undefined) {
|
|
159
|
+
finish(noStart('failed', 'transient-failure'));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
finish(makeResult({
|
|
163
|
+
outcome: failed ? 'failed' : 'shown',
|
|
164
|
+
presentation: 'closed',
|
|
165
|
+
eligibility: eligibility(),
|
|
166
|
+
...(failed ? { reason: 'transient-failure' } : {}),
|
|
167
|
+
...(earned === undefined ? {} : { evidence: earned }),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
// Failure before presentation proves no earning is forthcoming. After a
|
|
171
|
+
// started presentation, retain the scoped callback for a delayed reward.
|
|
172
|
+
if (failed && !started && earned === undefined) {
|
|
173
|
+
cleaned = true;
|
|
174
|
+
if (cleanup !== undefined) {
|
|
175
|
+
try {
|
|
176
|
+
cleanup();
|
|
177
|
+
}
|
|
178
|
+
catch { /* Isolate cleanup. */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
clean();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const startedEvent = () => {
|
|
186
|
+
if (closed) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!started) {
|
|
190
|
+
started = true;
|
|
191
|
+
owned.state = 'open';
|
|
192
|
+
emit({ type: 'started' });
|
|
193
|
+
}
|
|
194
|
+
if (!displayTimerArmed) {
|
|
195
|
+
displayTimerArmed = true;
|
|
196
|
+
if (timer !== undefined) {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
timer = setTimeout(uncertain, input.displayTimeoutMs);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
legacyPhase(true);
|
|
203
|
+
emit({ type: 'requested' });
|
|
204
|
+
timer = setTimeout(uncertain, input.requestTimeoutMs);
|
|
205
|
+
try {
|
|
206
|
+
cleanup = sdk({
|
|
207
|
+
options: { adGroupId: group },
|
|
208
|
+
onEvent(event) {
|
|
209
|
+
switch (event.type) {
|
|
210
|
+
case 'requested':
|
|
211
|
+
if (!closed && !started) {
|
|
212
|
+
if (timer !== undefined) {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
timer = setTimeout(uncertain, Math.min(input.startTimeoutMs, Math.max(0, input.requestTimeoutMs - (Date.now() - startedAt))));
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
case 'show':
|
|
219
|
+
case 'impression':
|
|
220
|
+
case 'clicked':
|
|
221
|
+
startedEvent();
|
|
222
|
+
break;
|
|
223
|
+
case 'userEarnedReward':
|
|
224
|
+
if (request.format !== 'rewarded' || earned !== undefined || cleaned) {
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (!closed) {
|
|
228
|
+
startedEvent();
|
|
229
|
+
}
|
|
230
|
+
earned = { schema: 'apps-in-toss.rewarded-ad.callback.v1', payload: { event: 'user-earned-reward', correlationId: request.idempotencyKey, placementId: group } };
|
|
231
|
+
emit({ type: 'reward-earned', evidence: earned });
|
|
232
|
+
clean();
|
|
233
|
+
break;
|
|
234
|
+
case 'dismissed':
|
|
235
|
+
close(false);
|
|
236
|
+
break;
|
|
237
|
+
case 'failedToShow':
|
|
238
|
+
close(true);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
// A bridge error does not prove the native UI never opened.
|
|
243
|
+
onError() { uncertain(); },
|
|
244
|
+
});
|
|
245
|
+
if (cleaned) {
|
|
246
|
+
try {
|
|
247
|
+
cleanup();
|
|
248
|
+
}
|
|
249
|
+
catch { /* Isolate synchronous terminal cleanup. */ }
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
clean();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
uncertain();
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
id,
|
|
262
|
+
protocol: adProtocol,
|
|
263
|
+
protocolVersion: adProtocolVersion,
|
|
264
|
+
rewardSignal: 'delayed',
|
|
265
|
+
getAvailability: async (request) => availability(request),
|
|
266
|
+
async preload(request) {
|
|
267
|
+
const available = availability(request);
|
|
268
|
+
if (available.state !== 'available') {
|
|
269
|
+
return {
|
|
270
|
+
status: 'unavailable',
|
|
271
|
+
...(available.reason === undefined ? {} : { reason: available.reason }),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const group = input.groups.get(request.placementId);
|
|
275
|
+
if (group === undefined) {
|
|
276
|
+
return { status: 'unavailable', reason: 'configuration-required' };
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
await input.prepare(group);
|
|
280
|
+
return { status: 'ready' };
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return { status: 'failed', reason: 'transient-failure' };
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
show: (request) => show(request, false),
|
|
287
|
+
showLegacy: (request) => show(request, true),
|
|
288
|
+
subscribe(listener) {
|
|
289
|
+
listeners.add(listener);
|
|
290
|
+
return () => {
|
|
291
|
+
listeners.delete(listener);
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* response validation in its authority client. This helper only owns the
|
|
9
9
|
* WebView transport invocation, so all game-owned authority endpoints can use
|
|
10
10
|
* the same safe call shape.
|
|
11
|
+
*
|
|
12
|
+
* Authority calls carry grant evidence and idempotency headers, so the
|
|
13
|
+
* resource must resolve to an `https:` origin. Plain `http:` is accepted only
|
|
14
|
+
* for a loopback development authority (`localhost` or `127.0.0.1`).
|
|
15
|
+
*
|
|
16
|
+
* The transport receives exactly the representation that passed the policy
|
|
17
|
+
* check. A string, or a URL-like object (a string `href` and no `url`, which
|
|
18
|
+
* also covers a `URL` created in another realm such as an iframe), is
|
|
19
|
+
* forwarded as its validated absolute `href`. A same-realm `Request` is
|
|
20
|
+
* forwarded unchanged after its immutable `url` validated. Anything else is
|
|
21
|
+
* rejected: a Request-like object that is not a same-realm `Request` would be
|
|
22
|
+
* stringified by native fetch through `toString`, not `url`, so validating its
|
|
23
|
+
* `url` would not cover what is actually fetched.
|
|
11
24
|
*/
|
|
12
25
|
export declare function fetchAitAuthority(input: {
|
|
13
26
|
readonly resource: RequestInfo | URL;
|
package/dist/authority-fetch.js
CHANGED
|
@@ -8,11 +8,91 @@
|
|
|
8
8
|
* response validation in its authority client. This helper only owns the
|
|
9
9
|
* WebView transport invocation, so all game-owned authority endpoints can use
|
|
10
10
|
* the same safe call shape.
|
|
11
|
+
*
|
|
12
|
+
* Authority calls carry grant evidence and idempotency headers, so the
|
|
13
|
+
* resource must resolve to an `https:` origin. Plain `http:` is accepted only
|
|
14
|
+
* for a loopback development authority (`localhost` or `127.0.0.1`).
|
|
15
|
+
*
|
|
16
|
+
* The transport receives exactly the representation that passed the policy
|
|
17
|
+
* check. A string, or a URL-like object (a string `href` and no `url`, which
|
|
18
|
+
* also covers a `URL` created in another realm such as an iframe), is
|
|
19
|
+
* forwarded as its validated absolute `href`. A same-realm `Request` is
|
|
20
|
+
* forwarded unchanged after its immutable `url` validated. Anything else is
|
|
21
|
+
* rejected: a Request-like object that is not a same-realm `Request` would be
|
|
22
|
+
* stringified by native fetch through `toString`, not `url`, so validating its
|
|
23
|
+
* `url` would not cover what is actually fetched.
|
|
11
24
|
*/
|
|
12
25
|
export function fetchAitAuthority(input) {
|
|
13
26
|
const fetchImplementation = input.fetch ?? globalThis.fetch;
|
|
14
27
|
if (typeof fetchImplementation !== 'function') {
|
|
15
28
|
return Promise.reject(new TypeError('AIT authority fetch is unavailable.'));
|
|
16
29
|
}
|
|
17
|
-
|
|
30
|
+
const resolution = resolveAitAuthorityResource(input.resource);
|
|
31
|
+
if (!resolution.ok) {
|
|
32
|
+
return Promise.reject(resolution.error);
|
|
33
|
+
}
|
|
34
|
+
return fetchImplementation(resolution.resource, input.init);
|
|
35
|
+
}
|
|
36
|
+
const loopbackHostnames = new Set(['localhost', '127.0.0.1']);
|
|
37
|
+
function resolveAitAuthorityResource(resource) {
|
|
38
|
+
const candidate = readAitAuthorityResourceCandidate(resource);
|
|
39
|
+
if (candidate === undefined) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: new TypeError('AIT authority resource must be a string, URL, or same-realm Request.'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(candidate.href, globalThis.location?.href);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: new TypeError('AIT authority resource must be an absolute https: URL.'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const policyError = checkAitAuthorityUrl(url);
|
|
56
|
+
if (policyError !== undefined) {
|
|
57
|
+
return { ok: false, error: policyError };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
resource: candidate.kind === 'request' ? candidate.request : url.href,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function checkAitAuthorityUrl(url) {
|
|
65
|
+
if (url.protocol === 'https:') {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol === 'http:' && loopbackHostnames.has(url.hostname)) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return new TypeError(`AIT authority resource must use https: (http: is allowed only for localhost); received ${url.protocol}//${url.host}.`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reads the URL text once so the same value is both validated and forwarded.
|
|
75
|
+
*
|
|
76
|
+
* URL-like objects are recognized by shape (`instanceof URL` is false for a
|
|
77
|
+
* URL from another realm) and forwarded as their href string, so the object
|
|
78
|
+
* itself never reaches the transport. Requests are only accepted via a
|
|
79
|
+
* same-realm `instanceof Request`: the instance must be forwarded as-is, and
|
|
80
|
+
* native fetch stringifies any non-Request object through `toString` rather
|
|
81
|
+
* than reading `url`, so a duck-typed `{ url }` cannot be trusted.
|
|
82
|
+
*/
|
|
83
|
+
function readAitAuthorityResourceCandidate(resource) {
|
|
84
|
+
if (typeof resource === 'string') {
|
|
85
|
+
return { kind: 'href', href: resource };
|
|
86
|
+
}
|
|
87
|
+
if (typeof resource !== 'object' || resource === null) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (typeof Request === 'function' && resource instanceof Request) {
|
|
91
|
+
return { kind: 'request', href: resource.url, request: resource };
|
|
92
|
+
}
|
|
93
|
+
const { href, url } = resource;
|
|
94
|
+
if (typeof href === 'string' && url === undefined) {
|
|
95
|
+
return { kind: 'href', href };
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
18
98
|
}
|
package/dist/host.d.ts
CHANGED
|
@@ -149,7 +149,7 @@ export interface InstallAitHostBridgeOptions {
|
|
|
149
149
|
readonly adLoadQueueTimeoutMs?: number;
|
|
150
150
|
/** Upper bound for the requested-to-display portion of the total show timeout. */
|
|
151
151
|
readonly adDisplayStartTimeoutMs?: number;
|
|
152
|
-
/**
|
|
152
|
+
/** Caller wait limit; a missing native terminal callback keeps presentation unknown. */
|
|
153
153
|
readonly adMaximumDisplayMs?: number;
|
|
154
154
|
/** Maximum wait for an inline banner to render or report no-fill/failure. */
|
|
155
155
|
readonly bannerRenderTimeoutMs?: number;
|
package/dist/host.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as grantPromotionReward, IAP, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, TossAds, } from '@apps-in-toss/web-framework';
|
|
2
2
|
import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
|
|
3
|
+
import { assertAdShowInput, toAdAdapter } from '@mpgd/platform/ads';
|
|
4
|
+
import { createAitAdProvider } from './ad-provider.js';
|
|
3
5
|
import { dispatchAitLifecycleEvent } from './lifecycle.js';
|
|
4
6
|
const defaultAdTimeoutMs = 60_000;
|
|
5
7
|
const defaultAdLoadQueueTimeoutMs = 5_000;
|
|
@@ -13,9 +15,15 @@ const defaultIapPurchaseSessionTimeoutMs = 30 * 60_000;
|
|
|
13
15
|
/** A provider response is untrusted input; keep restore work bounded per launch. */
|
|
14
16
|
const maximumPendingIapOrders = 20;
|
|
15
17
|
const invalidBridgeRequestId = 'ait-invalid-request';
|
|
16
|
-
const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
|
|
17
18
|
const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
|
|
18
19
|
const minimumPromotionTossAppVersion = '5.232.0';
|
|
20
|
+
/**
|
|
21
|
+
* Every adapter-owned durable marker (purchase attempts, promotion grants,
|
|
22
|
+
* recovery cursors) lives under this prefix. Game code shares the same native
|
|
23
|
+
* storage, so `storage.save`/`storage.load` must never touch these keys: a
|
|
24
|
+
* forged `completed` marker would otherwise short-circuit purchase verification.
|
|
25
|
+
*/
|
|
26
|
+
const reservedStorageKeyPrefix = 'mpgd:ait:';
|
|
19
27
|
const promotionGrantStoragePrefix = 'mpgd:ait:promotion-grant:v1:';
|
|
20
28
|
/**
|
|
21
29
|
* A client idempotency key must survive a bridge reload. The authoritative
|
|
@@ -108,7 +116,6 @@ export function createAitHostBridge(options = {}) {
|
|
|
108
116
|
const notificationSubscriptions = new Set();
|
|
109
117
|
const loadedAdGroupIds = new Set();
|
|
110
118
|
const loadingAdGroups = new Map();
|
|
111
|
-
const activeAdGroupIds = new Set();
|
|
112
119
|
const activeBannerAttachments = new Map();
|
|
113
120
|
const bannerMountOwners = new Map();
|
|
114
121
|
const pendingBannerMountSettlements = new Map();
|
|
@@ -124,10 +131,20 @@ export function createAitHostBridge(options = {}) {
|
|
|
124
131
|
tone: options.bannerAppearance?.tone ?? 'blackAndWhite',
|
|
125
132
|
variant: options.bannerAppearance?.variant ?? 'expanded',
|
|
126
133
|
};
|
|
127
|
-
const adLoadCoordinator =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
const adLoadCoordinator = createAdLoadCoordinator(dependencies.loadFullScreenAd, adLoadQueueTimeoutMs);
|
|
135
|
+
const nativeAds = createAitAdProvider({
|
|
136
|
+
dependencies,
|
|
137
|
+
groups: adGroupIds,
|
|
138
|
+
types: adPlacementTypes,
|
|
139
|
+
isSupported: () => areFullScreenAdsSupported(dependencies),
|
|
140
|
+
prepare: (group) => preloadAdGroup(dependencies, group, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs),
|
|
141
|
+
consume: (group) => consumeLoadedAd(group, loadedAdGroupIds),
|
|
142
|
+
requestTimeoutMs: adTimeoutMs,
|
|
143
|
+
startTimeoutMs: adDisplayStartTimeoutMs,
|
|
144
|
+
displayTimeoutMs: adMaximumDisplayMs,
|
|
145
|
+
onLegacyPhase: (active) => dispatchAitLifecycleEvent(active ? 'pause' : 'resume'),
|
|
146
|
+
});
|
|
147
|
+
const legacyAds = toAdAdapter({ ...nativeAds, show: nativeAds.showLegacy });
|
|
131
148
|
const enqueueCompletedIapAttemptRetention = (storage, storageKey) => {
|
|
132
149
|
const scheduled = completedIapAttemptRetention.then(async () => {
|
|
133
150
|
await retainCompletedAitIapPurchaseAttempt(storage, storageKey, iapProductGrantTimeoutMs);
|
|
@@ -138,6 +155,13 @@ export function createAitHostBridge(options = {}) {
|
|
|
138
155
|
return next;
|
|
139
156
|
};
|
|
140
157
|
return {
|
|
158
|
+
advertising: {
|
|
159
|
+
id: nativeAds.id,
|
|
160
|
+
protocol: nativeAds.protocol,
|
|
161
|
+
protocolVersion: nativeAds.protocolVersion,
|
|
162
|
+
rewardSignal: nativeAds.rewardSignal,
|
|
163
|
+
subscribe: (listener) => nativeAds.subscribe(listener),
|
|
164
|
+
},
|
|
141
165
|
async request(input) {
|
|
142
166
|
try {
|
|
143
167
|
return await handleRequest(parseBridgeRequest(input));
|
|
@@ -370,51 +394,24 @@ export function createAitHostBridge(options = {}) {
|
|
|
370
394
|
await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs, placementType);
|
|
371
395
|
return ok(request, {});
|
|
372
396
|
}
|
|
373
|
-
case 'ads.
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
|
|
382
|
-
const correlationId = readIdempotencyKey(request.payload, request.id);
|
|
383
|
-
const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
|
|
384
|
-
return result.rewardGranted
|
|
385
|
-
? {
|
|
386
|
-
...result,
|
|
387
|
-
// game-services forwards this as platformImpressionId and compares it
|
|
388
|
-
// with the native callback correlationId during authority verification.
|
|
389
|
-
ledgerEntryId: correlationId,
|
|
390
|
-
evidence: {
|
|
391
|
-
schema: rewardedAdEvidenceSchema,
|
|
392
|
-
payload: {
|
|
393
|
-
event: 'user-earned-reward',
|
|
394
|
-
correlationId,
|
|
395
|
-
placementId: adGroupId,
|
|
396
|
-
},
|
|
397
|
-
},
|
|
398
|
-
}
|
|
399
|
-
: result;
|
|
397
|
+
case 'ads.getAvailability':
|
|
398
|
+
case 'ads.prepare': {
|
|
399
|
+
const payload = readPayloadRecord(request.payload);
|
|
400
|
+
const query = assertAdShowInput({
|
|
401
|
+
...payload,
|
|
402
|
+
invocationId: 'host-query',
|
|
403
|
+
idempotencyKey: 'host-query',
|
|
400
404
|
});
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return ok(request, shown.value);
|
|
405
|
-
}
|
|
406
|
-
case 'ads.showInterstitial': {
|
|
407
|
-
const placementId = readPlacementId(request.payload);
|
|
408
|
-
const adGroupId = adGroupIds.get(placementId);
|
|
409
|
-
if (adGroupId === undefined
|
|
410
|
-
|| adPlacementTypes.get(placementId) !== 'interstitial'
|
|
411
|
-
|| !areFullScreenAdsSupported(dependencies)) {
|
|
412
|
-
return ok(request, { status: 'unavailable' });
|
|
413
|
-
}
|
|
414
|
-
const showInterstitial = () => showInterstitialAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
|
|
415
|
-
const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'interstitial', showInterstitial);
|
|
416
|
-
return ok(request, shown.acquired ? shown.value : { status: 'unavailable' });
|
|
405
|
+
return ok(request, request.method === 'ads.prepare'
|
|
406
|
+
? await nativeAds.preload(query)
|
|
407
|
+
: await nativeAds.getAvailability(query));
|
|
417
408
|
}
|
|
409
|
+
case 'ads.show':
|
|
410
|
+
return ok(request, await nativeAds.show(assertAdShowInput(request.payload)));
|
|
411
|
+
case 'ads.showRewarded':
|
|
412
|
+
return ok(request, await legacyAds.showRewarded({ placementId: readPlacementId(request.payload), idempotencyKey: readRewardedAdCorrelationId(request.payload, request.id) }));
|
|
413
|
+
case 'ads.showInterstitial':
|
|
414
|
+
return ok(request, await legacyAds.showInterstitial?.({ placementId: readPlacementId(request.payload) }));
|
|
418
415
|
case 'ads.mountBanner': {
|
|
419
416
|
const placementId = readPlacementId(request.payload);
|
|
420
417
|
const surfaceId = readBannerSurfaceId(request.payload);
|
|
@@ -828,6 +825,24 @@ async function preloadAdGroup(dependencies, adGroupId, loaded, loading, coordina
|
|
|
828
825
|
}
|
|
829
826
|
}
|
|
830
827
|
}
|
|
828
|
+
const nativeAdLoadScopes = new WeakMap();
|
|
829
|
+
function createAdLoadCoordinator(sdk, waitTimeoutMs) {
|
|
830
|
+
let shared = nativeAdLoadScopes.get(sdk);
|
|
831
|
+
if (shared === undefined) {
|
|
832
|
+
shared = { active: undefined };
|
|
833
|
+
nativeAdLoadScopes.set(sdk, shared);
|
|
834
|
+
}
|
|
835
|
+
const owned = shared;
|
|
836
|
+
return {
|
|
837
|
+
get active() {
|
|
838
|
+
return owned.active;
|
|
839
|
+
},
|
|
840
|
+
set active(value) {
|
|
841
|
+
owned.active = value;
|
|
842
|
+
},
|
|
843
|
+
waitTimeoutMs,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
831
846
|
async function runSerializedAdLoad(coordinator, adGroupId, startNativeLoad) {
|
|
832
847
|
const waitDeadline = Date.now() + coordinator.waitTimeoutMs;
|
|
833
848
|
while (coordinator.active !== undefined) {
|
|
@@ -871,22 +886,6 @@ function waitForAdLoadSettlement(active, timeoutMs, adGroupId) {
|
|
|
871
886
|
void active.then(() => finish(), () => finish());
|
|
872
887
|
});
|
|
873
888
|
}
|
|
874
|
-
async function acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType) {
|
|
875
|
-
if (active.has(adGroupId)) {
|
|
876
|
-
return false;
|
|
877
|
-
}
|
|
878
|
-
const loadedSuccessfully = await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType);
|
|
879
|
-
if (!loadedSuccessfully) {
|
|
880
|
-
return false;
|
|
881
|
-
}
|
|
882
|
-
// Re-check after the asynchronous load so concurrent callers cannot consume
|
|
883
|
-
// or display the same native ad slot twice.
|
|
884
|
-
if (active.has(adGroupId) || !consumeLoadedAd(adGroupId, loaded)) {
|
|
885
|
-
return false;
|
|
886
|
-
}
|
|
887
|
-
active.add(adGroupId);
|
|
888
|
-
return true;
|
|
889
|
-
}
|
|
890
889
|
async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType) {
|
|
891
890
|
// The first caller enters preloadAdGroup synchronously and publishes its promise
|
|
892
891
|
// before yielding. Remember that ownership so concurrent waiters do not emit the
|
|
@@ -905,136 +904,19 @@ async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, lo
|
|
|
905
904
|
return false;
|
|
906
905
|
}
|
|
907
906
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
finally {
|
|
917
|
-
active.delete(adGroupId);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
async function showRewardedAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
|
|
921
|
-
let rewardGranted = false;
|
|
922
|
-
const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, (eventType) => {
|
|
923
|
-
if (eventType === 'userEarnedReward') {
|
|
924
|
-
rewardGranted = true;
|
|
925
|
-
}
|
|
926
|
-
});
|
|
927
|
-
let resultStatus;
|
|
928
|
-
if (status === 'shown' && rewardGranted) {
|
|
929
|
-
resultStatus = 'completed';
|
|
930
|
-
}
|
|
931
|
-
else if (status === 'failed') {
|
|
932
|
-
resultStatus = 'failed';
|
|
933
|
-
}
|
|
934
|
-
else {
|
|
935
|
-
resultStatus = 'skipped';
|
|
936
|
-
}
|
|
937
|
-
return {
|
|
938
|
-
status: resultStatus,
|
|
939
|
-
rewardGranted,
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
|
-
async function showInterstitialAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
|
|
943
|
-
const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs);
|
|
944
|
-
return { status: status === 'failed' ? 'skipped' : status };
|
|
945
|
-
}
|
|
946
|
-
function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, observe) {
|
|
947
|
-
dispatchAitLifecycleEvent('pause');
|
|
948
|
-
return new Promise((resolve) => {
|
|
949
|
-
const startedAt = Date.now();
|
|
950
|
-
let cleanup = () => { };
|
|
951
|
-
let settled = false;
|
|
952
|
-
let maximumDisplayTimeoutArmed = false;
|
|
953
|
-
let adTimer;
|
|
954
|
-
const clearAdTimer = () => {
|
|
955
|
-
if (adTimer !== undefined) {
|
|
956
|
-
globalThis.clearTimeout(adTimer);
|
|
957
|
-
adTimer = undefined;
|
|
958
|
-
}
|
|
959
|
-
};
|
|
960
|
-
const finish = (status) => {
|
|
961
|
-
if (settled) {
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
settled = true;
|
|
965
|
-
clearAdTimer();
|
|
966
|
-
cleanup();
|
|
967
|
-
dispatchAitLifecycleEvent('resume');
|
|
968
|
-
resolve(status);
|
|
969
|
-
};
|
|
970
|
-
const armDisplayStartTimeout = () => {
|
|
971
|
-
clearAdTimer();
|
|
972
|
-
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
|
973
|
-
const remainingMs = Math.max(0, timeoutMs - elapsedMs);
|
|
974
|
-
adTimer = globalThis.setTimeout(() => finish('failed'), Math.min(displayStartTimeoutMs, remainingMs));
|
|
975
|
-
};
|
|
976
|
-
const armMaximumDisplayTimeout = () => {
|
|
977
|
-
if (maximumDisplayTimeoutArmed) {
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
maximumDisplayTimeoutArmed = true;
|
|
981
|
-
clearAdTimer();
|
|
982
|
-
adTimer = globalThis.setTimeout(() => {
|
|
983
|
-
console.warn('AIT full-screen ad omitted its terminal callback; recovering the game lifecycle.', adGroupId);
|
|
984
|
-
finish('shown');
|
|
985
|
-
}, maximumDisplayMs);
|
|
986
|
-
};
|
|
987
|
-
const failShow = (error) => {
|
|
988
|
-
if (settled) {
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
console.warn('Failed to show AIT full-screen ad.', adGroupId, error ?? 'unknown native error');
|
|
992
|
-
finish('failed');
|
|
993
|
-
};
|
|
994
|
-
adTimer = globalThis.setTimeout(() => finish('failed'), timeoutMs);
|
|
995
|
-
try {
|
|
996
|
-
const unregister = dependencies.showFullScreenAd({
|
|
997
|
-
options: { adGroupId },
|
|
998
|
-
onEvent: (event) => {
|
|
999
|
-
observe?.(event.type);
|
|
1000
|
-
switch (event.type) {
|
|
1001
|
-
case 'requested':
|
|
1002
|
-
armDisplayStartTimeout();
|
|
1003
|
-
break;
|
|
1004
|
-
case 'show':
|
|
1005
|
-
case 'impression':
|
|
1006
|
-
case 'clicked':
|
|
1007
|
-
case 'userEarnedReward':
|
|
1008
|
-
// Native terminal callbacks remain authoritative. A long, one-shot
|
|
1009
|
-
// recovery timeout prevents a broken SDK callback from deadlocking
|
|
1010
|
-
// the game forever without treating ordinary end-card dwell as failure.
|
|
1011
|
-
armMaximumDisplayTimeout();
|
|
1012
|
-
break;
|
|
1013
|
-
case 'dismissed':
|
|
1014
|
-
finish('shown');
|
|
1015
|
-
break;
|
|
1016
|
-
case 'failedToShow':
|
|
1017
|
-
finish('failed');
|
|
1018
|
-
break;
|
|
1019
|
-
}
|
|
1020
|
-
},
|
|
1021
|
-
onError: failShow,
|
|
1022
|
-
});
|
|
1023
|
-
cleanup = unregister;
|
|
1024
|
-
if (settled) {
|
|
1025
|
-
cleanup();
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
catch (error) {
|
|
1029
|
-
failShow(error);
|
|
1030
|
-
}
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
907
|
+
/**
|
|
908
|
+
* Launch and inbound-share parameters arrive from untrusted deep links. Bound
|
|
909
|
+
* each forwarded value and the nested `queryParams` JSON so a hostile link
|
|
910
|
+
* cannot push oversized or unbounded data into the game.
|
|
911
|
+
*/
|
|
912
|
+
const maximumLaunchParamLength = 256;
|
|
913
|
+
const maximumNestedQueryParamsJsonLength = 4_096;
|
|
914
|
+
const maximumNestedQueryParamCount = 32;
|
|
1033
915
|
function getLaunchIntent() {
|
|
1034
916
|
const params = inboundSearchParams();
|
|
1035
|
-
const challengeToken =
|
|
1036
|
-
const puzzleId =
|
|
1037
|
-
const requestedEntry =
|
|
917
|
+
const challengeToken = boundedParam(params.get('challengeToken'));
|
|
918
|
+
const puzzleId = boundedParam(params.get('puzzleId'));
|
|
919
|
+
const requestedEntry = boundedParam(params.get('entry'));
|
|
1038
920
|
let entry;
|
|
1039
921
|
if (requestedEntry !== undefined && launchEntries.has(requestedEntry)) {
|
|
1040
922
|
entry = requestedEntry;
|
|
@@ -1053,8 +935,8 @@ function getLaunchIntent() {
|
|
|
1053
935
|
}
|
|
1054
936
|
function readInboundShare() {
|
|
1055
937
|
const params = inboundSearchParams();
|
|
1056
|
-
const puzzleId =
|
|
1057
|
-
const challengeToken =
|
|
938
|
+
const puzzleId = boundedParam(params.get('puzzleId'));
|
|
939
|
+
const challengeToken = boundedParam(params.get('challengeToken'));
|
|
1058
940
|
return puzzleId === undefined && challengeToken === undefined
|
|
1059
941
|
? null
|
|
1060
942
|
: {
|
|
@@ -1065,12 +947,24 @@ function readInboundShare() {
|
|
|
1065
947
|
function inboundSearchParams() {
|
|
1066
948
|
const params = new URLSearchParams(globalThis.location?.search ?? '');
|
|
1067
949
|
const nested = params.get('queryParams');
|
|
1068
|
-
if (nested !== null) {
|
|
950
|
+
if (nested !== null && nested.length <= maximumNestedQueryParamsJsonLength) {
|
|
1069
951
|
try {
|
|
1070
952
|
const parsed = JSON.parse(nested);
|
|
1071
953
|
if (isRecord(parsed)) {
|
|
954
|
+
let merged = 0;
|
|
1072
955
|
for (const [key, value] of Object.entries(parsed)) {
|
|
1073
|
-
if (
|
|
956
|
+
if (merged >= maximumNestedQueryParamCount) {
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
// Direct search params win; nested values only fill missing keys and
|
|
960
|
+
// must be bounded strings. Keys are counted whether or not they merge
|
|
961
|
+
// so an attacker cannot pad past the cap with ignored entries.
|
|
962
|
+
merged += 1;
|
|
963
|
+
if (typeof value === 'string'
|
|
964
|
+
&& key.length > 0
|
|
965
|
+
&& key.length <= maximumLaunchParamLength
|
|
966
|
+
&& value.length <= maximumLaunchParamLength
|
|
967
|
+
&& !params.has(key)) {
|
|
1074
968
|
params.set(key, value);
|
|
1075
969
|
}
|
|
1076
970
|
}
|
|
@@ -2170,6 +2064,9 @@ function readStorageKey(payload) {
|
|
|
2170
2064
|
if (typeof key !== 'string' || key.length === 0) {
|
|
2171
2065
|
throw new TypeError('AIT storage key must be a non-empty string.');
|
|
2172
2066
|
}
|
|
2067
|
+
if (key.startsWith(reservedStorageKeyPrefix)) {
|
|
2068
|
+
throw new TypeError(`AIT storage keys starting with "${reservedStorageKeyPrefix}" are reserved for adapter-owned markers.`);
|
|
2069
|
+
}
|
|
2173
2070
|
return key;
|
|
2174
2071
|
}
|
|
2175
2072
|
function readPlacementId(payload) {
|
|
@@ -2186,9 +2083,24 @@ function readBannerSurfaceId(payload) {
|
|
|
2186
2083
|
}
|
|
2187
2084
|
return surfaceId;
|
|
2188
2085
|
}
|
|
2189
|
-
|
|
2086
|
+
/**
|
|
2087
|
+
* A rewarded-ad idempotencyKey becomes the client-supplied evidence
|
|
2088
|
+
* correlationId. It is optional, but when present it must obey the same
|
|
2089
|
+
* bounded visible-character rule as purchase keys so a game cannot push
|
|
2090
|
+
* arbitrary blobs into reward evidence.
|
|
2091
|
+
*/
|
|
2092
|
+
function readRewardedAdCorrelationId(payload, fallback) {
|
|
2190
2093
|
const value = readPayloadRecord(payload).idempotencyKey;
|
|
2191
|
-
|
|
2094
|
+
if (value === undefined) {
|
|
2095
|
+
return fallback;
|
|
2096
|
+
}
|
|
2097
|
+
if (typeof value !== 'string'
|
|
2098
|
+
|| value.length === 0
|
|
2099
|
+
|| value.length > 256
|
|
2100
|
+
|| /[\p{Cc}\p{Cf}]/u.test(value)) {
|
|
2101
|
+
throw new TypeError('AIT rewarded ad idempotencyKey must contain 1 to 256 visible characters.');
|
|
2102
|
+
}
|
|
2103
|
+
return value;
|
|
2192
2104
|
}
|
|
2193
2105
|
function readCommercePurchase(payload) {
|
|
2194
2106
|
const value = readPayloadRecord(payload);
|
|
@@ -2256,8 +2168,10 @@ function ok(request, data) {
|
|
|
2256
2168
|
function createBridgeError(id, code, message, retryable = false) {
|
|
2257
2169
|
return { id, ok: false, error: { code, message, retryable } };
|
|
2258
2170
|
}
|
|
2259
|
-
function
|
|
2260
|
-
return value === null || value.length === 0
|
|
2171
|
+
function boundedParam(value) {
|
|
2172
|
+
return value === null || value.length === 0 || value.length > maximumLaunchParamLength
|
|
2173
|
+
? undefined
|
|
2174
|
+
: value;
|
|
2261
2175
|
}
|
|
2262
2176
|
function isAbortError(error) {
|
|
2263
2177
|
return isRecord(error) && error.name === 'AbortError';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
2
|
import { type PlatformGateway } from '@mpgd/platform';
|
|
3
|
+
import { type AdProvider } from '@mpgd/platform/ads';
|
|
3
4
|
export interface GamePlatformBridge {
|
|
4
5
|
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
6
|
+
/** Native observations are scoped to this host; SDK commands still use request envelopes. */
|
|
7
|
+
readonly advertising?: Pick<AdProvider, 'id' | 'protocol' | 'protocolVersion' | 'rewardSignal' | 'subscribe'>;
|
|
5
8
|
}
|
|
6
9
|
export interface AitSandboxStorage {
|
|
7
10
|
readonly getItem: (key: string) => string | null;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,48 @@
|
|
|
1
|
-
import { bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
|
|
1
|
+
import { assertBridgeResponse, bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
|
|
2
2
|
import { PlatformOperationError, } from '@mpgd/platform';
|
|
3
|
+
import { assertAdAvailability, assertAdPreparationResult, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, toAdAdapter, } from '@mpgd/platform/ads';
|
|
3
4
|
import { createAitLifecycleAdapter } from './lifecycle.js';
|
|
4
5
|
export function createAitPlatformGateway(input) {
|
|
6
|
+
const advertisingBridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
|
|
7
|
+
const advertising = advertisingBridge?.advertising;
|
|
8
|
+
async function requestAdvertising(method, payload) {
|
|
9
|
+
if (advertisingBridge === undefined) {
|
|
10
|
+
throw new PlatformOperationError({
|
|
11
|
+
code: 'AIT_BRIDGE_NOT_INSTALLED',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const id = crypto.randomUUID();
|
|
15
|
+
const response = assertBridgeResponse(await advertisingBridge.request({ id, method, payload, meta: { target: 'ait', appVersion: input.appVersion, buildId: input.buildId, sentAt: new Date().toISOString() } }));
|
|
16
|
+
if (response.id !== id) {
|
|
17
|
+
throw new TypeError('Advertising response belongs to another bridge request.');
|
|
18
|
+
}
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new PlatformOperationError(response.error);
|
|
21
|
+
}
|
|
22
|
+
return response.data;
|
|
23
|
+
}
|
|
24
|
+
const provider = advertising === undefined
|
|
25
|
+
? undefined
|
|
26
|
+
: {
|
|
27
|
+
id: advertising.id,
|
|
28
|
+
protocol: advertising.protocol,
|
|
29
|
+
protocolVersion: advertising.protocolVersion,
|
|
30
|
+
rewardSignal: advertising.rewardSignal,
|
|
31
|
+
getAvailability: async (payload) => assertAdAvailability(await requestAdvertising('ads.getAvailability', payload)),
|
|
32
|
+
preload: async (payload) => assertAdPreparationResult(await requestAdvertising('ads.prepare', payload)),
|
|
33
|
+
show: async (supplied) => {
|
|
34
|
+
const request = assertAdShowInput(supplied);
|
|
35
|
+
const result = assertAdShowResult(await requestAdvertising('ads.show', request));
|
|
36
|
+
if (result.providerId !== advertising.id || result.invocationId !== request.invocationId || result.format !== request.format) {
|
|
37
|
+
throw new TypeError('Advertising result belongs to another invocation.');
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
},
|
|
41
|
+
subscribe: (listener) => advertising.subscribe((event) => listener(assertAdPresentationEvent(event))),
|
|
42
|
+
};
|
|
43
|
+
if (provider !== undefined) {
|
|
44
|
+
toAdAdapter(provider);
|
|
45
|
+
}
|
|
5
46
|
async function request(method, payload) {
|
|
6
47
|
const bridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
|
|
7
48
|
if (bridge === undefined) {
|
|
@@ -60,6 +101,7 @@ export function createAitPlatformGateway(input) {
|
|
|
60
101
|
getEntitlements: () => request('commerce.getEntitlements', {}),
|
|
61
102
|
},
|
|
62
103
|
ads: {
|
|
104
|
+
...(provider === undefined ? {} : { provider }),
|
|
63
105
|
preload: (payload) => request('ads.preload', payload),
|
|
64
106
|
showRewarded: (payload) => request('ads.showRewarded', payload),
|
|
65
107
|
showInterstitial: (payload) => request('ads.showInterstitial', payload),
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LifecycleAdapter } from '@mpgd/platform';
|
|
2
2
|
export declare const aitLifecyclePauseEvent = "mpgd:ait:pause";
|
|
3
3
|
export declare const aitLifecycleResumeEvent = "mpgd:ait:resume";
|
|
4
|
+
/** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
|
|
4
5
|
export declare function createAitLifecycleAdapter(): LifecycleAdapter;
|
|
5
6
|
export declare function dispatchAitLifecycleEvent(type: 'pause' | 'resume'): void;
|
package/dist/lifecycle.js
CHANGED
|
@@ -1,45 +1,92 @@
|
|
|
1
1
|
export const aitLifecyclePauseEvent = 'mpgd:ait:pause';
|
|
2
2
|
export const aitLifecycleResumeEvent = 'mpgd:ait:resume';
|
|
3
|
-
|
|
3
|
+
/** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
|
|
4
4
|
export function createAitLifecycleAdapter() {
|
|
5
|
-
return {
|
|
6
|
-
onPause(callback) {
|
|
7
|
-
return subscribeToAitLifecycle('pause', callback);
|
|
8
|
-
},
|
|
9
|
-
onResume(callback) {
|
|
10
|
-
return subscribeToAitLifecycle('resume', callback);
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
export function dispatchAitLifecycleEvent(type) {
|
|
15
|
-
globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
|
|
16
|
-
}
|
|
17
|
-
function subscribeToAitLifecycle(type, callback) {
|
|
18
5
|
const target = globalThis;
|
|
19
6
|
const documentTarget = target.document;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
7
|
+
const pauses = new Set();
|
|
8
|
+
const resumes = new Set();
|
|
9
|
+
let nativePaused = false;
|
|
10
|
+
let pageActive = true;
|
|
11
|
+
let attached = false;
|
|
12
|
+
let active = documentTarget?.visibilityState !== 'hidden';
|
|
13
|
+
const notify = (callback) => {
|
|
14
|
+
try {
|
|
15
|
+
void Promise.resolve(callback()).catch(() => undefined);
|
|
26
16
|
}
|
|
27
|
-
|
|
28
|
-
callback();
|
|
17
|
+
catch { /* Keep lifecycle observation alive. */ }
|
|
29
18
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
19
|
+
function reconcile() {
|
|
20
|
+
const next = !nativePaused && pageActive && documentTarget?.visibilityState !== 'hidden';
|
|
21
|
+
if (next === active) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
active = next;
|
|
25
|
+
for (const callback of [...(active ? resumes : pauses)]) {
|
|
26
|
+
notify(callback);
|
|
34
27
|
}
|
|
28
|
+
}
|
|
29
|
+
const nativePause = () => {
|
|
30
|
+
nativePaused = true;
|
|
31
|
+
reconcile();
|
|
32
|
+
};
|
|
33
|
+
const nativeResume = () => {
|
|
34
|
+
nativePaused = false;
|
|
35
|
+
reconcile();
|
|
35
36
|
};
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
target.addEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
|
|
40
|
-
return () => {
|
|
41
|
-
target.removeEventListener?.(eventName, handleCustomEvent);
|
|
42
|
-
documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
43
|
-
target.removeEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
|
|
37
|
+
const pageHide = () => {
|
|
38
|
+
pageActive = false;
|
|
39
|
+
reconcile();
|
|
44
40
|
};
|
|
41
|
+
const pageShow = () => {
|
|
42
|
+
pageActive = true;
|
|
43
|
+
reconcile();
|
|
44
|
+
};
|
|
45
|
+
function attach() {
|
|
46
|
+
if (attached) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
attached = true;
|
|
50
|
+
target.addEventListener?.(aitLifecyclePauseEvent, nativePause);
|
|
51
|
+
target.addEventListener?.(aitLifecycleResumeEvent, nativeResume);
|
|
52
|
+
target.addEventListener?.('pagehide', pageHide);
|
|
53
|
+
target.addEventListener?.('pageshow', pageShow);
|
|
54
|
+
documentTarget?.addEventListener('visibilitychange', reconcile);
|
|
55
|
+
}
|
|
56
|
+
function detach() {
|
|
57
|
+
if (!attached) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
attached = false;
|
|
61
|
+
target.removeEventListener?.(aitLifecyclePauseEvent, nativePause);
|
|
62
|
+
target.removeEventListener?.(aitLifecycleResumeEvent, nativeResume);
|
|
63
|
+
target.removeEventListener?.('pagehide', pageHide);
|
|
64
|
+
target.removeEventListener?.('pageshow', pageShow);
|
|
65
|
+
documentTarget?.removeEventListener('visibilitychange', reconcile);
|
|
66
|
+
}
|
|
67
|
+
function subscribe(callbacks, callback) {
|
|
68
|
+
callbacks.add(callback);
|
|
69
|
+
attach();
|
|
70
|
+
if (callbacks === pauses && !active) {
|
|
71
|
+
notify(callback);
|
|
72
|
+
}
|
|
73
|
+
return () => {
|
|
74
|
+
callbacks.delete(callback);
|
|
75
|
+
if (pauses.size + resumes.size === 0) {
|
|
76
|
+
detach();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
onPause: (callback) => subscribe(pauses, callback),
|
|
82
|
+
onResume: (callback) => subscribe(resumes, callback),
|
|
83
|
+
async dispose() {
|
|
84
|
+
pauses.clear();
|
|
85
|
+
resumes.clear();
|
|
86
|
+
detach();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function dispatchAitLifecycleEvent(type) {
|
|
91
|
+
globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
|
|
45
92
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-ait",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Apps in Toss WebView platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"dist"
|
|
60
60
|
],
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@mpgd/
|
|
63
|
-
"@mpgd/
|
|
62
|
+
"@mpgd/platform": "0.15.0",
|
|
63
|
+
"@mpgd/bridge": "0.11.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@apps-in-toss/web-framework": ">=3.0.0 <4"
|
|
@@ -71,10 +71,12 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
+
"@types/node": "^24.0.0",
|
|
74
75
|
"@apps-in-toss/web-framework": "3.5.0",
|
|
75
76
|
"ttsc": "0.30.4",
|
|
76
77
|
"typescript": "7.0.2",
|
|
77
|
-
"vitest": "^4.0.0"
|
|
78
|
+
"vitest": "^4.0.0",
|
|
79
|
+
"@mpgd/game-runtime": "0.3.0"
|
|
78
80
|
},
|
|
79
81
|
"main": "./dist/index.js",
|
|
80
82
|
"types": "./dist/index.d.ts",
|