Добавить диагностику PWA/Push и endpoint тестового push

This commit is contained in:
AidarKC 2026-04-21 01:10:56 +03:00
parent 185ba5b1d3
commit d07602b0a9
11 changed files with 811 additions and 4 deletions

View File

@ -16,6 +16,7 @@ self.addEventListener('push', (event) => {
let rawText = '';
let kind = '';
let fromLogin = '';
let title = '';
try {
if (event.data) {
const text = event.data.text();
@ -23,6 +24,7 @@ self.addEventListener('push', (event) => {
try {
const json = JSON.parse(rawText || '{}');
kind = String(json.kind || '');
title = String(json.title || '');
body = String(json.text || '');
fromLogin = String(json.fromLogin || '');
} catch {
@ -33,11 +35,14 @@ self.addEventListener('push', (event) => {
// ignore
}
const shouldNotify = kind === 'new_message' || (!kind && body);
const shouldNotify = kind === 'new_message' || kind === 'test_push' || (!kind && body);
const notificationTitle = kind === 'test_push'
? (title || 'SHiNE: тестовый push')
: 'SHiNE: входящее сообщение';
const notifyPromise = shouldNotify
? self.registration.showNotification('SHiNE: входящее сообщение', {
? self.registration.showNotification(notificationTitle, {
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
tag: 'shine-direct-message',
tag: kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message',
renotify: true,
})
: Promise.resolve();

View File

@ -44,6 +44,7 @@ import * as showKeysView from './pages/show-keys-view.js';
import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
@ -78,6 +79,7 @@ const routes = {
'device-session-view': deviceSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
@ -330,12 +332,25 @@ async function init() {
const data = event?.data || {};
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
const kind = String(payload.kind || '').trim();
const now = Date.now();
try {
localStorage.setItem('shine-ui-last-push-at-v1', String(now));
localStorage.setItem('shine-ui-last-push-payload-v1', JSON.stringify(payload || {}));
if (kind === 'test_push') {
localStorage.setItem('shine-ui-last-test-push-status-v1', 'доставлен');
localStorage.setItem('shine-ui-last-test-push-at-v1', String(now));
}
} catch {
// ignore localStorage errors
}
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
});
}

View File

@ -0,0 +1,533 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
export const pageMeta = { id: 'pwa-diagnostics-view', title: 'Диагностика PWA / Push' };
const LS_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
const LS_SUBSCRIPTION_UPDATED_AT_KEY = 'shine-ui-webpush-subscription-updated-at-v1';
const LS_LAST_TEST_STATUS_KEY = 'shine-ui-last-test-push-status-v1';
const LS_LAST_TEST_AT_KEY = 'shine-ui-last-test-push-at-v1';
const LS_LAST_PUSH_PAYLOAD_KEY = 'shine-ui-last-push-payload-v1';
function vapidBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function formatTs(ts) {
const num = Number(ts);
if (!Number.isFinite(num) || num <= 0) return '—';
try {
return new Date(num).toLocaleString('ru-RU');
} catch {
return String(num);
}
}
function detectLaunchMode() {
try {
if (window.matchMedia?.('(display-mode: standalone)')?.matches || window.navigator?.standalone === true) {
return 'standalone';
}
if (window.matchMedia?.('(display-mode: fullscreen)')?.matches) {
return 'fullscreen';
}
if (window.matchMedia?.('(display-mode: browser)')?.matches) {
return 'браузер';
}
return 'unknown';
} catch {
return 'unknown';
}
}
function detectIosSafari() {
const ua = String(navigator.userAgent || '').toLowerCase();
const isIos = /iphone|ipad|ipod/.test(ua);
const isSafari = /safari/.test(ua) && !/crios|fxios|edgios|opr\//.test(ua);
return isIos && isSafari;
}
function buildInstallState({ launchMode, manifestOk, swRegistered }) {
if (launchMode === 'standalone' || launchMode === 'fullscreen') return 'уже установлено';
if (canInstallPwa()) return 'доступна кнопка установки';
if (!window.isSecureContext || !manifestOk || !swRegistered) return 'недоступно';
return 'браузер не сообщает';
}
function toneFor(value, type = 'binary') {
const v = String(value || '').toLowerCase();
if (type === 'permission') {
if (v === 'granted') return 'ok';
if (v === 'default') return 'warn';
if (v === 'denied') return 'bad';
return 'neutral';
}
if (type === 'testPush') {
if (v === 'доставлен') return 'ok';
if (v === 'отправлен') return 'warn';
if (v === 'ошибка') return 'bad';
return 'neutral';
}
if (type === 'install') {
if (v === 'уже установлено' || v === 'доступна кнопка установки') return 'ok';
if (v === 'браузер не сообщает' || v === 'недоступно') return 'warn';
return 'neutral';
}
if (type === 'launchMode') {
if (v === 'standalone' || v === 'fullscreen' || v === 'браузер') return 'ok';
return 'neutral';
}
if (v === 'да' || v === 'найден' || v === 'зарегистрирован' || v === 'поддерживается' || v === 'есть') return 'ok';
if (v === 'не найден' || v === 'не зарегистрирован' || v === 'нет' || v === 'ошибка') return 'bad';
return 'neutral';
}
function rowEl(label, value, tone) {
const row = document.createElement('div');
row.className = 'pwa-diag-row';
const left = document.createElement('span');
left.className = 'pwa-diag-key';
left.textContent = label;
const right = document.createElement('span');
right.className = 'pwa-diag-value';
right.textContent = String(value || '—');
const indicator = document.createElement('span');
indicator.className = `pwa-diag-indicator is-${tone || 'neutral'}`;
row.append(left, right, indicator);
return row;
}
async function collectDiagnostics() {
const result = {
secureContext: window.isSecureContext === true,
manifestStatus: 'не найден',
manifestUrl: '',
swStatus: 'не зарегистрирован',
swError: '',
launchMode: detectLaunchMode(),
installAvailability: 'браузер не сообщает',
notificationsApi: 'нет',
pushApi: 'нет',
notificationPermission: 'default',
pushSubscription: 'нет',
subscriptionEndpoint: '',
subscriptionUpdatedAtMs: 0,
lastTestPush: 'не запускался',
lastTestPushAtMs: 0,
lastPushPayload: null,
iosSafariHint: false,
tech: {},
};
result.notificationsApi = 'Notification' in window ? 'поддерживается' : 'нет';
result.pushApi = 'PushManager' in window ? 'поддерживается' : 'нет';
if ('Notification' in window) {
result.notificationPermission = String(Notification.permission || 'default');
}
try {
const linkEl = document.querySelector('link[rel="manifest"]');
const href = String(linkEl?.getAttribute('href') || '').trim();
if (href) {
const url = new URL(href, window.location.href).toString();
const resp = await fetch(url, { cache: 'no-store' });
result.manifestStatus = resp.ok ? 'найден' : 'не найден';
result.manifestUrl = url;
} else {
result.manifestStatus = 'не найден';
}
} catch {
result.manifestStatus = 'не найден';
}
let registration = null;
if ('serviceWorker' in navigator) {
try {
registration = await navigator.serviceWorker.getRegistration();
result.swStatus = registration ? 'зарегистрирован' : 'не зарегистрирован';
} catch (error) {
result.swStatus = 'ошибка';
result.swError = error?.message || 'unknown';
}
} else {
result.swStatus = 'не зарегистрирован';
}
result.installAvailability = buildInstallState({
launchMode: result.launchMode,
manifestOk: result.manifestStatus === 'найден',
swRegistered: result.swStatus === 'зарегистрирован',
});
if (registration && ('PushManager' in window)) {
try {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
result.pushSubscription = 'есть';
result.subscriptionEndpoint = String(subscription.endpoint || '');
} else {
result.pushSubscription = 'нет';
}
} catch (error) {
result.pushSubscription = 'ошибка получения';
result.swError = result.swError || (error?.message || 'unknown');
}
}
try {
const updatedAt = Number(localStorage.getItem(LS_SUBSCRIPTION_UPDATED_AT_KEY) || 0);
result.subscriptionUpdatedAtMs = Number.isFinite(updatedAt) ? updatedAt : 0;
} catch {
result.subscriptionUpdatedAtMs = 0;
}
try {
const st = String(localStorage.getItem(LS_LAST_TEST_STATUS_KEY) || '').trim();
const at = Number(localStorage.getItem(LS_LAST_TEST_AT_KEY) || 0);
if (st) result.lastTestPush = st;
if (Number.isFinite(at) && at > 0) result.lastTestPushAtMs = at;
} catch {
// ignore
}
try {
const raw = localStorage.getItem(LS_LAST_PUSH_PAYLOAD_KEY);
if (raw) result.lastPushPayload = JSON.parse(raw);
} catch {
result.lastPushPayload = null;
}
result.iosSafariHint = detectIosSafari() && !isStandalonePwaMode();
result.tech = {
secureContext: window.isSecureContext,
hasServiceWorkerApi: 'serviceWorker' in navigator,
hasPushManagerApi: 'PushManager' in window,
hasNotificationsApi: 'Notification' in window,
notificationPermission: result.notificationPermission,
launchMode: result.launchMode,
canInstallPrompt: canInstallPwa(),
swStatus: result.swStatus,
swError: result.swError,
manifestStatus: result.manifestStatus,
manifestUrl: result.manifestUrl,
pushSubscription: result.pushSubscription,
subscriptionEndpoint: result.subscriptionEndpoint,
subscriptionUpdatedAtMs: result.subscriptionUpdatedAtMs,
lastTestPush: result.lastTestPush,
lastTestPushAtMs: result.lastTestPushAtMs,
lastPushPayload: result.lastPushPayload,
};
return result;
}
function buildRecommendations(diag) {
const rows = [];
if (!diag.secureContext) {
rows.push('Откройте приложение через HTTPS. Без этого push и install-mode работают нестабильно.');
}
if (diag.manifestStatus !== 'найден') {
rows.push('Проверьте доступность `manifest.webmanifest` и заголовки кэша для него.');
}
if (diag.swStatus !== 'зарегистрирован') {
rows.push('Service Worker не зарегистрирован. Проверьте путь `firebase-messaging-sw.js` и HTTPS.');
}
if (diag.notificationPermission === 'default') {
rows.push('Выдайте разрешение на уведомления через кнопку «Запросить разрешение на уведомления».');
} else if (diag.notificationPermission === 'denied') {
rows.push('Разрешение уведомлений заблокировано в браузере. Разрешите уведомления в настройках сайта.');
}
if (diag.pushSubscription !== 'есть') {
rows.push('Создайте push-подписку кнопкой «Подписаться на push».');
}
if (diag.installAvailability === 'доступна кнопка установки') {
rows.push('Установите приложение как PWA для более стабильной доставки push в фоне.');
}
if (diag.iosSafariHint) {
rows.push('iPhone/Safari: push работает только после «Добавить на экран Домой».');
}
if (!rows.length) {
rows.push('Критических проблем не найдено. Проверьте тестовый push для финальной валидации.');
}
return rows;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Диагностика PWA / Push',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const statusCard = document.createElement('div');
statusCard.className = 'card stack';
const statusList = document.createElement('div');
statusList.className = 'stack pwa-diag-list';
statusCard.append(statusList);
const actionsCard = document.createElement('div');
actionsCard.className = 'card stack';
const actions = document.createElement('div');
actions.className = 'pwa-diag-actions';
actions.innerHTML = `
<button class="text-btn" type="button" id="diag-refresh">Проверить заново</button>
<button class="text-btn" type="button" id="diag-notif">Запросить разрешение на уведомления</button>
<button class="text-btn" type="button" id="diag-subscribe">Подписаться на push</button>
<button class="text-btn" type="button" id="diag-unsubscribe">Отписаться от push</button>
<button class="text-btn" type="button" id="diag-testpush">Отправить тестовый push</button>
<button class="text-btn" type="button" id="diag-toggle-details">Показать тех. детали</button>
`;
actionsCard.append(actions);
const recommendationsCard = document.createElement('div');
recommendationsCard.className = 'card stack';
const recommendationsTitle = document.createElement('strong');
recommendationsTitle.textContent = 'Рекомендации';
const recommendationsList = document.createElement('div');
recommendationsList.className = 'stack pwa-diag-recommendations';
recommendationsCard.append(recommendationsTitle, recommendationsList);
const detailsCard = document.createElement('div');
detailsCard.className = 'card stack';
detailsCard.hidden = true;
const detailsTitle = document.createElement('strong');
detailsTitle.textContent = 'Технические детали';
const detailsPre = document.createElement('pre');
detailsPre.className = 'pwa-diag-json';
detailsCard.append(detailsTitle, detailsPre);
const info = document.createElement('div');
info.className = 'meta-muted';
screen.append(statusCard, actionsCard, recommendationsCard, detailsCard, info);
let latestDiag = null;
let lastTestResponse = null;
const setInfo = (text) => {
info.textContent = String(text || '');
};
const rerender = () => {
if (!latestDiag) return;
statusList.innerHTML = '';
statusList.append(
rowEl('HTTPS', latestDiag.secureContext ? 'да' : 'нет', toneFor(latestDiag.secureContext ? 'да' : 'нет')),
rowEl('Manifest', latestDiag.manifestStatus, toneFor(latestDiag.manifestStatus)),
rowEl('Service Worker', latestDiag.swStatus, toneFor(latestDiag.swStatus)),
rowEl('Режим запуска', latestDiag.launchMode, toneFor(latestDiag.launchMode, 'launchMode')),
rowEl('Возможность установки', latestDiag.installAvailability, toneFor(latestDiag.installAvailability, 'install')),
rowEl('Notifications API', latestDiag.notificationsApi, toneFor(latestDiag.notificationsApi)),
rowEl('Push API', latestDiag.pushApi, toneFor(latestDiag.pushApi)),
rowEl('Разрешение на уведомления', latestDiag.notificationPermission, toneFor(latestDiag.notificationPermission, 'permission')),
rowEl('Push subscription', latestDiag.pushSubscription, toneFor(latestDiag.pushSubscription)),
rowEl('Последний тест push', latestDiag.lastTestPush, toneFor(latestDiag.lastTestPush, 'testPush')),
);
if (latestDiag.subscriptionEndpoint) {
statusList.append(rowEl('Endpoint подписки', latestDiag.subscriptionEndpoint, 'neutral'));
}
if (latestDiag.subscriptionUpdatedAtMs) {
statusList.append(rowEl('Дата обновления подписки', formatTs(latestDiag.subscriptionUpdatedAtMs), 'neutral'));
}
if (latestDiag.lastTestPushAtMs) {
statusList.append(rowEl('Время последнего теста', formatTs(latestDiag.lastTestPushAtMs), 'neutral'));
}
recommendationsList.innerHTML = '';
buildRecommendations(latestDiag).forEach((entry) => {
const p = document.createElement('p');
p.className = 'meta-muted';
p.textContent = `${entry}`;
recommendationsList.append(p);
});
const details = {
diagnostics: latestDiag.tech || {},
lastTestPushResponse: lastTestResponse,
login: state.session.login || '',
sessionId: state.session.sessionId || '',
};
detailsPre.textContent = JSON.stringify(details, null, 2);
};
const reloadStatuses = async () => {
setInfo('Обновляю статусы...');
latestDiag = await collectDiagnostics();
rerender();
if (latestDiag.swError) {
setInfo(`Есть предупреждения: ${latestDiag.swError}`);
} else {
setInfo(`Статусы обновлены: ${new Date().toLocaleTimeString('ru-RU')}`);
}
};
const withButtonBusy = async (btn, fn) => {
btn.disabled = true;
try {
await fn();
} finally {
btn.disabled = false;
}
};
actions.querySelector('#diag-refresh').addEventListener('click', async (event) => {
await withButtonBusy(event.currentTarget, reloadStatuses);
});
actions.querySelector('#diag-notif').addEventListener('click', async (event) => {
await withButtonBusy(event.currentTarget, async () => {
if (!('Notification' in window)) {
setInfo('Этот браузер не поддерживает Notifications API.');
return;
}
const permission = await Notification.requestPermission();
setInfo(`Разрешение на уведомления: ${permission}`);
await reloadStatuses();
});
});
actions.querySelector('#diag-subscribe').addEventListener('click', async (event) => {
await withButtonBusy(event.currentTarget, async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setInfo('ServiceWorker/PushManager не поддерживаются в этом браузере.');
return;
}
const vapidPublicKey = String(window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ || '').trim();
if (!vapidPublicKey) {
setInfo('Не задан публичный VAPID ключ.');
return;
}
const registration = await navigator.serviceWorker.register('./firebase-messaging-sw.js');
let permission = String(Notification.permission || 'default');
if (permission !== 'granted') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
setInfo(`Разрешение на уведомления: ${permission}`);
await reloadStatuses();
return;
}
let sub = await registration.pushManager.getSubscription();
if (!sub) {
sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidBase64ToUint8Array(vapidPublicKey),
});
}
const json = sub.toJSON();
await authService.upsertPushToken({
endpoint: json.endpoint || '',
p256dhKey: json.keys?.p256dh || '',
authKey: json.keys?.auth || '',
platform: 'web',
userAgent: navigator.userAgent || '',
});
const now = Date.now();
try {
localStorage.setItem(LS_SUBSCRIPTION_KEY, JSON.stringify(sub));
localStorage.setItem(LS_SUBSCRIPTION_UPDATED_AT_KEY, String(now));
} catch {
// ignore local storage errors
}
setInfo('Push-подписка создана и отправлена на сервер.');
await reloadStatuses();
});
});
actions.querySelector('#diag-unsubscribe').addEventListener('click', async (event) => {
await withButtonBusy(event.currentTarget, async () => {
if (!('serviceWorker' in navigator)) {
setInfo('Service Worker API недоступен.');
return;
}
const registration = await navigator.serviceWorker.getRegistration();
if (!registration || !registration.pushManager) {
setInfo('PushManager недоступен.');
await reloadStatuses();
return;
}
const sub = await registration.pushManager.getSubscription();
if (!sub) {
setInfo('Активная push-подписка не найдена.');
await reloadStatuses();
return;
}
await sub.unsubscribe();
try {
localStorage.removeItem(LS_SUBSCRIPTION_KEY);
localStorage.setItem(LS_SUBSCRIPTION_UPDATED_AT_KEY, String(Date.now()));
} catch {
// ignore
}
setInfo('Push-подписка удалена на клиенте.');
await reloadStatuses();
});
});
actions.querySelector('#diag-testpush').addEventListener('click', async (event) => {
await withButtonBusy(event.currentTarget, async () => {
try {
localStorage.setItem(LS_LAST_TEST_STATUS_KEY, 'отправлен');
localStorage.setItem(LS_LAST_TEST_AT_KEY, String(Date.now()));
} catch {
// ignore
}
const resp = await authService.sendTestWebPush({
login: state.session.login || '',
title: 'SHiNE: тестовый push',
text: `Тестовый push для ${state.session.login || 'unknown'}`,
});
lastTestResponse = resp;
setInfo(`Тестовый push отправлен. Доставлено: ${Number(resp?.delivered || 0)}.`);
await reloadStatuses();
}).catch(async (error) => {
try {
localStorage.setItem(LS_LAST_TEST_STATUS_KEY, 'ошибка');
localStorage.setItem(LS_LAST_TEST_AT_KEY, String(Date.now()));
} catch {
// ignore
}
setInfo(`Ошибка тестового push: ${error?.message || 'unknown'}`);
await reloadStatuses();
});
});
actions.querySelector('#diag-toggle-details').addEventListener('click', (event) => {
detailsCard.hidden = !detailsCard.hidden;
event.currentTarget.textContent = detailsCard.hidden ? 'Показать тех. детали' : 'Скрыть тех. детали';
});
const onPushDiagUpdate = async () => {
await reloadStatuses();
};
window.addEventListener('shine-push-diagnostics-update', onPushDiagUpdate);
void reloadStatuses();
screen.cleanup = () => {
window.removeEventListener('shine-push-diagnostics-update', onPushDiagUpdate);
};
return screen;
}

View File

@ -28,6 +28,7 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
<button class="text-btn" type="button" id="settings-pwa-install">Зарегистрировать PWA</button>
`;
@ -36,6 +37,7 @@ export function render({ navigate }) {
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
card.querySelector('#settings-pwa-diagnostics').addEventListener('click', () => navigate('pwa-diagnostics-view'));
const signOutBtn = card.querySelector('#settings-signout');
const pwaInstallBtn = card.querySelector('#settings-pwa-install');

View File

@ -98,7 +98,8 @@ export function resolveToolbarActive(pageId) {
pageId === 'show-keys-view' ||
pageId === 'device-session-view' ||
pageId === 'language-view' ||
pageId === 'app-log-view'
pageId === 'app-log-view' ||
pageId === 'pwa-diagnostics-view'
) {
return 'profile-view';
}

View File

@ -1226,6 +1226,17 @@ export class AuthService {
return response.payload || {};
}
async sendTestWebPush({ login = '', sessionId = '', title = '', text = '' } = {}) {
const payload = {};
if (String(login || '').trim()) payload.login = String(login || '').trim();
if (String(sessionId || '').trim()) payload.sessionId = String(sessionId || '').trim();
if (String(title || '').trim()) payload.title = String(title || '').trim();
if (String(text || '').trim()) payload.text = String(text || '').trim();
const response = await this.ws.request('SendTestWebPush', payload);
if (response.status !== 200) throw opError('SendTestWebPush', response);
return response.payload || {};
}
async buildSignedDm2Block({
login,
toLogin,

View File

@ -726,6 +726,82 @@
gap: 8px;
}
.pwa-diag-list {
gap: 8px;
}
.pwa-diag-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 10px;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(143, 167, 215, 0.18);
background: rgba(14, 24, 45, 0.42);
}
.pwa-diag-key {
color: #cdd9f2;
font-size: 13px;
}
.pwa-diag-value {
color: #edf3ff;
font-size: 13px;
text-align: right;
word-break: break-word;
}
.pwa-diag-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: #64708a;
}
.pwa-diag-indicator.is-ok {
background: #66d69a;
box-shadow: 0 0 0 3px rgba(102, 214, 154, 0.18);
}
.pwa-diag-indicator.is-warn {
background: #f0c56b;
box-shadow: 0 0 0 3px rgba(240, 197, 107, 0.18);
}
.pwa-diag-indicator.is-bad {
background: #e48792;
box-shadow: 0 0 0 3px rgba(228, 135, 146, 0.18);
}
.pwa-diag-indicator.is-neutral {
background: #8994ab;
}
.pwa-diag-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.pwa-diag-recommendations {
gap: 6px;
}
.pwa-diag-json {
margin: 0;
max-height: 260px;
overflow: auto;
border-radius: 10px;
padding: 10px;
font-size: 12px;
line-height: 1.35;
color: #dfe9ff;
background: rgba(8, 15, 28, 0.82);
border: 1px solid rgba(134, 157, 205, 0.2);
}
.contact-search-actions {
display: grid;
grid-template-columns: 1fr;

View File

@ -65,6 +65,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
@ -73,6 +74,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
// --- NEW: Ping ---
@ -129,6 +131,7 @@ public final class JsonHandlerRegistry {
// --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
@ -180,6 +183,7 @@ public final class JsonHandlerRegistry {
// --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),

View File

@ -0,0 +1,108 @@
package server.logic.ws_protocol.JSON.messages;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry;
import java.util.ArrayList;
import java.util.List;
public class Net_SendTestWebPush_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_SendTestWebPush_Request req = (Net_SendTestWebPush_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String authLogin = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
String targetLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim();
if (targetLogin.isBlank()) targetLogin = authLogin;
if (!targetLogin.equalsIgnoreCase(authLogin)) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"FORBIDDEN_TARGET_LOGIN",
"Разрешена отправка тестового push только для текущего авторизованного пользователя"
);
}
List<ActiveSessionEntry> targets = new ArrayList<>();
String targetSessionId = String.valueOf(req.getSessionId() == null ? "" : req.getSessionId()).trim();
if (!targetSessionId.isBlank()) {
ActiveSessionEntry one = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId);
if (one == null || !targetLogin.equalsIgnoreCase(one.getLogin())) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "SESSION_NOT_FOUND", "Сессия для тестового push не найдена");
}
targets.add(one);
} else {
targets = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
}
String title = String.valueOf(req.getTitle() == null ? "" : req.getTitle()).trim();
if (title.isBlank()) title = "SHiNE: тестовый push";
String text = String.valueOf(req.getText() == null ? "" : req.getText()).trim();
if (text.isBlank()) text = "Тестовое push-уведомление отправлено успешно.";
int sessionsWithPushConfig = 0;
int delivered = 0;
int failed = 0;
long now = System.currentTimeMillis();
for (ActiveSessionEntry s : targets) {
if (isBlank(s.getPushEndpoint()) || isBlank(s.getPushP256dhKey()) || isBlank(s.getPushAuthKey())) {
continue;
}
sessionsWithPushConfig++;
String payload = "{\"kind\":\"test_push\",\"title\":\"" + jsonEscape(title) + "\",\"text\":\"" + jsonEscape(text) + "\",\"sentAt\":" + now + "}";
boolean ok = WebPushSender.sendBase64Payload(
s.getPushEndpoint(),
s.getPushP256dhKey(),
s.getPushAuthKey(),
payload
);
if (ok) delivered++;
else failed++;
}
Net_SendTestWebPush_Response resp = new Net_SendTestWebPush_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setTargetLogin(targetLogin);
resp.setAttemptedSessions(targets.size());
resp.setSessionsWithPushConfig(sessionsWithPushConfig);
resp.setDelivered(delivered);
resp.setFailed(failed);
resp.setSentAtMs(now);
return resp;
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String jsonEscape(String s) {
if (s == null) return "";
StringBuilder out = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') out.append("\\\\");
else if (c == '"') out.append("\\\"");
else if (c == '\n') out.append("\\n");
else if (c == '\r') out.append("\\r");
else if (c == '\t') out.append("\\t");
else out.append(c);
}
return out.toString();
}
}

View File

@ -0,0 +1,22 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendTestWebPush_Request extends Net_Request {
private String login;
private String sessionId;
private String title;
private String text;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
}

View File

@ -0,0 +1,30 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SendTestWebPush_Response extends Net_Response {
private String targetLogin;
private int attemptedSessions;
private int sessionsWithPushConfig;
private int delivered;
private int failed;
private long sentAtMs;
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public int getAttemptedSessions() { return attemptedSessions; }
public void setAttemptedSessions(int attemptedSessions) { this.attemptedSessions = attemptedSessions; }
public int getSessionsWithPushConfig() { return sessionsWithPushConfig; }
public void setSessionsWithPushConfig(int sessionsWithPushConfig) { this.sessionsWithPushConfig = sessionsWithPushConfig; }
public int getDelivered() { return delivered; }
public void setDelivered(int delivered) { this.delivered = delivered; }
public int getFailed() { return failed; }
public void setFailed(int failed) { this.failed = failed; }
public long getSentAtMs() { return sentAtMs; }
public void setSentAtMs(long sentAtMs) { this.sentAtMs = sentAtMs; }
}