From 21fbc8ffa0b466e81ff7df10989e4f02ef98b145bf5960135364c79e731aa753 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 14 Apr 2026 23:53:54 +0300 Subject: [PATCH] =?UTF-8?q?14-04-2026=20=D0=9F=D1=80=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=B6=D1=83=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=D0=BD?= =?UTF-8?q?=D1=83=20=D1=85=D0=BE=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D1=8F=D1=82?= =?UTF-8?q?.=20=D0=9D=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=B5=D1=89?= =?UTF-8?q?=D1=91=20=D0=B2=D0=B5=D0=B1=D0=BF=D1=83=D1=88=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/mvp-web-push-notes/README.md | 18 ++++ shine-UI/js/app.js | 50 ++++++++--- shine-UI/js/pages/chat-view.js | 1 + shine-UI/js/services/auth-service.js | 10 +-- shine-UI/js/services/pwa-push-service.js | 13 ++- shine-UI/js/state.js | 14 ++- .../it/cases/Seed_TestDataPopulation.java | 87 ++++++++++++------- 7 files changed, 140 insertions(+), 53 deletions(-) create mode 100644 doc/mvp-web-push-notes/README.md diff --git a/doc/mvp-web-push-notes/README.md b/doc/mvp-web-push-notes/README.md new file mode 100644 index 0000000..d6c1680 --- /dev/null +++ b/doc/mvp-web-push-notes/README.md @@ -0,0 +1,18 @@ +# MVP notes: Web Push + +## Временное поведение (сделано для тестового стенда) + +- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась. +- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает. + +## Что доработать для production + +- Вернуть режим "отправлять только при изменении подписки" как основной. +- Добавить безопасный механизм ресинхронизации: +- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней). +- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне. +- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик. +- Добавить e2e-тесты сценариев: +- Переустановка сервера (потеря токена в БД). +- Смена браузерной подписки. +- Повторный запуск клиента без изменений. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index ade61e7..5585e92 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -8,6 +8,7 @@ import { authorizeSession, isSessionInvalidError, refreshSessions, + setSessionAuthorizedHandler, setSessionResetHandler, state, terminateCurrentSession, @@ -88,6 +89,8 @@ const screenEl = document.getElementById('app-screen'); const toolbarEl = document.getElementById('toolbar-slot'); let currentCleanup = null; +let pingIntervalId = null; +let sessionRuntimeStarted = false; setClientErrorTransport((payload) => authService.reportClientError(payload)); @@ -273,6 +276,29 @@ async function tryAutoLogin() { } } +async function ensureSessionRuntimeStarted() { + if (!state.session.isAuthorized || sessionRuntimeStarted) return; + sessionRuntimeStarted = true; + + await initPwaPush({ + authService, + onLog: (entry) => addAppLogEntry(entry), + }); + + if (pingIntervalId) { + window.clearInterval(pingIntervalId); + pingIntervalId = null; + } + pingIntervalId = window.setInterval(async () => { + if (!state.session.isAuthorized) return; + try { + await authService.ws.request('Ping', { timeMs: Date.now() }); + } catch { + // silent keep-alive + } + }, 60_000); +} + async function init() { addAppLogEntry({ level: 'info', @@ -281,9 +307,18 @@ async function init() { }); setSessionResetHandler(() => { + sessionRuntimeStarted = false; + if (pingIntervalId) { + window.clearInterval(pingIntervalId); + pingIntervalId = null; + } navigate('start-view'); }); + setSessionAuthorizedHandler(() => { + void ensureSessionRuntimeStarted(); + }); + if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', (event) => { const data = event?.data || {}; @@ -347,20 +382,7 @@ async function init() { } }); await tryAutoLogin(); - if (state.session.isAuthorized) { - await initPwaPush({ - authService, - onLog: (entry) => addAppLogEntry(entry), - }); - window.setInterval(async () => { - if (!state.session.isAuthorized) return; - try { - await authService.ws.request('Ping', { timeMs: Date.now() }); - } catch { - // silent keep-alive - } - }, 60_000); - } + await ensureSessionRuntimeStarted(); if (!window.location.hash) { navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 73e9d3a..b1db188 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -67,6 +67,7 @@ export function render({ navigate, route }) { try { await authService.sendDirectMessage({ + login: state.session.login, toLogin: chatId, text, storagePwd: state.session.storagePwdInMemory, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c685cf9..08c8a2e 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1115,14 +1115,14 @@ export class AuthService { return response.payload || {}; } - async sendDirectMessage({ toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) { + async sendDirectMessage({ login, toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) { + const cleanFromLogin = String(login || '').trim(); const cleanToLogin = String(toLogin || '').trim(); const cleanText = String(text || ''); - if (!cleanToLogin || !cleanText) throw new Error('Не передан toLogin/text'); + if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); - if (!this.ws.login) throw new Error('Нет активной авторизованной сессии'); - const secrets = await loadEncryptedUserSecrets(this.ws.login, storagePwd); + const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); const devicePriv = secrets?.deviceKey; if (!devicePriv) throw new Error('Не найден приватный deviceKey'); const privateKey = await importPkcs8Ed25519(devicePriv); @@ -1130,7 +1130,7 @@ export class AuthService { const prefix = utf8Bytes('SHiNE_msg'); const version = uint8Bytes(1); const toBytes = utf8Bytes(cleanToLogin); - const fromBytes = utf8Bytes(this.ws.login); + const fromBytes = utf8Bytes(cleanFromLogin); if (toBytes.length < 1 || toBytes.length > 30) throw new Error('toLogin должен быть 1..30 ASCII-символов'); if (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов'); if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение'); diff --git a/shine-UI/js/services/pwa-push-service.js b/shine-UI/js/services/pwa-push-service.js index b680469..2e2eb1b 100644 --- a/shine-UI/js/services/pwa-push-service.js +++ b/shine-UI/js/services/pwa-push-service.js @@ -76,15 +76,20 @@ export async function initPwaPush({ authService, onLog = null }) { const serialized = JSON.stringify(sub); const prevSerialized = localStorage.getItem(LS_KEY); - if (prevSerialized === serialized) { + if (prevSerialized !== serialized) { + localStorage.setItem(LS_KEY, serialized); log({ level: 'info', source: 'web-push', - message: 'Push-подписка не изменилась, отправка на сервер не требуется', + message: 'Push-подписка изменилась относительно прошлого запуска', + }); + } else { + log({ + level: 'info', + source: 'web-push', + message: 'Push-подписка не изменилась, но в MVP включена принудительная отправка на сервер', }); - return; } - localStorage.setItem(LS_KEY, serialized); const json = sub.toJSON(); const endpoint = json.endpoint || ''; diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index a62944f..e26ac67 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -197,6 +197,7 @@ export const state = createInitialState(); export const authService = new AuthService(state.entrySettings.shineServer); let onSessionReset = null; +let onSessionAuthorized = null; export function getChatMessages(chatId) { if (!state.chats[chatId]) { @@ -321,7 +322,11 @@ export function clearAuthMessages() { state.authUi.info = ''; } -export function authorizeSession({ login, sessionId, storagePwd }) { +export function authorizeSession({ + login = state.session.login, + sessionId = state.session.sessionId, + storagePwd = state.session.storagePwdInMemory, +} = {}) { state.session.isAuthorized = true; state.session.login = login; state.session.sessionId = sessionId; @@ -332,12 +337,19 @@ export function authorizeSession({ login, sessionId, storagePwd }) { sessionId, }); state.startHint = ''; + if (onSessionAuthorized) { + onSessionAuthorized(); + } } export function setSessionResetHandler(handler) { onSessionReset = typeof handler === 'function' ? handler : null; } +export function setSessionAuthorizedHandler(handler) { + onSessionAuthorized = typeof handler === 'function' ? handler : null; +} + export function isSessionInvalidError(error) { return INVALID_SESSION_CODES.has(error?.code); } diff --git a/src/test/java/test/it/cases/Seed_TestDataPopulation.java b/src/test/java/test/it/cases/Seed_TestDataPopulation.java index 2354613..3e02fd1 100644 --- a/src/test/java/test/it/cases/Seed_TestDataPopulation.java +++ b/src/test/java/test/it/cases/Seed_TestDataPopulation.java @@ -27,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.fail; * * ВАЖНО: * - НЕ заполняет БД напрямую. - * - Создаёт тестовых пользователей A1..A10 через API AddUser. + * - Создаёт тестовых пользователей 1..9 через API AddUser. * - Создаёт сеть дружбы через API AddBlock (CONNECTION_FRIEND). */ public class Seed_TestDataPopulation { @@ -45,7 +45,7 @@ public class Seed_TestDataPopulation { try (WsSession ws = WsSession.open()) { UserKeys keys = deriveKeysFromPassword(PASSWORD); - List users = List.of("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"); + List users = List.of("1", "2", "3", "4", "5", "6", "7", "8", "9"); for (String login : users) { createUserViaApi(ws, r, login, keys, t); @@ -70,36 +70,38 @@ public class Seed_TestDataPopulation { } // Насыщенная сеть дружбы (взаимно). Целевые контрольные значения: - // A1=5 друзей, A2=7 друзей (<8), A3=3 друга. - addMutualFriend(senders, states, headerHashes, "A1", "A2", t); - addMutualFriend(senders, states, headerHashes, "A1", "A3", t); - addMutualFriend(senders, states, headerHashes, "A1", "A4", t); - addMutualFriend(senders, states, headerHashes, "A1", "A5", t); - addMutualFriend(senders, states, headerHashes, "A1", "A6", t); + // 1=5 друзей, 2=7 друзей (<8), 3=3 друга. + addMutualFriend(senders, states, headerHashes, "1", "2", t); + addMutualFriend(senders, states, headerHashes, "1", "3", t); + addMutualFriend(senders, states, headerHashes, "1", "4", t); + addMutualFriend(senders, states, headerHashes, "1", "5", t); + addMutualFriend(senders, states, headerHashes, "1", "6", t); - addMutualFriend(senders, states, headerHashes, "A2", "A3", t); - addMutualFriend(senders, states, headerHashes, "A2", "A4", t); - addMutualFriend(senders, states, headerHashes, "A2", "A5", t); - addMutualFriend(senders, states, headerHashes, "A2", "A6", t); - addMutualFriend(senders, states, headerHashes, "A2", "A7", t); - addMutualFriend(senders, states, headerHashes, "A2", "A8", t); + addMutualFriend(senders, states, headerHashes, "2", "3", t); + addMutualFriend(senders, states, headerHashes, "2", "4", t); + addMutualFriend(senders, states, headerHashes, "2", "5", t); + addMutualFriend(senders, states, headerHashes, "2", "6", t); + addMutualFriend(senders, states, headerHashes, "2", "7", t); + addMutualFriend(senders, states, headerHashes, "2", "8", t); - addMutualFriend(senders, states, headerHashes, "A3", "A4", t); - addMutualFriend(senders, states, headerHashes, "A4", "A5", t); - addMutualFriend(senders, states, headerHashes, "A5", "A6", t); - addMutualFriend(senders, states, headerHashes, "A5", "A7", t); - addMutualFriend(senders, states, headerHashes, "A6", "A8", t); - addMutualFriend(senders, states, headerHashes, "A6", "A9", t); - addMutualFriend(senders, states, headerHashes, "A7", "A10", t); - addMutualFriend(senders, states, headerHashes, "A8", "A9", t); - addMutualFriend(senders, states, headerHashes, "A8", "A10", t); - addMutualFriend(senders, states, headerHashes, "A9", "A10", t); + addMutualFriend(senders, states, headerHashes, "3", "4", t); + addMutualFriend(senders, states, headerHashes, "4", "5", t); + addMutualFriend(senders, states, headerHashes, "5", "6", t); + addMutualFriend(senders, states, headerHashes, "5", "7", t); + addMutualFriend(senders, states, headerHashes, "6", "8", t); + addMutualFriend(senders, states, headerHashes, "6", "9", t); + addMutualFriend(senders, states, headerHashes, "8", "9", t); - verifyOutFriendsCount(ws, r, "A1", 5, t); - verifyOutFriendsCount(ws, r, "A2", 7, t); - verifyOutFriendsCount(ws, r, "A3", 3, t); + // Контакты: 1/2/3 должны быть друг у друга в контактах (взаимно). + addMutualContact(senders, states, headerHashes, "1", "2", t); + addMutualContact(senders, states, headerHashes, "1", "3", t); + addMutualContact(senders, states, headerHashes, "2", "3", t); - r.ok("Пользователи A1..A10 созданы через API, дружеские связи созданы через AddBlock"); + verifyOutFriendsCount(ws, r, "1", 5, t); + verifyOutFriendsCount(ws, r, "2", 7, t); + verifyOutFriendsCount(ws, r, "3", 3, t); + + r.ok("Пользователи 1..9 созданы через API, дружеские и контактные связи созданы через AddBlock"); } catch (Throwable e) { r.fail("Ошибка IT_07: " + e.getMessage()); fail("IT_07 failed", e); @@ -159,13 +161,30 @@ public class Seed_TestDataPopulation { String targetBch, byte[] targetHeaderHash, Duration timeout) { + sendConnection(sender, st, MsgSubType.CONNECTION_FRIEND, targetBch, targetHeaderHash, timeout); + } + + private static void sendContactConnection(AddBlockSender sender, + ChainState st, + String targetBch, + byte[] targetHeaderHash, + Duration timeout) { + sendConnection(sender, st, MsgSubType.CONNECTION_CONTACT, targetBch, targetHeaderHash, timeout); + } + + private static void sendConnection(AddBlockSender sender, + ChainState st, + short relationSubType, + String targetBch, + byte[] targetHeaderHash, + Duration timeout) { ChainState.NextLine ln = st.nextLineByType(ChainState.TYPE_CONNECTION); sender.send(new ConnectionBody( 0, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - MsgSubType.CONNECTION_FRIEND, + relationSubType, targetBch, 0, targetHeaderHash @@ -182,6 +201,16 @@ public class Seed_TestDataPopulation { sendFriendConnection(senders.get(b), states.get(b), bch(a), headerHashes.get(a), t); } + private static void addMutualContact(Map senders, + Map states, + Map headerHashes, + String a, + String b, + Duration t) { + sendContactConnection(senders.get(a), states.get(a), bch(b), headerHashes.get(b), t); + sendContactConnection(senders.get(b), states.get(b), bch(a), headerHashes.get(a), t); + } + private static void verifyOutFriendsCount(WsSession ws, TestResult r, String login, int expectedCount, Duration t) { String resp = ws.call("GetFriendsLists#" + login, JsonBuilders.getFriendsLists(login), t); int st = JsonParsers.status(resp);