@usions/sdk 2.12.0 → 2.14.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/package.json +1 -1
- package/src/browser.js +233 -23
- package/src/modules/core.js +16 -0
- package/src/modules/index.js +2 -0
- package/src/modules/notify.js +70 -0
- package/src/modules/wallet.js +144 -22
- package/types/index.d.ts +42 -2
package/package.json
CHANGED
package/src/browser.js
CHANGED
|
@@ -69,7 +69,7 @@ var Usion = (function () {
|
|
|
69
69
|
* Core Usion object with init, _post, _request
|
|
70
70
|
*/
|
|
71
71
|
const core = {
|
|
72
|
-
version: '2.
|
|
72
|
+
version: '2.14.0', // injected from package.json at build
|
|
73
73
|
config: {},
|
|
74
74
|
_initialized: false,
|
|
75
75
|
_initCallback: null,
|
|
@@ -212,6 +212,22 @@ var Usion = (function () {
|
|
|
212
212
|
return this.config.language || 'en';
|
|
213
213
|
},
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Launch parameters the host opened this app with. Use `path` to deep-link
|
|
217
|
+
* to a specific screen — e.g. when the user taps a notification sent via
|
|
218
|
+
* `Usion.notify.send({ path })`, the app reopens and `getLaunchParams().path`
|
|
219
|
+
* returns that same path so the app can route to it.
|
|
220
|
+
* @returns {{ path: string|null, ref: string|null, roomId: string|null }}
|
|
221
|
+
*/
|
|
222
|
+
getLaunchParams: function() {
|
|
223
|
+
var c = this.config || {};
|
|
224
|
+
return {
|
|
225
|
+
path: c.launchPath || null,
|
|
226
|
+
ref: c.ref || c.launchRef || null,
|
|
227
|
+
roomId: c.roomId || null,
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
|
|
215
231
|
/**
|
|
216
232
|
* Send message to parent app
|
|
217
233
|
* @private
|
|
@@ -410,6 +426,13 @@ var Usion = (function () {
|
|
|
410
426
|
_balance: null,
|
|
411
427
|
_balanceChangeHandler: null,
|
|
412
428
|
|
|
429
|
+
// Payment timing knobs (overridable, mainly for tests). The defaults are
|
|
430
|
+
// the production values.
|
|
431
|
+
_paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
|
|
432
|
+
_recoveryPollMs: 2000, // gap between result-recovery re-queries
|
|
433
|
+
_recoveryMaxPolls: 10, // bounded recovery window after the timeout
|
|
434
|
+
_recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
|
|
435
|
+
|
|
413
436
|
/**
|
|
414
437
|
* Get current wallet balance
|
|
415
438
|
* @returns {Promise<number>} Balance in credits
|
|
@@ -440,20 +463,69 @@ var Usion = (function () {
|
|
|
440
463
|
},
|
|
441
464
|
|
|
442
465
|
/**
|
|
443
|
-
* Request payment from user with balance check
|
|
466
|
+
* Request payment from user with balance check.
|
|
467
|
+
*
|
|
468
|
+
* The charge is debited immediately by the host (escrow); the resolved
|
|
469
|
+
* value carries a `receiptToken` the mini-app server later settles/refunds.
|
|
470
|
+
*
|
|
471
|
+
* Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
|
|
472
|
+
* backgrounded tab, race) the user may ALREADY be charged. Instead of
|
|
473
|
+
* rejecting at the timeout, this re-queries the host for the result and
|
|
474
|
+
* recovers the receipt token — so a dropped message never strands a paid
|
|
475
|
+
* charge. Pass `idempotencyKey` to additionally guarantee that any retry of
|
|
476
|
+
* the SAME intent reuses the SAME charge (the host + backend dedupe on it),
|
|
477
|
+
* so the user can never be charged twice even across reloads.
|
|
478
|
+
*
|
|
444
479
|
* @param {number} amount - Credit amount to charge
|
|
445
480
|
* @param {string} reason - Description shown to user
|
|
446
|
-
* @param {object} data - Optional
|
|
481
|
+
* @param {object} [data] - Optional options/payload. `data.idempotencyKey`
|
|
482
|
+
* (string) makes the charge safely retryable. Any other fields are
|
|
483
|
+
* forwarded to the host verbatim (backward compatible).
|
|
447
484
|
* @returns {Promise} Resolves on payment success, rejects on failure
|
|
448
485
|
*/
|
|
449
486
|
requestPayment: function(amount, reason, data) {
|
|
450
487
|
const self = this;
|
|
451
488
|
|
|
489
|
+
const opts = (data && typeof data === 'object') ? data : null;
|
|
490
|
+
const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
|
|
491
|
+
? opts.idempotencyKey
|
|
492
|
+
: null;
|
|
493
|
+
|
|
452
494
|
return new Promise(function(resolve, reject) {
|
|
453
495
|
const requestId = getNextRequestId();
|
|
454
|
-
const timeoutMs = 60000;
|
|
496
|
+
const timeoutMs = self._paymentTimeoutMs || 60000;
|
|
497
|
+
let settled = false;
|
|
498
|
+
let recoverTimer = null;
|
|
455
499
|
|
|
456
|
-
|
|
500
|
+
function cleanup() {
|
|
501
|
+
clearTimeout(timer);
|
|
502
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
503
|
+
window.removeEventListener('message', handler);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function finishResolve(response) {
|
|
507
|
+
if (settled) return;
|
|
508
|
+
settled = true;
|
|
509
|
+
cleanup();
|
|
510
|
+
// Update cached balance from the authoritative new balance, else
|
|
511
|
+
// best-effort subtract (no-op if balance is unknown).
|
|
512
|
+
if (response && response.newBalance !== undefined) {
|
|
513
|
+
self._balance = response.newBalance;
|
|
514
|
+
} else if (self._balance !== null) {
|
|
515
|
+
self._balance -= amount;
|
|
516
|
+
}
|
|
517
|
+
resolve(response);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function finishReject(error) {
|
|
521
|
+
if (settled) return;
|
|
522
|
+
settled = true;
|
|
523
|
+
cleanup();
|
|
524
|
+
reject(error);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Direct response from the host. Kept registered through the recovery
|
|
528
|
+
// window so a late PAYMENT_SUCCESS still resolves.
|
|
457
529
|
function handler(event) {
|
|
458
530
|
// Only honor payment results from the trusted host shell — a forged
|
|
459
531
|
// PAYMENT_SUCCESS must never resolve this promise.
|
|
@@ -467,32 +539,97 @@ var Usion = (function () {
|
|
|
467
539
|
}
|
|
468
540
|
|
|
469
541
|
// Only accept responses for this specific payment request.
|
|
470
|
-
if (response._requestId !== requestId) {
|
|
542
|
+
if (!response || response._requestId !== requestId) {
|
|
471
543
|
return;
|
|
472
544
|
}
|
|
473
545
|
|
|
474
546
|
if (response.type === 'PAYMENT_SUCCESS') {
|
|
475
|
-
|
|
476
|
-
window.removeEventListener('message', handler);
|
|
477
|
-
// Update cached balance
|
|
478
|
-
if (response.newBalance !== undefined) {
|
|
479
|
-
self._balance = response.newBalance;
|
|
480
|
-
} else if (self._balance !== null) {
|
|
481
|
-
self._balance -= amount;
|
|
482
|
-
}
|
|
483
|
-
resolve(response);
|
|
547
|
+
finishResolve(response);
|
|
484
548
|
} else if (response.type === 'PAYMENT_FAILED') {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
549
|
+
finishReject(new Error(response.reason || 'Payment failed'));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// After the direct deadline, re-query the host for this payment's
|
|
554
|
+
// result instead of rejecting.
|
|
555
|
+
// succeeded -> resolve with the recovered token
|
|
556
|
+
// failed -> reject
|
|
557
|
+
// pending -> the modal is STILL OPEN / charge still in progress, so
|
|
558
|
+
// the charge is fully recoverable: keep waiting (do NOT
|
|
559
|
+
// count this toward the give-up cap). A slow user who
|
|
560
|
+
// confirms minutes later still resolves — via the next
|
|
561
|
+
// 'succeeded' poll or the direct handler, which stays
|
|
562
|
+
// registered. Without this, a charge made after we gave
|
|
563
|
+
// up would be stranded (the original bug, shifted to the
|
|
564
|
+
// slow-confirm case).
|
|
565
|
+
// none/other -> likely no charge; confirm twice then reject cleanly so
|
|
566
|
+
// a fresh requestPayment is safe.
|
|
567
|
+
// The bounded cap only governs the give-up paths (none-streak and a host
|
|
568
|
+
// that never answers the query at all, e.g. an older host shell).
|
|
569
|
+
function recoverPaymentResult() {
|
|
570
|
+
const pollMs = self._recoveryPollMs || 2000;
|
|
571
|
+
const maxPolls = self._recoveryMaxPolls || 10;
|
|
572
|
+
const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
|
|
573
|
+
let noneStreak = 0;
|
|
574
|
+
let errorStreak = 0;
|
|
575
|
+
|
|
576
|
+
function poll() {
|
|
577
|
+
if (settled) return;
|
|
578
|
+
Usion._request('PAYMENT_RESULT_QUERY', {
|
|
579
|
+
paymentRequestId: requestId,
|
|
580
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
581
|
+
}, queryTimeoutMs).then(function(res) {
|
|
582
|
+
if (settled) return;
|
|
583
|
+
const status = res && res.status;
|
|
584
|
+
if (status === 'succeeded') {
|
|
585
|
+
finishResolve({
|
|
586
|
+
type: 'PAYMENT_SUCCESS',
|
|
587
|
+
_requestId: requestId,
|
|
588
|
+
newBalance: res.newBalance,
|
|
589
|
+
transactionId: res.transactionId,
|
|
590
|
+
receiptToken: res.receiptToken,
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (status === 'failed') {
|
|
595
|
+
finishReject(new Error(res.reason || 'Payment failed'));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (status === 'pending') {
|
|
599
|
+
// Charge still recoverable — keep waiting, no give-up budget.
|
|
600
|
+
noneStreak = 0;
|
|
601
|
+
errorStreak = 0;
|
|
602
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// 'none' or unknown — host is confident no charge exists. Confirm
|
|
606
|
+
// twice (guards a host that simply hasn't recorded yet) then reject.
|
|
607
|
+
errorStreak = 0;
|
|
608
|
+
noneStreak += 1;
|
|
609
|
+
if (noneStreak >= 2) {
|
|
610
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
614
|
+
}).catch(function() {
|
|
615
|
+
// Host didn't answer the query (e.g. an older host shell without
|
|
616
|
+
// PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
|
|
617
|
+
// then fall back to the original timeout rejection.
|
|
618
|
+
if (settled) return;
|
|
619
|
+
errorStreak += 1;
|
|
620
|
+
if (errorStreak >= maxPolls) {
|
|
621
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
625
|
+
});
|
|
488
626
|
}
|
|
627
|
+
|
|
628
|
+
poll();
|
|
489
629
|
}
|
|
490
630
|
|
|
491
631
|
window.addEventListener('message', handler);
|
|
492
|
-
const timer = setTimeout(
|
|
493
|
-
window.removeEventListener('message', handler);
|
|
494
|
-
reject(new Error('Payment confirmation timeout'));
|
|
495
|
-
}, timeoutMs);
|
|
632
|
+
const timer = setTimeout(recoverPaymentResult, timeoutMs);
|
|
496
633
|
|
|
497
634
|
// Send payment request
|
|
498
635
|
Usion._post({
|
|
@@ -500,7 +637,8 @@ var Usion = (function () {
|
|
|
500
637
|
_requestId: requestId,
|
|
501
638
|
amount: amount,
|
|
502
639
|
reason: reason,
|
|
503
|
-
data: data
|
|
640
|
+
data: data,
|
|
641
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
504
642
|
});
|
|
505
643
|
});
|
|
506
644
|
},
|
|
@@ -4835,6 +4973,77 @@ var Usion = (function () {
|
|
|
4835
4973
|
};
|
|
4836
4974
|
}
|
|
4837
4975
|
|
|
4976
|
+
/**
|
|
4977
|
+
* Usion SDK Notify — let a mini-app notify its own user.
|
|
4978
|
+
*
|
|
4979
|
+
* A notification reaches the user even when they aren't looking at the app:
|
|
4980
|
+
* - online elsewhere in Usion -> in-app banner
|
|
4981
|
+
* - offline / app backgrounded -> OS push notification
|
|
4982
|
+
* Tapping it re-opens THIS mini-app, optionally at a specific internal screen
|
|
4983
|
+
* via `path` (read back on launch with `Usion.getLaunchParams().path`).
|
|
4984
|
+
*
|
|
4985
|
+
* Rides the unified backend channel, so it works standalone AND embedded.
|
|
4986
|
+
* Scope & safety: a notification can only target the CURRENT user (you cannot
|
|
4987
|
+
* notify other people from here), and the platform rate-limits per user per
|
|
4988
|
+
* service. Users can silence a service with `setMuted(true)`.
|
|
4989
|
+
*
|
|
4990
|
+
* await Usion.notify.send({
|
|
4991
|
+
* title: 'Render complete',
|
|
4992
|
+
* body: 'Your video is ready to view.',
|
|
4993
|
+
* path: '/render/abc123', // optional — deep-links inside the app
|
|
4994
|
+
* });
|
|
4995
|
+
*
|
|
4996
|
+
* await Usion.notify.setMuted(true); // user opts out for this app
|
|
4997
|
+
* const muted = await Usion.notify.isMuted();
|
|
4998
|
+
*
|
|
4999
|
+
* For server-triggered notifications (e.g. a long-running job finishing while
|
|
5000
|
+
* the app is closed), a mini-app's own backend calls the signed REST endpoint
|
|
5001
|
+
* `POST /services/{id}/notify` instead — see the publishing reference.
|
|
5002
|
+
*/
|
|
5003
|
+
function createNotifyModule(Usion) {
|
|
5004
|
+
function serviceId(opts) {
|
|
5005
|
+
return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
return {
|
|
5009
|
+
/**
|
|
5010
|
+
* Send a notification to the current user.
|
|
5011
|
+
* @param {{title: string, body: string, path?: string, serviceId?: string}} opts
|
|
5012
|
+
* @returns {Promise<{success: boolean, delivered?: string}>}
|
|
5013
|
+
*/
|
|
5014
|
+
send: function (opts) {
|
|
5015
|
+
opts = opts || {};
|
|
5016
|
+
return Usion._backendEmit('notify:send', {
|
|
5017
|
+
service_id: serviceId(opts),
|
|
5018
|
+
title: opts.title,
|
|
5019
|
+
body: opts.body,
|
|
5020
|
+
path: opts.path,
|
|
5021
|
+
});
|
|
5022
|
+
},
|
|
5023
|
+
|
|
5024
|
+
/**
|
|
5025
|
+
* Mute (or unmute) notifications from this app for the current user.
|
|
5026
|
+
* @param {boolean} muted
|
|
5027
|
+
* @returns {Promise<{success: boolean, muted: boolean}>}
|
|
5028
|
+
*/
|
|
5029
|
+
setMuted: function (muted, opts) {
|
|
5030
|
+
return Usion._backendEmit('notify:set_pref', {
|
|
5031
|
+
service_id: serviceId(opts),
|
|
5032
|
+
muted: !!muted,
|
|
5033
|
+
});
|
|
5034
|
+
},
|
|
5035
|
+
|
|
5036
|
+
/**
|
|
5037
|
+
* Whether the current user has muted notifications from this app.
|
|
5038
|
+
* @returns {Promise<boolean>}
|
|
5039
|
+
*/
|
|
5040
|
+
isMuted: function (opts) {
|
|
5041
|
+
return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
|
|
5042
|
+
.then(function (r) { return !!(r && r.muted); });
|
|
5043
|
+
},
|
|
5044
|
+
};
|
|
5045
|
+
}
|
|
5046
|
+
|
|
4838
5047
|
/**
|
|
4839
5048
|
* Usion SDK — unified backend channel.
|
|
4840
5049
|
*
|
|
@@ -4972,6 +5181,7 @@ var Usion = (function () {
|
|
|
4972
5181
|
Usion.leaderboard = createLeaderboardModule(Usion);
|
|
4973
5182
|
Usion.cloud = createCloudModule(Usion);
|
|
4974
5183
|
Usion.matchmaking = createMatchmakingModule(Usion);
|
|
5184
|
+
Usion.notify = createNotifyModule(Usion);
|
|
4975
5185
|
|
|
4976
5186
|
// Netcode toolkit (transport-agnostic, zero-dependency).
|
|
4977
5187
|
Usion.netcode = netcode;
|
package/src/modules/core.js
CHANGED
|
@@ -209,6 +209,22 @@ export const core = {
|
|
|
209
209
|
return this.config.language || 'en';
|
|
210
210
|
},
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Launch parameters the host opened this app with. Use `path` to deep-link
|
|
214
|
+
* to a specific screen — e.g. when the user taps a notification sent via
|
|
215
|
+
* `Usion.notify.send({ path })`, the app reopens and `getLaunchParams().path`
|
|
216
|
+
* returns that same path so the app can route to it.
|
|
217
|
+
* @returns {{ path: string|null, ref: string|null, roomId: string|null }}
|
|
218
|
+
*/
|
|
219
|
+
getLaunchParams: function() {
|
|
220
|
+
var c = this.config || {};
|
|
221
|
+
return {
|
|
222
|
+
path: c.launchPath || null,
|
|
223
|
+
ref: c.ref || c.launchRef || null,
|
|
224
|
+
roomId: c.roomId || null,
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
|
|
212
228
|
/**
|
|
213
229
|
* Send message to parent app
|
|
214
230
|
* @private
|
package/src/modules/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { createLobbyModule } from './lobby.js';
|
|
|
29
29
|
import { createLeaderboardModule } from './leaderboard.js';
|
|
30
30
|
import { createCloudModule } from './cloud.js';
|
|
31
31
|
import { createMatchmakingModule } from './matchmaking.js';
|
|
32
|
+
import { createNotifyModule } from './notify.js';
|
|
32
33
|
import { applyBackendChannel } from './backend-channel.js';
|
|
33
34
|
import { netcode } from './netcode/index.js';
|
|
34
35
|
import { UsionError, ERROR_CODES } from './errors.js';
|
|
@@ -54,6 +55,7 @@ Usion.lobby = createLobbyModule(Usion);
|
|
|
54
55
|
Usion.leaderboard = createLeaderboardModule(Usion);
|
|
55
56
|
Usion.cloud = createCloudModule(Usion);
|
|
56
57
|
Usion.matchmaking = createMatchmakingModule(Usion);
|
|
58
|
+
Usion.notify = createNotifyModule(Usion);
|
|
57
59
|
|
|
58
60
|
// Netcode toolkit (transport-agnostic, zero-dependency).
|
|
59
61
|
Usion.netcode = netcode;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion SDK Notify — let a mini-app notify its own user.
|
|
3
|
+
*
|
|
4
|
+
* A notification reaches the user even when they aren't looking at the app:
|
|
5
|
+
* - online elsewhere in Usion -> in-app banner
|
|
6
|
+
* - offline / app backgrounded -> OS push notification
|
|
7
|
+
* Tapping it re-opens THIS mini-app, optionally at a specific internal screen
|
|
8
|
+
* via `path` (read back on launch with `Usion.getLaunchParams().path`).
|
|
9
|
+
*
|
|
10
|
+
* Rides the unified backend channel, so it works standalone AND embedded.
|
|
11
|
+
* Scope & safety: a notification can only target the CURRENT user (you cannot
|
|
12
|
+
* notify other people from here), and the platform rate-limits per user per
|
|
13
|
+
* service. Users can silence a service with `setMuted(true)`.
|
|
14
|
+
*
|
|
15
|
+
* await Usion.notify.send({
|
|
16
|
+
* title: 'Render complete',
|
|
17
|
+
* body: 'Your video is ready to view.',
|
|
18
|
+
* path: '/render/abc123', // optional — deep-links inside the app
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* await Usion.notify.setMuted(true); // user opts out for this app
|
|
22
|
+
* const muted = await Usion.notify.isMuted();
|
|
23
|
+
*
|
|
24
|
+
* For server-triggered notifications (e.g. a long-running job finishing while
|
|
25
|
+
* the app is closed), a mini-app's own backend calls the signed REST endpoint
|
|
26
|
+
* `POST /services/{id}/notify` instead — see the publishing reference.
|
|
27
|
+
*/
|
|
28
|
+
export function createNotifyModule(Usion) {
|
|
29
|
+
function serviceId(opts) {
|
|
30
|
+
return (opts && opts.serviceId) || (Usion.config && Usion.config.serviceId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Send a notification to the current user.
|
|
36
|
+
* @param {{title: string, body: string, path?: string, serviceId?: string}} opts
|
|
37
|
+
* @returns {Promise<{success: boolean, delivered?: string}>}
|
|
38
|
+
*/
|
|
39
|
+
send: function (opts) {
|
|
40
|
+
opts = opts || {};
|
|
41
|
+
return Usion._backendEmit('notify:send', {
|
|
42
|
+
service_id: serviceId(opts),
|
|
43
|
+
title: opts.title,
|
|
44
|
+
body: opts.body,
|
|
45
|
+
path: opts.path,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mute (or unmute) notifications from this app for the current user.
|
|
51
|
+
* @param {boolean} muted
|
|
52
|
+
* @returns {Promise<{success: boolean, muted: boolean}>}
|
|
53
|
+
*/
|
|
54
|
+
setMuted: function (muted, opts) {
|
|
55
|
+
return Usion._backendEmit('notify:set_pref', {
|
|
56
|
+
service_id: serviceId(opts),
|
|
57
|
+
muted: !!muted,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the current user has muted notifications from this app.
|
|
63
|
+
* @returns {Promise<boolean>}
|
|
64
|
+
*/
|
|
65
|
+
isMuted: function (opts) {
|
|
66
|
+
return Usion._backendEmit('notify:get_pref', { service_id: serviceId(opts) })
|
|
67
|
+
.then(function (r) { return !!(r && r.muted); });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/modules/wallet.js
CHANGED
|
@@ -12,6 +12,13 @@ export function createWalletModule(Usion) {
|
|
|
12
12
|
_balance: null,
|
|
13
13
|
_balanceChangeHandler: null,
|
|
14
14
|
|
|
15
|
+
// Payment timing knobs (overridable, mainly for tests). The defaults are
|
|
16
|
+
// the production values.
|
|
17
|
+
_paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
|
|
18
|
+
_recoveryPollMs: 2000, // gap between result-recovery re-queries
|
|
19
|
+
_recoveryMaxPolls: 10, // bounded recovery window after the timeout
|
|
20
|
+
_recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
|
|
21
|
+
|
|
15
22
|
/**
|
|
16
23
|
* Get current wallet balance
|
|
17
24
|
* @returns {Promise<number>} Balance in credits
|
|
@@ -42,20 +49,69 @@ export function createWalletModule(Usion) {
|
|
|
42
49
|
},
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
|
-
* Request payment from user with balance check
|
|
52
|
+
* Request payment from user with balance check.
|
|
53
|
+
*
|
|
54
|
+
* The charge is debited immediately by the host (escrow); the resolved
|
|
55
|
+
* value carries a `receiptToken` the mini-app server later settles/refunds.
|
|
56
|
+
*
|
|
57
|
+
* Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
|
|
58
|
+
* backgrounded tab, race) the user may ALREADY be charged. Instead of
|
|
59
|
+
* rejecting at the timeout, this re-queries the host for the result and
|
|
60
|
+
* recovers the receipt token — so a dropped message never strands a paid
|
|
61
|
+
* charge. Pass `idempotencyKey` to additionally guarantee that any retry of
|
|
62
|
+
* the SAME intent reuses the SAME charge (the host + backend dedupe on it),
|
|
63
|
+
* so the user can never be charged twice even across reloads.
|
|
64
|
+
*
|
|
46
65
|
* @param {number} amount - Credit amount to charge
|
|
47
66
|
* @param {string} reason - Description shown to user
|
|
48
|
-
* @param {object} data - Optional
|
|
67
|
+
* @param {object} [data] - Optional options/payload. `data.idempotencyKey`
|
|
68
|
+
* (string) makes the charge safely retryable. Any other fields are
|
|
69
|
+
* forwarded to the host verbatim (backward compatible).
|
|
49
70
|
* @returns {Promise} Resolves on payment success, rejects on failure
|
|
50
71
|
*/
|
|
51
72
|
requestPayment: function(amount, reason, data) {
|
|
52
73
|
const self = this;
|
|
53
74
|
|
|
75
|
+
const opts = (data && typeof data === 'object') ? data : null;
|
|
76
|
+
const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
|
|
77
|
+
? opts.idempotencyKey
|
|
78
|
+
: null;
|
|
79
|
+
|
|
54
80
|
return new Promise(function(resolve, reject) {
|
|
55
81
|
const requestId = getNextRequestId();
|
|
56
|
-
const timeoutMs = 60000;
|
|
82
|
+
const timeoutMs = self._paymentTimeoutMs || 60000;
|
|
83
|
+
let settled = false;
|
|
84
|
+
let recoverTimer = null;
|
|
85
|
+
|
|
86
|
+
function cleanup() {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
89
|
+
window.removeEventListener('message', handler);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function finishResolve(response) {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
cleanup();
|
|
96
|
+
// Update cached balance from the authoritative new balance, else
|
|
97
|
+
// best-effort subtract (no-op if balance is unknown).
|
|
98
|
+
if (response && response.newBalance !== undefined) {
|
|
99
|
+
self._balance = response.newBalance;
|
|
100
|
+
} else if (self._balance !== null) {
|
|
101
|
+
self._balance -= amount;
|
|
102
|
+
}
|
|
103
|
+
resolve(response);
|
|
104
|
+
}
|
|
57
105
|
|
|
58
|
-
|
|
106
|
+
function finishReject(error) {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Direct response from the host. Kept registered through the recovery
|
|
114
|
+
// window so a late PAYMENT_SUCCESS still resolves.
|
|
59
115
|
function handler(event) {
|
|
60
116
|
// Only honor payment results from the trusted host shell — a forged
|
|
61
117
|
// PAYMENT_SUCCESS must never resolve this promise.
|
|
@@ -69,32 +125,97 @@ export function createWalletModule(Usion) {
|
|
|
69
125
|
}
|
|
70
126
|
|
|
71
127
|
// Only accept responses for this specific payment request.
|
|
72
|
-
if (response._requestId !== requestId) {
|
|
128
|
+
if (!response || response._requestId !== requestId) {
|
|
73
129
|
return;
|
|
74
130
|
}
|
|
75
131
|
|
|
76
132
|
if (response.type === 'PAYMENT_SUCCESS') {
|
|
77
|
-
|
|
78
|
-
window.removeEventListener('message', handler);
|
|
79
|
-
// Update cached balance
|
|
80
|
-
if (response.newBalance !== undefined) {
|
|
81
|
-
self._balance = response.newBalance;
|
|
82
|
-
} else if (self._balance !== null) {
|
|
83
|
-
self._balance -= amount;
|
|
84
|
-
}
|
|
85
|
-
resolve(response);
|
|
133
|
+
finishResolve(response);
|
|
86
134
|
} else if (response.type === 'PAYMENT_FAILED') {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
135
|
+
finishReject(new Error(response.reason || 'Payment failed'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// After the direct deadline, re-query the host for this payment's
|
|
140
|
+
// result instead of rejecting.
|
|
141
|
+
// succeeded -> resolve with the recovered token
|
|
142
|
+
// failed -> reject
|
|
143
|
+
// pending -> the modal is STILL OPEN / charge still in progress, so
|
|
144
|
+
// the charge is fully recoverable: keep waiting (do NOT
|
|
145
|
+
// count this toward the give-up cap). A slow user who
|
|
146
|
+
// confirms minutes later still resolves — via the next
|
|
147
|
+
// 'succeeded' poll or the direct handler, which stays
|
|
148
|
+
// registered. Without this, a charge made after we gave
|
|
149
|
+
// up would be stranded (the original bug, shifted to the
|
|
150
|
+
// slow-confirm case).
|
|
151
|
+
// none/other -> likely no charge; confirm twice then reject cleanly so
|
|
152
|
+
// a fresh requestPayment is safe.
|
|
153
|
+
// The bounded cap only governs the give-up paths (none-streak and a host
|
|
154
|
+
// that never answers the query at all, e.g. an older host shell).
|
|
155
|
+
function recoverPaymentResult() {
|
|
156
|
+
const pollMs = self._recoveryPollMs || 2000;
|
|
157
|
+
const maxPolls = self._recoveryMaxPolls || 10;
|
|
158
|
+
const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
|
|
159
|
+
let noneStreak = 0;
|
|
160
|
+
let errorStreak = 0;
|
|
161
|
+
|
|
162
|
+
function poll() {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
Usion._request('PAYMENT_RESULT_QUERY', {
|
|
165
|
+
paymentRequestId: requestId,
|
|
166
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
167
|
+
}, queryTimeoutMs).then(function(res) {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
const status = res && res.status;
|
|
170
|
+
if (status === 'succeeded') {
|
|
171
|
+
finishResolve({
|
|
172
|
+
type: 'PAYMENT_SUCCESS',
|
|
173
|
+
_requestId: requestId,
|
|
174
|
+
newBalance: res.newBalance,
|
|
175
|
+
transactionId: res.transactionId,
|
|
176
|
+
receiptToken: res.receiptToken,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (status === 'failed') {
|
|
181
|
+
finishReject(new Error(res.reason || 'Payment failed'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (status === 'pending') {
|
|
185
|
+
// Charge still recoverable — keep waiting, no give-up budget.
|
|
186
|
+
noneStreak = 0;
|
|
187
|
+
errorStreak = 0;
|
|
188
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// 'none' or unknown — host is confident no charge exists. Confirm
|
|
192
|
+
// twice (guards a host that simply hasn't recorded yet) then reject.
|
|
193
|
+
errorStreak = 0;
|
|
194
|
+
noneStreak += 1;
|
|
195
|
+
if (noneStreak >= 2) {
|
|
196
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
200
|
+
}).catch(function() {
|
|
201
|
+
// Host didn't answer the query (e.g. an older host shell without
|
|
202
|
+
// PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
|
|
203
|
+
// then fall back to the original timeout rejection.
|
|
204
|
+
if (settled) return;
|
|
205
|
+
errorStreak += 1;
|
|
206
|
+
if (errorStreak >= maxPolls) {
|
|
207
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
211
|
+
});
|
|
90
212
|
}
|
|
213
|
+
|
|
214
|
+
poll();
|
|
91
215
|
}
|
|
92
216
|
|
|
93
217
|
window.addEventListener('message', handler);
|
|
94
|
-
const timer = setTimeout(
|
|
95
|
-
window.removeEventListener('message', handler);
|
|
96
|
-
reject(new Error('Payment confirmation timeout'));
|
|
97
|
-
}, timeoutMs);
|
|
218
|
+
const timer = setTimeout(recoverPaymentResult, timeoutMs);
|
|
98
219
|
|
|
99
220
|
// Send payment request
|
|
100
221
|
Usion._post({
|
|
@@ -102,7 +223,8 @@ export function createWalletModule(Usion) {
|
|
|
102
223
|
_requestId: requestId,
|
|
103
224
|
amount: amount,
|
|
104
225
|
reason: reason,
|
|
105
|
-
data: data
|
|
226
|
+
data: data,
|
|
227
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
106
228
|
});
|
|
107
229
|
});
|
|
108
230
|
},
|
package/types/index.d.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface UsionConfig {
|
|
|
24
24
|
serviceName?: string;
|
|
25
25
|
apiUrl?: string;
|
|
26
26
|
connectionMode?: 'platform' | 'direct';
|
|
27
|
+
/**
|
|
28
|
+
* Internal path the host opened this app at — set when the user taps a
|
|
29
|
+
* notification carrying a `path`. Read via `Usion.getLaunchParams().path`.
|
|
30
|
+
*/
|
|
31
|
+
launchPath?: string;
|
|
32
|
+
/** Referral code the app was opened with, if any. */
|
|
33
|
+
ref?: string;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
// ─── Payment ─────────────────────────────────────────────────────
|
|
@@ -36,6 +43,17 @@ export interface PaymentResponse {
|
|
|
36
43
|
transactionId?: string;
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
export interface PaymentOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Optional idempotency key. When set, the host + backend dedupe on it so any
|
|
49
|
+
* retry of the SAME intent (network error, reload, double-tap) reuses the
|
|
50
|
+
* SAME charge instead of debiting again, and returns the same receiptToken.
|
|
51
|
+
* Omit for the legacy one-shot behavior.
|
|
52
|
+
*/
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
[key: string]: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
// ─── User ────────────────────────────────────────────────────────
|
|
40
58
|
|
|
41
59
|
export interface UserProfile {
|
|
@@ -75,7 +93,7 @@ export interface FileStorageModule {
|
|
|
75
93
|
export interface WalletModule {
|
|
76
94
|
getBalance(): Promise<number>;
|
|
77
95
|
hasCredits(amount: number): Promise<boolean>;
|
|
78
|
-
requestPayment(amount: number, reason: string, data?:
|
|
96
|
+
requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
|
|
79
97
|
onBalanceChange(callback: (balance: number) => void): UnsubscribeFn;
|
|
80
98
|
}
|
|
81
99
|
|
|
@@ -753,8 +771,15 @@ export interface UsionSDK {
|
|
|
753
771
|
getTheme(): 'light' | 'dark';
|
|
754
772
|
getLanguage(): string;
|
|
755
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Launch parameters the host opened this app with. `path` is the deep-link
|
|
776
|
+
* target (e.g. from a tapped `Usion.notify` notification); route to it after
|
|
777
|
+
* init so the user lands on the right screen.
|
|
778
|
+
*/
|
|
779
|
+
getLaunchParams(): { path: string | null; ref: string | null; roomId: string | null };
|
|
780
|
+
|
|
756
781
|
// Payments
|
|
757
|
-
requestPayment(amount: number, reason: string, data?:
|
|
782
|
+
requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
|
|
758
783
|
|
|
759
784
|
// Service communication
|
|
760
785
|
submit(data: Record<string, any>): void;
|
|
@@ -831,9 +856,24 @@ export interface UsionSDK {
|
|
|
831
856
|
leaderboard: LeaderboardModule;
|
|
832
857
|
matchmaking: MatchmakingModule;
|
|
833
858
|
cloud: CloudModule;
|
|
859
|
+
notify: NotifyModule;
|
|
834
860
|
netcode: NetcodeModule;
|
|
835
861
|
}
|
|
836
862
|
|
|
863
|
+
/**
|
|
864
|
+
* Send notifications to the CURRENT user that reopen this app (in-app banner
|
|
865
|
+
* when online, OS push when offline). `path` deep-links to a screen, read back
|
|
866
|
+
* via `Usion.getLaunchParams().path`. Rate-limited per user per service.
|
|
867
|
+
*/
|
|
868
|
+
export interface NotifyModule {
|
|
869
|
+
/** Notify the current user. */
|
|
870
|
+
send(opts: { title: string; body: string; path?: string; serviceId?: string }): Promise<{ success: boolean; delivered?: string }>;
|
|
871
|
+
/** Mute/unmute notifications from this app for the current user. */
|
|
872
|
+
setMuted(muted: boolean, opts?: { serviceId?: string }): Promise<{ success: boolean; muted: boolean }>;
|
|
873
|
+
/** Whether the current user has muted this app's notifications. */
|
|
874
|
+
isMuted(opts?: { serviceId?: string }): Promise<boolean>;
|
|
875
|
+
}
|
|
876
|
+
|
|
837
877
|
/** Scoped server-persisted KV operations (per-user or shared). */
|
|
838
878
|
export interface CloudScope {
|
|
839
879
|
/** Get a value; resolves to null when the key doesn't exist. */
|