vimp-engine 0.37.0 → 0.38.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/client/boot.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +97 -15
- package/src/client/components/controller/Profile.js +85 -0
- package/src/client/components/model/Games.js +1 -1
- package/src/client/components/model/LobbyAuth.js +344 -115
- package/src/client/components/model/Profile.js +142 -0
- package/src/client/components/view/LobbyAuth.js +150 -62
- package/src/client/components/view/Profile.js +373 -0
- package/src/client/lib/authApi.js +97 -0
- package/src/client/lib/pkce.js +23 -0
- package/src/client/lib/roomLink.js +1 -1
- package/src/client/lib/signalingErrors.js +3 -3
- package/src/client/lib/turnstile.js +130 -0
- package/src/client/main.js +89 -24
- package/src/client/network/TokenRenewer.js +90 -0
- package/src/client/session/GuestSession.js +79 -19
- package/src/client/style.css +119 -16
- package/src/client/views/includes/lobby.pug +11 -6
- package/src/client/views/includes/lobbyAuth.pug +34 -16
- package/src/client/views/includes/profile.pug +73 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/authClient.js +54 -30
- package/src/config/chatMessages.js +1 -2
- package/src/config/env.js +2 -2
- package/src/config/lobby.js +50 -1
- package/src/config/master.js +15 -8
- package/src/config/wsports.js +4 -1
- package/src/devtools/builtinScenario.js +1 -1
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +1 -1
- package/src/host/GameCoreAdapter.js +0 -3
- package/src/host/HostGame.js +28 -0
- package/src/host/PortMachine.js +50 -1
- package/src/host/meta/SocketManager.js +0 -13
- package/src/host/meta/core/RoundManager.js +0 -19
- package/src/host/meta/modules/PlayerDataSync.js +29 -3
- package/src/host/meta/modules/chat/systemMessages.js +1 -4
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/validators.js +8 -0
- package/src/standalone/index.js +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// REST-клиент auth-сервиса (packages/auth): регистрация, сессия, OAuth,
|
|
2
|
+
// управление профилем. Без DOM; fetch инъектируется (тесты).
|
|
3
|
+
//
|
|
4
|
+
// Каждый метод возвращает { ok: true, status, data } или
|
|
5
|
+
// { ok: false, status, error }: error — поле `error` тела ответа ('unknown',
|
|
6
|
+
// если его нет), 'network' — fetch бросил исключение (status 0).
|
|
7
|
+
|
|
8
|
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} options.serviceUrl - базовый URL auth-сервиса.
|
|
13
|
+
* @param {Function} [options.fetchImpl]
|
|
14
|
+
*/
|
|
15
|
+
export function createAuthApi({
|
|
16
|
+
serviceUrl,
|
|
17
|
+
fetchImpl = (...args) => fetch(...args),
|
|
18
|
+
}) {
|
|
19
|
+
// куки не используются: личность — заголовок Authorization
|
|
20
|
+
async function request(method, path, { session, body } = {}) {
|
|
21
|
+
const headers = {};
|
|
22
|
+
|
|
23
|
+
if (body !== undefined) {
|
|
24
|
+
Object.assign(headers, JSON_HEADERS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (session) {
|
|
28
|
+
headers.Authorization = `Session ${session}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const init = { method, headers };
|
|
32
|
+
|
|
33
|
+
if (body !== undefined) {
|
|
34
|
+
init.body = JSON.stringify(body);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let res;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
res = await fetchImpl(`${serviceUrl}${path}`, init);
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, status: 0, error: 'network' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
status: res.status,
|
|
51
|
+
error: (data && data.error) || 'unknown',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: true, status: res.status, data };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
clientConfig: () => request('GET', '/client-config'),
|
|
60
|
+
|
|
61
|
+
register: ({ nick, turnstileToken, termsVersion }) =>
|
|
62
|
+
request('POST', '/register', {
|
|
63
|
+
body: { nick, turnstileToken, termsVersion },
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
token: session => request('POST', '/token', { session }),
|
|
67
|
+
|
|
68
|
+
logout: session => request('POST', '/logout', { session }),
|
|
69
|
+
|
|
70
|
+
restore: ({ code, verifier, termsVersion }) =>
|
|
71
|
+
request('POST', '/session/restore', {
|
|
72
|
+
body: { code, verifier, termsVersion },
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
linkOauth: (session, { code, verifier, consentVersion }) =>
|
|
76
|
+
request('POST', '/account/oauth', {
|
|
77
|
+
session,
|
|
78
|
+
body: { code, verifier, consentVersion },
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
unlinkOauth: (session, provider) =>
|
|
82
|
+
request('DELETE', `/account/oauth/${encodeURIComponent(provider)}`, {
|
|
83
|
+
session,
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
deleteAccount: (session, confirmNick) =>
|
|
87
|
+
request('DELETE', '/account', { session, body: { confirmNick } }),
|
|
88
|
+
|
|
89
|
+
exportData: session => request('GET', '/account/export', { session }),
|
|
90
|
+
|
|
91
|
+
// навигация вкладки, не fetch: URL отдаётся вызывающему
|
|
92
|
+
oauthStartUrl: (provider, { mode, returnUrl, challenge }) =>
|
|
93
|
+
`${serviceUrl}/oauth/${provider}/start?mode=${encodeURIComponent(mode)}` +
|
|
94
|
+
`&returnUrl=${encodeURIComponent(returnUrl)}` +
|
|
95
|
+
`&challenge=${encodeURIComponent(challenge)}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// PKCE-подобная привязка результата OAuth к начавшей вкладке: verifier
|
|
2
|
+
// остаётся в её sessionStorage, на auth уходит только challenge.
|
|
3
|
+
|
|
4
|
+
const toBase64Url = bytes =>
|
|
5
|
+
btoa(String.fromCharCode(...bytes))
|
|
6
|
+
.replace(/\+/g, '-')
|
|
7
|
+
.replace(/\//g, '_')
|
|
8
|
+
.replace(/=+$/, '');
|
|
9
|
+
|
|
10
|
+
// 32 случайных байта → base64url (43 символа)
|
|
11
|
+
export function createVerifier(cryptoImpl = crypto) {
|
|
12
|
+
return toBase64Url(cryptoImpl.getRandomValues(new Uint8Array(32)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// base64url(sha256(verifier)); Web Crypto, поэтому асинхронно
|
|
16
|
+
export async function challengeOf(verifier, cryptoImpl = crypto) {
|
|
17
|
+
const digest = await cryptoImpl.subtle.digest(
|
|
18
|
+
'SHA-256',
|
|
19
|
+
new TextEncoder().encode(verifier),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return toBase64Url(new Uint8Array(digest));
|
|
23
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// #/<gameId>/<roomId> — прямой вход в комнату
|
|
6
6
|
//
|
|
7
7
|
// Маршрут живёт в hash: страница одна (SPA), сервер про маршруты не знает, а
|
|
8
|
-
// OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.
|
|
8
|
+
// OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.restoreUrl). Решения
|
|
9
9
|
// «что делать с маршрутом» и «куда уходить из комнаты» — чистые функции:
|
|
10
10
|
// main.js исполняется при импорте, и проверить ветвление внутри него нечем.
|
|
11
11
|
|
|
@@ -52,13 +52,13 @@ export function decideUnknownRoom({
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Что делать с error {code: 'invalidToken'} мастера (
|
|
55
|
+
* Что делать с error {code: 'invalidToken'} мастера (токен отклонён).
|
|
56
56
|
* @param {Object} ctx
|
|
57
57
|
* @param {Object} ctx.msg - сообщение ошибки ({ code, re, roomId }).
|
|
58
58
|
* @param {boolean} ctx.promoting - вкладка занимает комнату (hostPromotion).
|
|
59
59
|
* @param {boolean} ctx.inRoom - вкладка в комнате.
|
|
60
60
|
* @param {string|null} ctx.sessionState - состояние SessionSupervisor.
|
|
61
|
-
* @returns {'abandonPromotion'|'keepPlaying'|'
|
|
61
|
+
* @returns {'abandonPromotion'|'keepPlaying'|'renewAndRetry'}
|
|
62
62
|
*/
|
|
63
63
|
export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
|
|
64
64
|
if (msg.re === 'register_host' && promoting) {
|
|
@@ -70,5 +70,5 @@ export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
|
|
|
70
70
|
return 'keepPlaying';
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
return
|
|
73
|
+
return 'renewAndRetry';
|
|
74
74
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Cloudflare Turnstile на регистрации: ленивая загрузка скрипта, один
|
|
2
|
+
// виджет, токен на одну отправку формы. Без сети в тестах: документ, окно и
|
|
3
|
+
// таймеры инъектируются.
|
|
4
|
+
|
|
5
|
+
// code: 'blocked' — скрипт или виджет недоступны (блокировщик, ошибка
|
|
6
|
+
// виджета); 'expired' — проверка истекла, не дождавшись игрока
|
|
7
|
+
function turnstileError(code) {
|
|
8
|
+
const error = new Error(`Turnstile ${code}`);
|
|
9
|
+
|
|
10
|
+
error.code = code;
|
|
11
|
+
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const blockedError = () => turnstileError('blocked');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {string} options.scriptUrl
|
|
20
|
+
* @param {number} options.loadTimeoutMs
|
|
21
|
+
* @param {string} options.action
|
|
22
|
+
* @param {Document} [options.doc]
|
|
23
|
+
* @param {Window} [options.win]
|
|
24
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
25
|
+
*/
|
|
26
|
+
export function createTurnstile({
|
|
27
|
+
scriptUrl,
|
|
28
|
+
loadTimeoutMs,
|
|
29
|
+
action,
|
|
30
|
+
doc = document,
|
|
31
|
+
win = window,
|
|
32
|
+
timers = globalThis,
|
|
33
|
+
}) {
|
|
34
|
+
let widgetId = null;
|
|
35
|
+
// виджет уже отдавал токен: перед следующим execute нужен reset
|
|
36
|
+
let used = false;
|
|
37
|
+
// текущий getToken: виджет создаётся один раз, а его callback'и должны
|
|
38
|
+
// завершать именно ожидающий вызов, не первый
|
|
39
|
+
let pending = null;
|
|
40
|
+
|
|
41
|
+
// скрипт подгружается при первом обращении; отказ (error, таймаут, нет
|
|
42
|
+
// win.turnstile после load) убирает элемент — Retry загрузит его заново
|
|
43
|
+
function loadScript() {
|
|
44
|
+
if (win.turnstile) {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const script = doc.createElement('script');
|
|
50
|
+
let timer = null;
|
|
51
|
+
|
|
52
|
+
const fail = () => {
|
|
53
|
+
timers.clearTimeout(timer);
|
|
54
|
+
script.remove();
|
|
55
|
+
reject(blockedError());
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
timer = timers.setTimeout(fail, loadTimeoutMs);
|
|
59
|
+
|
|
60
|
+
script.addEventListener('load', () => {
|
|
61
|
+
if (!win.turnstile) {
|
|
62
|
+
fail();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
timers.clearTimeout(timer);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
script.addEventListener('error', fail);
|
|
70
|
+
|
|
71
|
+
script.src = scriptUrl;
|
|
72
|
+
script.async = true;
|
|
73
|
+
doc.head.appendChild(script);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
// токен на одну отправку формы; отказ — Error с полем code = 'blocked'
|
|
79
|
+
async getToken(container, siteKey) {
|
|
80
|
+
await loadScript();
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
pending = { resolve, reject };
|
|
84
|
+
|
|
85
|
+
if (widgetId === null) {
|
|
86
|
+
widgetId = win.turnstile.render(container, {
|
|
87
|
+
sitekey: siteKey,
|
|
88
|
+
action,
|
|
89
|
+
execution: 'execute',
|
|
90
|
+
appearance: 'interaction-only',
|
|
91
|
+
callback: token => {
|
|
92
|
+
used = true;
|
|
93
|
+
pending?.resolve(token);
|
|
94
|
+
},
|
|
95
|
+
'error-callback': () => {
|
|
96
|
+
// после ошибки виджету нужен reset перед следующим execute
|
|
97
|
+
used = true;
|
|
98
|
+
pending?.reject(blockedError());
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
// проверка истекла, пока игрок не ответил: без этого getToken
|
|
102
|
+
// не завершился бы и форма осталась заблокированной
|
|
103
|
+
'timeout-callback': () => {
|
|
104
|
+
used = true;
|
|
105
|
+
pending?.reject(turnstileError('expired'));
|
|
106
|
+
},
|
|
107
|
+
'expired-callback': () => {
|
|
108
|
+
used = true;
|
|
109
|
+
pending?.reject(turnstileError('expired'));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} else if (used) {
|
|
113
|
+
win.turnstile.reset(widgetId);
|
|
114
|
+
used = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// срока ожидания нет: интерактивная проверка может ждать клика
|
|
118
|
+
win.turnstile.execute(container);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// токен израсходован сервером: следующая отправка получит новый
|
|
123
|
+
reset() {
|
|
124
|
+
if (widgetId !== null) {
|
|
125
|
+
win.turnstile.reset(widgetId);
|
|
126
|
+
used = false;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -69,6 +69,10 @@ import SessionSupervisor, {
|
|
|
69
69
|
import FpsMeter from './lib/FpsMeter.js';
|
|
70
70
|
import HostUnloadGuard from './lib/hostUnloadGuard.js';
|
|
71
71
|
import { createPageReload } from './lib/pageReload.js';
|
|
72
|
+
import { createAuthApi } from './lib/authApi.js';
|
|
73
|
+
import { createVerifier, challengeOf } from './lib/pkce.js';
|
|
74
|
+
import { createTurnstile } from './lib/turnstile.js';
|
|
75
|
+
import TokenRenewer from './network/TokenRenewer.js';
|
|
72
76
|
import {
|
|
73
77
|
isKickClose,
|
|
74
78
|
POLICY_CLOSE_INFORMS,
|
|
@@ -78,11 +82,14 @@ import LobbyModel from './components/model/Lobby.js';
|
|
|
78
82
|
import LobbyView from './components/view/Lobby.js';
|
|
79
83
|
import LobbyCtrl from './components/controller/Lobby.js';
|
|
80
84
|
import LobbyAuthModel from './components/model/LobbyAuth.js';
|
|
85
|
+
import ProfileModel from './components/model/Profile.js';
|
|
81
86
|
import LobbyAuthView from './components/view/LobbyAuth.js';
|
|
82
87
|
import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
|
|
83
88
|
import GamesModel from './components/model/Games.js';
|
|
84
89
|
import GamesView from './components/view/Games.js';
|
|
85
90
|
import GamesCtrl from './components/controller/Games.js';
|
|
91
|
+
import ProfileView from './components/view/Profile.js';
|
|
92
|
+
import ProfileCtrl from './components/controller/Profile.js';
|
|
86
93
|
import { createRoomContext } from './session/roomContext.js';
|
|
87
94
|
import { createHostRoomPrep } from './session/hostRoomPrep.js';
|
|
88
95
|
import Membership from './session/Membership.js';
|
|
@@ -274,7 +281,6 @@ const PS_FIRST_SHOT_DATA = wsports.server.FIRST_SHOT_DATA;
|
|
|
274
281
|
const PS_SOUND_DATA = wsports.server.SOUND_DATA;
|
|
275
282
|
const PS_GAME_INFORM_DATA = wsports.server.GAME_INFORM_DATA;
|
|
276
283
|
const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
|
|
277
|
-
const PS_MISC = wsports.server.MISC;
|
|
278
284
|
const PS_PING = wsports.server.PING;
|
|
279
285
|
const PS_CLEAR = wsports.server.CLEAR;
|
|
280
286
|
const PS_CONSOLE = wsports.server.CONSOLE;
|
|
@@ -290,6 +296,7 @@ const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
|
|
|
290
296
|
// PC (client ports): порты получения данных от клиента
|
|
291
297
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
292
298
|
const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
|
|
299
|
+
const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
|
|
293
300
|
const PC_MODULES_READY = wsports.client.MODULES_READY;
|
|
294
301
|
const PC_MAP_READY = wsports.client.MAP_READY;
|
|
295
302
|
const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
|
|
@@ -978,15 +985,6 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
|
|
|
978
985
|
}
|
|
979
986
|
};
|
|
980
987
|
|
|
981
|
-
// misc
|
|
982
|
-
socketMethods[PS_MISC] = data => {
|
|
983
|
-
const { key, value } = data;
|
|
984
|
-
|
|
985
|
-
if (key === 'localstorageNameReplace') {
|
|
986
|
-
localStorage['userName'] = value;
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
|
|
990
988
|
// ping
|
|
991
989
|
socketMethods[PS_PING] = pingId => {
|
|
992
990
|
sending(PC_PONG, pingId, false);
|
|
@@ -1909,7 +1907,7 @@ const guest = new GuestSession({
|
|
|
1909
1907
|
getPromotion: () => promotionFlow,
|
|
1910
1908
|
getSupervisor,
|
|
1911
1909
|
getToken,
|
|
1912
|
-
|
|
1910
|
+
refreshIdentity: () => lobbyAuthModel.refresh(),
|
|
1913
1911
|
config: lobbyConfig,
|
|
1914
1912
|
diagnostics,
|
|
1915
1913
|
ui: {
|
|
@@ -2493,6 +2491,17 @@ function initLobby() {
|
|
|
2493
2491
|
clientReportsView.setGames([...gamesById.values()]);
|
|
2494
2492
|
clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2495
2493
|
|
|
2494
|
+
// панель профиля: привязка аккаунта, выход, выгрузка данных, удаление
|
|
2495
|
+
const profileModel = new ProfileModel(lobbyConfig.profile, {
|
|
2496
|
+
auth: lobbyAuthModel,
|
|
2497
|
+
api: authApi,
|
|
2498
|
+
});
|
|
2499
|
+
const profileView = new ProfileView(profileModel, lobbyConfig.profile);
|
|
2500
|
+
const profile = new ProfileCtrl(profileModel, profileView);
|
|
2501
|
+
|
|
2502
|
+
// выход и удаление меняют личность вкладки — только перезагрузкой
|
|
2503
|
+
profile.publisher.on('left', () => reloadPage());
|
|
2504
|
+
|
|
2496
2505
|
// «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
|
|
2497
2506
|
// админ поднимает по нему комнату обычной кнопкой Create server
|
|
2498
2507
|
games.publisher.on('staged', ({ manifest }) => {
|
|
@@ -2595,13 +2604,21 @@ function initLobby() {
|
|
|
2595
2604
|
});
|
|
2596
2605
|
|
|
2597
2606
|
lobby.open();
|
|
2607
|
+
|
|
2608
|
+
// вкладка вернулась от провайдера: итог привязки показывает панель
|
|
2609
|
+
const linkResult = profileModel.takeLinkResult();
|
|
2610
|
+
|
|
2611
|
+
if (linkResult) {
|
|
2612
|
+
profile.openWithLinkResult(linkResult);
|
|
2613
|
+
}
|
|
2598
2614
|
}
|
|
2599
2615
|
|
|
2600
|
-
//
|
|
2601
|
-
// только
|
|
2602
|
-
//
|
|
2603
|
-
// (OAuth
|
|
2616
|
+
// экран профиля: central auth-сервис выдаёт JWT по сессии устройства, лобби
|
|
2617
|
+
// открывается только когда у вкладки есть личность (профиль с глобальным
|
|
2618
|
+
// ником). Не зависит от сигнального сокета мастера — читает query string
|
|
2619
|
+
// (возврат с OAuth) и localStorage независимо от 'welcome'
|
|
2604
2620
|
let lobbyAuthModel = null;
|
|
2621
|
+
let authApi = null;
|
|
2605
2622
|
let lobbyAuthView = null;
|
|
2606
2623
|
|
|
2607
2624
|
let welcomeReceived = false;
|
|
@@ -2656,13 +2673,32 @@ function leaveRoomWith(message) {
|
|
|
2656
2673
|
}
|
|
2657
2674
|
}
|
|
2658
2675
|
|
|
2659
|
-
// точка 2 ветвления: лобби,
|
|
2676
|
+
// точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
|
|
2660
2677
|
// solo и dedicated идут сразу к транспорту
|
|
2661
2678
|
if (isLobbyMode) {
|
|
2662
|
-
|
|
2679
|
+
authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
|
|
2680
|
+
|
|
2681
|
+
lobbyAuthModel = new LobbyAuthModel(authClientConfig, {
|
|
2682
|
+
api: authApi,
|
|
2683
|
+
pkce: { createVerifier, challengeOf },
|
|
2684
|
+
isDev: isDevBuild,
|
|
2685
|
+
});
|
|
2663
2686
|
lobbyAuthView = new LobbyAuthView(lobbyAuthModel, authClientConfig);
|
|
2664
2687
|
|
|
2665
|
-
|
|
2688
|
+
// контроллер подписывается на вид сам
|
|
2689
|
+
new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView, {
|
|
2690
|
+
turnstile: createTurnstile(authClientConfig.turnstile),
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2693
|
+
// тихое продление identity-токена: вкладка остаётся в аккаунте, пока жива
|
|
2694
|
+
// сессия устройства
|
|
2695
|
+
const tokenRenewer = new TokenRenewer({
|
|
2696
|
+
getExpiresAt: getTokenExpiresAt,
|
|
2697
|
+
renew: () => lobbyAuthModel.refresh(),
|
|
2698
|
+
onLost: () => {},
|
|
2699
|
+
leadMs: authClientConfig.renew.leadMs,
|
|
2700
|
+
retryMs: authClientConfig.renew.retryMs,
|
|
2701
|
+
});
|
|
2666
2702
|
|
|
2667
2703
|
signaling.publisher.on('welcome', () => {
|
|
2668
2704
|
welcomeReceived = true;
|
|
@@ -2695,16 +2731,43 @@ if (isLobbyMode) {
|
|
|
2695
2731
|
handoff.armTokenHandoff();
|
|
2696
2732
|
}
|
|
2697
2733
|
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2734
|
+
tokenRenewer.arm();
|
|
2735
|
+
});
|
|
2736
|
+
|
|
2737
|
+
// свежий токен — мастеру (следующим сообщением сигналинга), хосту (порт
|
|
2738
|
+
// TOKEN_REFRESH) и таймерам роли хоста
|
|
2739
|
+
lobbyAuthModel.publisher.on('token-renewed', () => {
|
|
2740
|
+
tokenRenewer.arm();
|
|
2741
|
+
membership.armTokenCapsTimer();
|
|
2742
|
+
// вкладка в комнате возвращает мастеру canHost по новому сроку
|
|
2743
|
+
membership.sendUpdate();
|
|
2744
|
+
|
|
2745
|
+
if (hostRole.controller) {
|
|
2746
|
+
handoff.armTokenHandoff();
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
if (sessionStarted && supervisor) {
|
|
2750
|
+
sending(PC_TOKEN_REFRESH, { token: getToken() });
|
|
2702
2751
|
}
|
|
2703
2752
|
});
|
|
2704
2753
|
|
|
2705
|
-
//
|
|
2754
|
+
// личность вкладки меняется только перезагрузкой страницы; из комнаты в
|
|
2755
|
+
// лобби ведёт тоже только reloadPage(), поэтому в матче Welcome с
|
|
2756
|
+
// уведомлением покажет следующая загрузка
|
|
2757
|
+
lobbyAuthModel.publisher.on('session-lost', () => {
|
|
2758
|
+
lobbyAuthModel.setNotice('profileGone');
|
|
2759
|
+
|
|
2760
|
+
if (!roomCtx.roomId) {
|
|
2761
|
+
reloadPage();
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
// возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
|
|
2706
2769
|
// (маршрут #/<gameId>[/<roomId>]) остаётся
|
|
2707
|
-
if (
|
|
2770
|
+
if (lobbyAuthModel.takeUrlParams(window.location.search)) {
|
|
2708
2771
|
window.history.replaceState(
|
|
2709
2772
|
null,
|
|
2710
2773
|
'',
|
|
@@ -2712,6 +2775,8 @@ if (isLobbyMode) {
|
|
|
2712
2775
|
);
|
|
2713
2776
|
}
|
|
2714
2777
|
|
|
2778
|
+
lobbyAuthModel.boot();
|
|
2779
|
+
|
|
2715
2780
|
window.addEventListener('hashchange', () => route.handleHashChange());
|
|
2716
2781
|
|
|
2717
2782
|
signaling.connect();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
|
|
2
|
+
// сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
|
|
3
|
+
// попыток: сеть вернётся — токен продлится.
|
|
4
|
+
//
|
|
5
|
+
// Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
|
|
6
|
+
|
|
7
|
+
export default class TokenRenewer {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {Function} options.getExpiresAt - () → мс эпохи | null.
|
|
11
|
+
* @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
|
|
12
|
+
* @param {Function} options.onLost - сессия потеряна, продлевать нечем.
|
|
13
|
+
* @param {number} options.leadMs
|
|
14
|
+
* @param {number} options.retryMs
|
|
15
|
+
* @param {Function} [options.now]
|
|
16
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
17
|
+
*/
|
|
18
|
+
constructor({
|
|
19
|
+
getExpiresAt,
|
|
20
|
+
renew,
|
|
21
|
+
onLost,
|
|
22
|
+
leadMs,
|
|
23
|
+
retryMs,
|
|
24
|
+
now = () => Date.now(),
|
|
25
|
+
timers = globalThis,
|
|
26
|
+
}) {
|
|
27
|
+
this._getExpiresAt = getExpiresAt;
|
|
28
|
+
this._renew = renew;
|
|
29
|
+
this._onLost = onLost;
|
|
30
|
+
this._leadMs = leadMs;
|
|
31
|
+
this._retryMs = retryMs;
|
|
32
|
+
this._now = now;
|
|
33
|
+
this._timers = timers;
|
|
34
|
+
this._timer = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
|
|
38
|
+
// таймера нет. Повторный arm заменяет прежний таймер
|
|
39
|
+
arm() {
|
|
40
|
+
this._schedule(this._delayToLead());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cancel() {
|
|
44
|
+
if (this._timer !== null) {
|
|
45
|
+
this._timers.clearTimeout(this._timer);
|
|
46
|
+
this._timer = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_delayToLead() {
|
|
51
|
+
const expiresAt = this._getExpiresAt();
|
|
52
|
+
|
|
53
|
+
if (expiresAt === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return Math.max(0, expiresAt - this._leadMs - this._now());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_schedule(delay) {
|
|
61
|
+
this.cancel();
|
|
62
|
+
|
|
63
|
+
if (delay === null) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this._timer = this._timers.setTimeout(() => this._fire(), delay);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async _fire() {
|
|
71
|
+
this._timer = null;
|
|
72
|
+
|
|
73
|
+
let result;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
result = await this._renew();
|
|
77
|
+
} catch {
|
|
78
|
+
// неожиданный сбой не должен обрывать цепочку продления
|
|
79
|
+
result = 'retry';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (result === 'ok') {
|
|
83
|
+
this.arm();
|
|
84
|
+
} else if (result === 'retry') {
|
|
85
|
+
this._schedule(this._retryMs);
|
|
86
|
+
} else {
|
|
87
|
+
this._onLost();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|