vimp-engine 0.37.1 → 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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/client/boot.js +1 -1
  3. package/src/client/components/controller/LobbyAuth.js +97 -15
  4. package/src/client/components/controller/Profile.js +85 -0
  5. package/src/client/components/model/Games.js +1 -1
  6. package/src/client/components/model/LobbyAuth.js +344 -115
  7. package/src/client/components/model/Profile.js +142 -0
  8. package/src/client/components/view/LobbyAuth.js +150 -62
  9. package/src/client/components/view/Profile.js +373 -0
  10. package/src/client/lib/authApi.js +97 -0
  11. package/src/client/lib/pkce.js +23 -0
  12. package/src/client/lib/roomLink.js +1 -1
  13. package/src/client/lib/signalingErrors.js +3 -3
  14. package/src/client/lib/turnstile.js +130 -0
  15. package/src/client/main.js +89 -24
  16. package/src/client/network/TokenRenewer.js +90 -0
  17. package/src/client/session/GuestSession.js +63 -17
  18. package/src/client/style.css +119 -16
  19. package/src/client/views/includes/lobby.pug +11 -6
  20. package/src/client/views/includes/lobbyAuth.pug +34 -16
  21. package/src/client/views/includes/profile.pug +73 -0
  22. package/src/client/views/index.pug +1 -0
  23. package/src/config/authClient.js +54 -30
  24. package/src/config/chatMessages.js +1 -2
  25. package/src/config/env.js +2 -2
  26. package/src/config/lobby.js +45 -0
  27. package/src/config/master.js +8 -5
  28. package/src/config/wsports.js +4 -1
  29. package/src/devtools/builtinScenario.js +1 -1
  30. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  31. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  32. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  33. package/src/devtools/invariants.js +1 -1
  34. package/src/devtools/pluginLoader.js +1 -1
  35. package/src/host/GameCoreAdapter.js +0 -3
  36. package/src/host/HostGame.js +28 -0
  37. package/src/host/PortMachine.js +50 -1
  38. package/src/host/meta/SocketManager.js +0 -13
  39. package/src/host/meta/core/RoundManager.js +0 -19
  40. package/src/host/meta/modules/PlayerDataSync.js +29 -3
  41. package/src/host/meta/modules/chat/systemMessages.js +1 -4
  42. package/src/lib/loadGamePackage.js +1 -1
  43. package/src/lib/validators.js +8 -0
  44. package/src/standalone/index.js +1 -1
@@ -1,17 +1,42 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { decodeJwtPayload } from '../../../lib/jwt.js';
3
+ import { isValidNick } from '../../../lib/validators.js';
3
4
 
4
5
  // Singleton LobbyAuthModel
5
6
 
6
7
  let lobbyAuthModel;
7
8
 
8
- // Модель логина лобби (Этап B2): OAuth-редирект на central auth-сервис
9
- // (packages/auth), выбор ника при первом входе, identity JWT в localStorage.
10
- // Хранит состояние сессии и делает единственный сетевой вызов сама (POST
11
- // /nick) — в отличие от Lobby/Auth-моделей это простой fetch без сигнального
12
- // сокета, отдельный REST-канал к другому домену.
9
+ const SESSION_RE = /^s_[A-Za-z0-9_-]{43}$/;
10
+
11
+ // Модель профиля лобби: регистрация ника, сессия устройства, identity-токен
12
+ // (в памяти вкладки), возврат с OAuth (Restore и привязка). Сетевые вызовы —
13
+ // через инъектированный authApi, PKCE — через pkce.
14
+ //
15
+ // События: loading, welcome { notice }, authenticated { nick, role, profile },
16
+ // register-error <код>, restore-error <код>, service-error,
17
+ // token-renewed { expiresAt }, session-lost, profile-changed <profile>.
13
18
  export default class LobbyAuthModel {
14
- constructor(config) {
19
+ /**
20
+ * @param {Object} config - config/authClient.js.
21
+ * @param {Object} deps
22
+ * @param {Object} deps.api - client/lib/authApi.js.
23
+ * @param {Object} deps.pkce - { createVerifier, challengeOf }.
24
+ * @param {Storage} [deps.storage] - секрет сессии.
25
+ * @param {Storage} [deps.sessionStore] - попытка OAuth, уведомление.
26
+ * @param {boolean} [deps.isDev] - принимать ?devSession= (dev-сборка).
27
+ * @param {Function} [deps.getLocation]
28
+ */
29
+ constructor(
30
+ config,
31
+ {
32
+ api,
33
+ pkce,
34
+ storage = localStorage,
35
+ sessionStore = sessionStorage,
36
+ isDev = false,
37
+ getLocation = () => window.location,
38
+ } = {},
39
+ ) {
15
40
  if (lobbyAuthModel) {
16
41
  return lobbyAuthModel;
17
42
  }
@@ -19,167 +44,371 @@ export default class LobbyAuthModel {
19
44
  lobbyAuthModel = this;
20
45
 
21
46
  this._config = config;
22
- this._pendingToken = null;
23
- this._identityToken = null;
24
- this._nick = null;
47
+ this._api = api;
48
+ this._pkce = pkce;
49
+ this._storage = storage;
50
+ this._sessionStore = sessionStore;
51
+ this._isDev = isDev;
52
+ this._getLocation = getLocation;
53
+
54
+ this._session = null;
55
+ this._token = null;
56
+ this._profile = null;
57
+ this._clientConfig = null;
58
+ this._urlParams = { code: null, error: null, devSession: null };
59
+ this._linkResult = null;
60
+ this._refreshing = null;
25
61
 
26
62
  this.publisher = new Publisher();
27
63
  }
28
64
 
29
- // разбирает query-параметры OAuth-редиректа (?token=/?pendingToken=/
30
- // ?authError=); при их отсутствии — восстанавливает сессию из localStorage.
31
- // Возвращает true, если параметры были найдены и вызывающий должен
32
- // подчистить адресную строку (history.replaceState)
33
- boot(search) {
34
- const { queryParams, tokenStorageKey } = this._config;
65
+ // читает параметры возврата с OAuth и (в dev-сборке) dev-сессию; true —
66
+ // что-то найдено, вызывающий чистит адресную строку. Ищет только в search:
67
+ // колбэк auth собирает адрес через URL.searchParams, поэтому параметры
68
+ // стоят до #, а hash — маршрут #/<gameId>[/<roomId>] (его разбирает RouteBoot)
69
+ takeUrlParams(search) {
70
+ const { oauth, devSessionParam } = this._config;
35
71
  const params = new URLSearchParams(search);
36
72
 
37
- const error = params.get(queryParams.error);
38
- const token = params.get(queryParams.token);
39
- const pendingToken = params.get(queryParams.pendingToken);
73
+ this._urlParams = {
74
+ code: params.get(oauth.codeParam),
75
+ error: params.get(oauth.errorParam),
76
+ devSession: this._isDev ? params.get(devSessionParam) : null,
77
+ };
78
+
79
+ const { code, error, devSession } = this._urlParams;
80
+
81
+ return Boolean(code || error || devSession);
82
+ }
40
83
 
41
- if (error) {
42
- this.publisher.emit('login-error', error);
43
- this._restore(tokenStorageKey);
44
- return true;
84
+ async boot() {
85
+ try {
86
+ await this._boot();
87
+ } catch {
88
+ // иначе экран навсегда остался бы на «Loading…» без Retry
89
+ this.publisher.emit('service-error');
45
90
  }
91
+ }
46
92
 
47
- if (token) {
48
- this._setIdentity(token, tokenStorageKey);
49
- return true;
93
+ async _boot() {
94
+ const { sessionStorageKey } = this._config;
95
+ const { devSession } = this._urlParams;
96
+
97
+ if (devSession && SESSION_RE.test(devSession)) {
98
+ this._storage.setItem(sessionStorageKey, devSession);
50
99
  }
51
100
 
52
- if (pendingToken) {
53
- this._pendingToken = pendingToken;
54
- this.publisher.emit('nick-required');
55
- return true;
101
+ this.publisher.emit('loading');
102
+
103
+ const cfg = await this._api.clientConfig();
104
+
105
+ if (!cfg.ok) {
106
+ this.publisher.emit('service-error');
107
+ return;
56
108
  }
57
109
 
58
- this._restore(tokenStorageKey);
59
- return false;
110
+ this._clientConfig = cfg.data;
111
+
112
+ // попытка живёт один возврат: любой исход её закрывает
113
+ const attempt = this._takeAttempt();
114
+ const { code, error } = this._urlParams;
115
+
116
+ this._urlParams = { code: null, error: null, devSession: null };
117
+
118
+ const session = this._storage.getItem(sessionStorageKey);
119
+
120
+ if (!session) {
121
+ await this._bootWithoutSession(attempt, code, error);
122
+ return;
123
+ }
124
+
125
+ this._session = session;
126
+
127
+ const res = await this._api.token(session);
128
+
129
+ if (res.ok) {
130
+ this._adopt({ token: res.data.token, profile: res.data.profile });
131
+ await this._finishLink(attempt, code, error);
132
+ this._emitAuthenticated();
133
+ } else if (res.status === 401) {
134
+ this._forgetSession();
135
+ this.publisher.emit('welcome', { notice: 'profileGone' });
136
+ } else {
137
+ // код OAuth одноразовый: Retry доведёт привязку, а не потеряет её
138
+ this._restoreAttempt(attempt, code, error);
139
+ this.publisher.emit('service-error');
140
+ }
60
141
  }
61
142
 
62
- // URL редиректа на старт OAuth-потока auth-сервиса; вызывающий должен сам
63
- // сделать переход (window.location.href) — модель не трогает location.
64
- // hash (#/<gameId>[/<roomId>]) едет в returnUrl: после логина игрок
65
- // попадает туда, куда вёл открытый URL, а не в общее лобби
66
- loginUrl(provider) {
67
- const { origin, pathname, hash } = window.location;
68
- const returnUrl = `${origin}${pathname}${hash}`;
143
+ async _bootWithoutSession(attempt, code, error) {
144
+ const notice = this._takeNotice();
145
+ const restoring = attempt?.mode === 'restore';
146
+
147
+ if (restoring && code) {
148
+ const res = await this._api.restore({
149
+ code,
150
+ verifier: attempt.verifier,
151
+ termsVersion: this._clientConfig.legal.version,
152
+ });
153
+
154
+ if (res.ok) {
155
+ this._adopt(res.data);
156
+ this._emitAuthenticated();
157
+ return;
158
+ }
159
+
160
+ this.publisher.emit('welcome', { notice });
161
+ this.publisher.emit('restore-error', res.error);
162
+ return;
163
+ }
69
164
 
70
- return `${this._config.serviceUrl}/oauth/${provider}/start?returnUrl=${encodeURIComponent(returnUrl)}`;
165
+ this.publisher.emit('welcome', { notice });
166
+
167
+ if (restoring && error) {
168
+ this.publisher.emit(
169
+ 'restore-error',
170
+ error === 'denied' ? 'denied' : 'failed',
171
+ );
172
+ }
71
173
  }
72
174
 
73
- async submitNick(nick) {
74
- if (!this._pendingToken) {
75
- this.publisher.emit('nick-error', 'sessionExpired');
175
+ // привязка завершается до authenticated, а не в панели профиля: код живёт
176
+ // 120 с и не должен ждать, пока откроется лобби
177
+ async _finishLink(attempt, code, error) {
178
+ if (attempt?.mode !== 'link') {
76
179
  return;
77
180
  }
78
181
 
79
- let res;
80
- let data;
182
+ const { provider } = attempt;
81
183
 
82
- try {
83
- res = await fetch(`${this._config.serviceUrl}/nick`, {
84
- method: 'POST',
85
- headers: {
86
- 'Content-Type': 'application/json',
87
- Authorization: `Bearer ${this._pendingToken}`,
88
- },
89
- body: JSON.stringify({ nick }),
184
+ if (code) {
185
+ const res = await this._api.linkOauth(this._session, {
186
+ code,
187
+ verifier: attempt.verifier,
188
+ consentVersion: attempt.consentVersion,
90
189
  });
91
- data = await res.json().catch(() => ({}));
190
+
191
+ if (res.ok) {
192
+ this._profile = res.data.profile;
193
+ this._linkResult = { ok: true, provider };
194
+ } else {
195
+ this._linkResult = { ok: false, provider, code: res.error };
196
+ }
197
+ } else if (error) {
198
+ this._linkResult = {
199
+ ok: false,
200
+ provider,
201
+ code: error === 'denied' ? 'denied' : 'failed',
202
+ };
203
+ }
204
+ }
205
+
206
+ _restoreAttempt(attempt, code, error) {
207
+ if (attempt) {
208
+ this._sessionStore.setItem(
209
+ this._config.oauth.pendingKey,
210
+ JSON.stringify(attempt),
211
+ );
212
+ }
213
+
214
+ this._urlParams = { code, error, devSession: null };
215
+ }
216
+
217
+ _takeAttempt() {
218
+ const key = this._config.oauth.pendingKey;
219
+ const raw = this._sessionStore.getItem(key);
220
+
221
+ this._sessionStore.removeItem(key);
222
+
223
+ try {
224
+ return raw ? JSON.parse(raw) : null;
92
225
  } catch {
93
- this.publisher.emit('nick-error', 'network');
94
- return;
226
+ return null;
227
+ }
228
+ }
229
+
230
+ _takeNotice() {
231
+ const key = this._config.noticeKey;
232
+ const notice = this._sessionStore.getItem(key);
233
+
234
+ this._sessionStore.removeItem(key);
235
+
236
+ return notice || null;
237
+ }
238
+
239
+ // принять личность: секрет сессии (если пришёл) — в хранилище, токен и
240
+ // профиль — в память
241
+ _adopt({ session, token, profile }) {
242
+ if (session) {
243
+ this._session = session;
244
+ this._storage.setItem(this._config.sessionStorageKey, session);
95
245
  }
96
246
 
247
+ this._token = token;
248
+ this._profile = profile;
249
+ }
250
+
251
+ _emitAuthenticated() {
252
+ this.publisher.emit('authenticated', {
253
+ nick: this.getNick(),
254
+ role: this.getRole(),
255
+ profile: this._profile,
256
+ });
257
+ }
258
+
259
+ _forgetSession() {
260
+ this._storage.removeItem(this._config.sessionStorageKey);
261
+ this._session = null;
262
+ this._token = null;
263
+ this._profile = null;
264
+ }
265
+
266
+ validateNick(nick) {
267
+ return isValidNick(nick);
268
+ }
269
+
270
+ async register(nick, turnstileToken) {
271
+ const res = await this._api.register({
272
+ nick,
273
+ turnstileToken,
274
+ termsVersion: this._clientConfig.legal.version,
275
+ });
276
+
97
277
  if (!res.ok) {
98
- this.publisher.emit('nick-error', data.error || 'unknown');
99
- return;
278
+ this.publisher.emit('register-error', res.error);
279
+ return false;
280
+ }
281
+
282
+ this._adopt(res.data);
283
+ this._emitAuthenticated();
284
+
285
+ return true;
286
+ }
287
+
288
+ // URL старта OAuth для Restore; попытка (verifier) остаётся в
289
+ // sessionStorage вкладки, на auth уходит только challenge
290
+ async restoreUrl(provider) {
291
+ return this._oauthUrl('restore', provider);
292
+ }
293
+
294
+ // то же для привязки из панели профиля; consentVersion — версия
295
+ // согласий, принятых чекбоксами перед переходом
296
+ async linkUrl(provider, consentVersion) {
297
+ return this._oauthUrl('link', provider, { consentVersion });
298
+ }
299
+
300
+ async _oauthUrl(mode, provider, extra = {}) {
301
+ const verifier = this._pkce.createVerifier();
302
+ const challenge = await this._pkce.challengeOf(verifier);
303
+ const { origin, pathname, hash } = this._getLocation();
304
+
305
+ this._sessionStore.setItem(
306
+ this._config.oauth.pendingKey,
307
+ JSON.stringify({ mode, provider, verifier, ...extra }),
308
+ );
309
+
310
+ // hash (#/<gameId>[/<roomId>]) едет в returnUrl: после возврата игрок
311
+ // попадает туда, куда вёл открытый адрес
312
+ return this._api.oauthStartUrl(provider, {
313
+ mode,
314
+ returnUrl: `${origin}${pathname}${hash}`,
315
+ challenge,
316
+ });
317
+ }
318
+
319
+ // продление identity-токена по сессии; параллельные вызовы делят запрос
320
+ refresh() {
321
+ if (!this._refreshing) {
322
+ this._refreshing = this._refresh().finally(() => {
323
+ this._refreshing = null;
324
+ });
325
+ }
326
+
327
+ return this._refreshing;
328
+ }
329
+
330
+ async _refresh() {
331
+ if (!this._session) {
332
+ return 'lost';
333
+ }
334
+
335
+ const res = await this._api.token(this._session);
336
+
337
+ if (res.ok) {
338
+ this._token = res.data.token;
339
+ this._profile = res.data.profile;
340
+ this.publisher.emit('token-renewed', {
341
+ expiresAt: this.getTokenExpiresAt(),
342
+ });
343
+ return 'ok';
100
344
  }
101
345
 
102
- this._pendingToken = null;
103
- this._setIdentity(data.token, this._config.tokenStorageKey);
346
+ if (res.status === 401) {
347
+ this._forgetSession();
348
+ this.publisher.emit('session-lost');
349
+ return 'lost';
350
+ }
351
+
352
+ return 'retry';
104
353
  }
105
354
 
106
- logout() {
107
- delete localStorage[this._config.tokenStorageKey];
355
+ // итог привязки, завершённой в boot(); отдаётся один раз
356
+ takeLinkResult() {
357
+ const result = this._linkResult;
358
+
359
+ this._linkResult = null;
108
360
 
109
- this._identityToken = null;
110
- this._nick = null;
361
+ return result;
362
+ }
363
+
364
+ setProfile(profile) {
365
+ this._profile = profile;
366
+ this.publisher.emit('profile-changed', profile);
367
+ }
111
368
 
112
- this.publisher.emit('login-required', this._config.providers);
369
+ // выход и удаление профиля (панель профиля)
370
+ clearSession() {
371
+ this._forgetSession();
372
+ }
373
+
374
+ // уведомление для экрана Welcome после перезагрузки страницы
375
+ setNotice(code) {
376
+ this._sessionStore.setItem(this._config.noticeKey, code);
113
377
  }
114
378
 
115
379
  getToken() {
116
- return this._identityToken;
380
+ return this._token;
117
381
  }
118
382
 
119
383
  getNick() {
120
- return this._nick;
384
+ return this._profile?.nick ?? null;
121
385
  }
122
386
 
123
- // роль из payload уже проверенного на срок токена (master-game-registry,
124
- // этап 4). Это ПОДСКАЗКА ИНТЕРФЕЙСУ — показывать ли кнопку модерации, — а
125
- // не право: доступ к данным проверяет мастер по подписи, а запись —
126
- // auth-сервис, перечитывая роль из БД
387
+ // роль из payload токена — ПОДСКАЗКА ИНТЕРФЕЙСУ (показывать ли кнопку
388
+ // модерации), не право: доступ к данным проверяет мастер по подписи, а
389
+ // запись — auth-сервис, перечитывая роль из БД
127
390
  getRole() {
128
- return decodeJwtPayload(this._identityToken)?.role ?? 'user';
391
+ return decodeJwtPayload(this._token)?.role ?? 'user';
129
392
  }
130
393
 
131
394
  // срок входа (мс эпохи) или null — неизвестен. Вкладка с кончающимся
132
395
  // токеном не принимает роль хоста: register_host/reclaim_host предъявят
133
- // его мастеру посреди матча (host-migration-review этап 7)
396
+ // его мастеру посреди матча
134
397
  getTokenExpiresAt() {
135
- const exp = decodeJwtPayload(this._identityToken)?.exp;
398
+ const exp = decodeJwtPayload(this._token)?.exp;
136
399
 
137
400
  return typeof exp === 'number' ? exp * 1000 : null;
138
401
  }
139
402
 
140
- _restore(tokenStorageKey) {
141
- const token = localStorage[tokenStorageKey];
142
-
143
- if (token) {
144
- this._setIdentity(token, tokenStorageKey, false);
145
- } else {
146
- this.publisher.emit('login-required', this._config.providers);
147
- }
148
- }
149
-
150
- // payload JWT не проверяется подписью на клиенте (только для отображения
151
- // ника) — авторитетная проверка по /jwks на хосте (Этап B3). exp
152
- // проверяется (F5 кодревью) — иначе восстановленная из localStorage сессия
153
- // показывает «залогинен» токеном, который хост уже отклонит при входе в игру
154
- _setIdentity(token, tokenStorageKey, persist = true) {
155
- const payload = decodeJwtPayload(token);
156
- const isExpired =
157
- typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
158
-
159
- if (!payload || !payload.nick || isExpired) {
160
- delete localStorage[tokenStorageKey];
161
-
162
- // Тихое восстановление из localStorage (persist === false): просроченный/
163
- // битый токен — штатная ситуация возврата на сайт, не ошибка. login-error
164
- // — только на интерактивном пути.
165
- if (persist) {
166
- this.publisher.emit(
167
- 'login-error',
168
- isExpired ? 'tokenExpired' : 'invalidToken',
169
- );
170
- }
171
-
172
- this.publisher.emit('login-required', this._config.providers);
173
- return;
174
- }
175
-
176
- this._identityToken = token;
177
- this._nick = payload.nick;
403
+ getProfile() {
404
+ return this._profile;
405
+ }
178
406
 
179
- if (persist) {
180
- localStorage[tokenStorageKey] = token;
181
- }
407
+ getClientConfig() {
408
+ return this._clientConfig;
409
+ }
182
410
 
183
- this.publisher.emit('authenticated', { nick: this._nick });
411
+ getSession() {
412
+ return this._session;
184
413
  }
185
414
  }
@@ -0,0 +1,142 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton ProfileModel
4
+
5
+ let profileModel;
6
+
7
+ // Модель панели профиля: привязка и отвязка OAuth, выход, выгрузка данных,
8
+ // удаление. Личность и сессию держит LobbyAuthModel, сеть — authApi; сама
9
+ // модель только оркестрирует и сообщает исход событиями.
10
+ //
11
+ // События: profile <profile>, busy <boolean>, export { data, fileName },
12
+ // left (выход или удаление — вкладка меняет личность),
13
+ // error { area: 'link' | 'session' | 'export' | 'delete', code }.
14
+ export default class ProfileModel {
15
+ /**
16
+ * @param {Object} config - Блок `profile` конфига лобби.
17
+ * @param {Object} deps
18
+ * @param {Object} deps.auth - LobbyAuthModel.
19
+ * @param {Object} deps.api - client/lib/authApi.js.
20
+ */
21
+ constructor(config, { auth, api }) {
22
+ if (profileModel) {
23
+ return profileModel;
24
+ }
25
+
26
+ profileModel = this;
27
+
28
+ this._config = config;
29
+ this._auth = auth;
30
+ this._api = api;
31
+ this._busy = false;
32
+
33
+ this.publisher = new Publisher();
34
+
35
+ auth.publisher.on('profile-changed', profile =>
36
+ this.publisher.emit('profile', profile),
37
+ );
38
+ }
39
+
40
+ getProfile() {
41
+ return this._auth.getProfile();
42
+ }
43
+
44
+ getClientConfig() {
45
+ return this._auth.getClientConfig();
46
+ }
47
+
48
+ // версия согласий — версия условий из /client-config: чекбоксы панели
49
+ // принимают именно их
50
+ async linkUrl(provider) {
51
+ return this._auth.linkUrl(provider, this.getClientConfig().legal.version);
52
+ }
53
+
54
+ takeLinkResult() {
55
+ return this._auth.takeLinkResult();
56
+ }
57
+
58
+ async unlink(provider) {
59
+ const data = await this._run('link', () =>
60
+ this._api.unlinkOauth(this._auth.getSession(), provider),
61
+ );
62
+
63
+ if (data) {
64
+ this._auth.setProfile(data.profile);
65
+ }
66
+ }
67
+
68
+ async signOut() {
69
+ const data = await this._run('session', () =>
70
+ this._api.logout(this._auth.getSession()),
71
+ );
72
+
73
+ if (data !== undefined) {
74
+ this._leave('signedOut');
75
+ }
76
+ }
77
+
78
+ async deleteProfile(confirmNick) {
79
+ const data = await this._run('delete', () =>
80
+ this._api.deleteAccount(this._auth.getSession(), confirmNick),
81
+ );
82
+
83
+ if (data !== undefined) {
84
+ this._leave('deleted');
85
+ }
86
+ }
87
+
88
+ async exportData() {
89
+ const data = await this._run('export', () =>
90
+ this._api.exportData(this._auth.getSession()),
91
+ );
92
+
93
+ if (data) {
94
+ this.publisher.emit('export', {
95
+ data,
96
+ fileName: this._config.exportFileName(this.getProfile().nick),
97
+ });
98
+ }
99
+ }
100
+
101
+ // сессия стёрта, уведомление ждёт экран Welcome: дальше вкладка
102
+ // перезагружается (решение ◆13), состояние по частям не чистится
103
+ _leave(notice) {
104
+ this._auth.clearSession();
105
+ this._auth.setNotice(notice);
106
+ this.publisher.emit('left');
107
+ }
108
+
109
+ // один запрос за раз; успех — data тела ответа (null у 204); отказ —
110
+ // undefined и событие error, а при 401 (сессия мертва: профиль удалён в
111
+ // другой вкладке, срок вышел) — вместо error событие left с уведомлением
112
+ // profileGone, уже после снятия busy; запрос во время другого не стартует
113
+ async _run(area, request) {
114
+ if (this._busy) {
115
+ return undefined;
116
+ }
117
+
118
+ this._busy = true;
119
+ this.publisher.emit('busy', true);
120
+
121
+ let res;
122
+
123
+ try {
124
+ res = await request();
125
+ } finally {
126
+ this._busy = false;
127
+ this.publisher.emit('busy', false);
128
+ }
129
+
130
+ if (res.ok) {
131
+ return res.data;
132
+ }
133
+
134
+ if (res.status === 401) {
135
+ this._leave('profileGone');
136
+ } else {
137
+ this.publisher.emit('error', { area, code: res.error });
138
+ }
139
+
140
+ return undefined;
141
+ }
142
+ }