diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js
index 11df433..14b0316 100644
--- a/shine-UI/firebase-messaging-sw.js
+++ b/shine-UI/firebase-messaging-sw.js
@@ -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();
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 311c2be..aa7a321 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -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 }));
});
}
diff --git a/shine-UI/js/pages/pwa-diagnostics-view.js b/shine-UI/js/pages/pwa-diagnostics-view.js
new file mode 100644
index 0000000..161bf54
--- /dev/null
+++ b/shine-UI/js/pages/pwa-diagnostics-view.js
@@ -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 = `
+
+
+
+
+
+
+ `;
+ 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;
+}
diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js
index dc1be71..b395fb3 100644
--- a/shine-UI/js/pages/settings-view.js
+++ b/shine-UI/js/pages/settings-view.js
@@ -28,6 +28,7 @@ export function render({ navigate }) {
+
`;
@@ -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');
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 62aa722..e08d89f 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -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';
}
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 48d4930..430877e 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -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,
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 6ad8481..7428890 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -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;
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
index 3d8051e..4706883 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
@@ -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),
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendTestWebPush_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendTestWebPush_Handler.java
new file mode 100644
index 0000000..ce2e143
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendTestWebPush_Handler.java
@@ -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 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();
+ }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Request.java
new file mode 100644
index 0000000..248d0ae
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Request.java
@@ -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; }
+}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Response.java
new file mode 100644
index 0000000..2fc16f6
--- /dev/null
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Response.java
@@ -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; }
+}