14-04-2026

Промежуточный комит версии в которой ну хоть какието тестовые уведомления приходят. Но пока ещё вебпуш не работает
This commit is contained in:
AidarKC 2026-04-14 23:53:54 +03:00
parent 24be1d0c1f
commit 21fbc8ffa0
7 changed files with 140 additions and 53 deletions

View File

@ -0,0 +1,18 @@
# MVP notes: Web Push
## Временное поведение (сделано для тестового стенда)
- Клиент отправляет push-подписку на сервер при каждом запуске после авторизации, даже если подписка не изменилась.
- Причина: на тестовом сервере/после переустановки БД запись о токене может пропасть, а клиент этого не узнает.
## Что доработать для production
- Вернуть режим "отправлять только при изменении подписки" как основной.
- Добавить безопасный механизм ресинхронизации:
- Вариант 1: периодическая принудительная отправка (например, 1 раз в N дней).
- Вариант 2: endpoint на сервере "есть ли подписка", и отправка только при отсутствии/рассинхроне.
- В логах разделить обычную отправку и принудительную, чтобы видеть лишний трафик.
- Добавить e2e-тесты сценариев:
- Переустановка сервера (потеря токена в БД).
- Смена браузерной подписки.
- Повторный запуск клиента без изменений.

View File

@ -8,6 +8,7 @@ import {
authorizeSession, authorizeSession,
isSessionInvalidError, isSessionInvalidError,
refreshSessions, refreshSessions,
setSessionAuthorizedHandler,
setSessionResetHandler, setSessionResetHandler,
state, state,
terminateCurrentSession, terminateCurrentSession,
@ -88,6 +89,8 @@ const screenEl = document.getElementById('app-screen');
const toolbarEl = document.getElementById('toolbar-slot'); const toolbarEl = document.getElementById('toolbar-slot');
let currentCleanup = null; let currentCleanup = null;
let pingIntervalId = null;
let sessionRuntimeStarted = false;
setClientErrorTransport((payload) => authService.reportClientError(payload)); 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() { async function init() {
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
@ -281,9 +307,18 @@ async function init() {
}); });
setSessionResetHandler(() => { setSessionResetHandler(() => {
sessionRuntimeStarted = false;
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
navigate('start-view'); navigate('start-view');
}); });
setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted();
});
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {}; const data = event?.data || {};
@ -347,20 +382,7 @@ async function init() {
} }
}); });
await tryAutoLogin(); await tryAutoLogin();
if (state.session.isAuthorized) { await ensureSessionRuntimeStarted();
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);
}
if (!window.location.hash) { if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');

View File

@ -67,6 +67,7 @@ export function render({ navigate, route }) {
try { try {
await authService.sendDirectMessage({ await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId, toLogin: chatId,
text, text,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,

View File

@ -1115,14 +1115,14 @@ export class AuthService {
return response.payload || {}; 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 cleanToLogin = String(toLogin || '').trim();
const cleanText = String(text || ''); 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 (!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; const devicePriv = secrets?.deviceKey;
if (!devicePriv) throw new Error('Не найден приватный deviceKey'); if (!devicePriv) throw new Error('Не найден приватный deviceKey');
const privateKey = await importPkcs8Ed25519(devicePriv); const privateKey = await importPkcs8Ed25519(devicePriv);
@ -1130,7 +1130,7 @@ export class AuthService {
const prefix = utf8Bytes('SHiNE_msg'); const prefix = utf8Bytes('SHiNE_msg');
const version = uint8Bytes(1); const version = uint8Bytes(1);
const toBytes = utf8Bytes(cleanToLogin); 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 (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 (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов');
if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение'); if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение');

View File

@ -76,15 +76,20 @@ export async function initPwaPush({ authService, onLog = null }) {
const serialized = JSON.stringify(sub); const serialized = JSON.stringify(sub);
const prevSerialized = localStorage.getItem(LS_KEY); const prevSerialized = localStorage.getItem(LS_KEY);
if (prevSerialized === serialized) { if (prevSerialized !== serialized) {
localStorage.setItem(LS_KEY, serialized);
log({ log({
level: 'info', level: 'info',
source: 'web-push', 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 json = sub.toJSON();
const endpoint = json.endpoint || ''; const endpoint = json.endpoint || '';

View File

@ -197,6 +197,7 @@ export const state = createInitialState();
export const authService = new AuthService(state.entrySettings.shineServer); export const authService = new AuthService(state.entrySettings.shineServer);
let onSessionReset = null; let onSessionReset = null;
let onSessionAuthorized = null;
export function getChatMessages(chatId) { export function getChatMessages(chatId) {
if (!state.chats[chatId]) { if (!state.chats[chatId]) {
@ -321,7 +322,11 @@ export function clearAuthMessages() {
state.authUi.info = ''; 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.isAuthorized = true;
state.session.login = login; state.session.login = login;
state.session.sessionId = sessionId; state.session.sessionId = sessionId;
@ -332,12 +337,19 @@ export function authorizeSession({ login, sessionId, storagePwd }) {
sessionId, sessionId,
}); });
state.startHint = ''; state.startHint = '';
if (onSessionAuthorized) {
onSessionAuthorized();
}
} }
export function setSessionResetHandler(handler) { export function setSessionResetHandler(handler) {
onSessionReset = typeof handler === 'function' ? handler : null; onSessionReset = typeof handler === 'function' ? handler : null;
} }
export function setSessionAuthorizedHandler(handler) {
onSessionAuthorized = typeof handler === 'function' ? handler : null;
}
export function isSessionInvalidError(error) { export function isSessionInvalidError(error) {
return INVALID_SESSION_CODES.has(error?.code); return INVALID_SESSION_CODES.has(error?.code);
} }

View File

@ -27,7 +27,7 @@ import static org.junit.jupiter.api.Assertions.fail;
* *
* ВАЖНО: * ВАЖНО:
* - НЕ заполняет БД напрямую. * - НЕ заполняет БД напрямую.
* - Создаёт тестовых пользователей A1..A10 через API AddUser. * - Создаёт тестовых пользователей 1..9 через API AddUser.
* - Создаёт сеть дружбы через API AddBlock (CONNECTION_FRIEND). * - Создаёт сеть дружбы через API AddBlock (CONNECTION_FRIEND).
*/ */
public class Seed_TestDataPopulation { public class Seed_TestDataPopulation {
@ -45,7 +45,7 @@ public class Seed_TestDataPopulation {
try (WsSession ws = WsSession.open()) { try (WsSession ws = WsSession.open()) {
UserKeys keys = deriveKeysFromPassword(PASSWORD); UserKeys keys = deriveKeysFromPassword(PASSWORD);
List<String> users = List.of("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10"); List<String> users = List.of("1", "2", "3", "4", "5", "6", "7", "8", "9");
for (String login : users) { for (String login : users) {
createUserViaApi(ws, r, login, keys, t); createUserViaApi(ws, r, login, keys, t);
@ -70,36 +70,38 @@ public class Seed_TestDataPopulation {
} }
// Насыщенная сеть дружбы (взаимно). Целевые контрольные значения: // Насыщенная сеть дружбы (взаимно). Целевые контрольные значения:
// A1=5 друзей, A2=7 друзей (<8), A3=3 друга. // 1=5 друзей, 2=7 друзей (<8), 3=3 друга.
addMutualFriend(senders, states, headerHashes, "A1", "A2", t); addMutualFriend(senders, states, headerHashes, "1", "2", t);
addMutualFriend(senders, states, headerHashes, "A1", "A3", t); addMutualFriend(senders, states, headerHashes, "1", "3", t);
addMutualFriend(senders, states, headerHashes, "A1", "A4", t); addMutualFriend(senders, states, headerHashes, "1", "4", t);
addMutualFriend(senders, states, headerHashes, "A1", "A5", t); addMutualFriend(senders, states, headerHashes, "1", "5", t);
addMutualFriend(senders, states, headerHashes, "A1", "A6", t); addMutualFriend(senders, states, headerHashes, "1", "6", t);
addMutualFriend(senders, states, headerHashes, "A2", "A3", t); addMutualFriend(senders, states, headerHashes, "2", "3", t);
addMutualFriend(senders, states, headerHashes, "A2", "A4", t); addMutualFriend(senders, states, headerHashes, "2", "4", t);
addMutualFriend(senders, states, headerHashes, "A2", "A5", t); addMutualFriend(senders, states, headerHashes, "2", "5", t);
addMutualFriend(senders, states, headerHashes, "A2", "A6", t); addMutualFriend(senders, states, headerHashes, "2", "6", t);
addMutualFriend(senders, states, headerHashes, "A2", "A7", t); addMutualFriend(senders, states, headerHashes, "2", "7", t);
addMutualFriend(senders, states, headerHashes, "A2", "A8", t); addMutualFriend(senders, states, headerHashes, "2", "8", t);
addMutualFriend(senders, states, headerHashes, "A3", "A4", t); addMutualFriend(senders, states, headerHashes, "3", "4", t);
addMutualFriend(senders, states, headerHashes, "A4", "A5", t); addMutualFriend(senders, states, headerHashes, "4", "5", t);
addMutualFriend(senders, states, headerHashes, "A5", "A6", t); addMutualFriend(senders, states, headerHashes, "5", "6", t);
addMutualFriend(senders, states, headerHashes, "A5", "A7", t); addMutualFriend(senders, states, headerHashes, "5", "7", t);
addMutualFriend(senders, states, headerHashes, "A6", "A8", t); addMutualFriend(senders, states, headerHashes, "6", "8", t);
addMutualFriend(senders, states, headerHashes, "A6", "A9", t); addMutualFriend(senders, states, headerHashes, "6", "9", t);
addMutualFriend(senders, states, headerHashes, "A7", "A10", t); addMutualFriend(senders, states, headerHashes, "8", "9", t);
addMutualFriend(senders, states, headerHashes, "A8", "A9", t);
addMutualFriend(senders, states, headerHashes, "A8", "A10", t);
addMutualFriend(senders, states, headerHashes, "A9", "A10", t);
verifyOutFriendsCount(ws, r, "A1", 5, t); // Контакты: 1/2/3 должны быть друг у друга в контактах (взаимно).
verifyOutFriendsCount(ws, r, "A2", 7, t); addMutualContact(senders, states, headerHashes, "1", "2", t);
verifyOutFriendsCount(ws, r, "A3", 3, 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) { } catch (Throwable e) {
r.fail("Ошибка IT_07: " + e.getMessage()); r.fail("Ошибка IT_07: " + e.getMessage());
fail("IT_07 failed", e); fail("IT_07 failed", e);
@ -159,13 +161,30 @@ public class Seed_TestDataPopulation {
String targetBch, String targetBch,
byte[] targetHeaderHash, byte[] targetHeaderHash,
Duration timeout) { 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); ChainState.NextLine ln = st.nextLineByType(ChainState.TYPE_CONNECTION);
sender.send(new ConnectionBody( sender.send(new ConnectionBody(
0, 0,
ln.prevLineNumber, ln.prevLineNumber,
ln.prevLineHash32, ln.prevLineHash32,
ln.thisLineNumber, ln.thisLineNumber,
MsgSubType.CONNECTION_FRIEND, relationSubType,
targetBch, targetBch,
0, 0,
targetHeaderHash targetHeaderHash
@ -182,6 +201,16 @@ public class Seed_TestDataPopulation {
sendFriendConnection(senders.get(b), states.get(b), bch(a), headerHashes.get(a), t); sendFriendConnection(senders.get(b), states.get(b), bch(a), headerHashes.get(a), t);
} }
private static void addMutualContact(Map<String, AddBlockSender> senders,
Map<String, ChainState> states,
Map<String, byte[]> 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) { private static void verifyOutFriendsCount(WsSession ws, TestResult r, String login, int expectedCount, Duration t) {
String resp = ws.call("GetFriendsLists#" + login, JsonBuilders.getFriendsLists(login), t); String resp = ws.call("GetFriendsLists#" + login, JsonBuilders.getFriendsLists(login), t);
int st = JsonParsers.status(resp); int st = JsonParsers.status(resp);