From d07602b0a9ce52c090eeace72a8c90e9568701d9c7ee869f15e6370329e87a7d Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 21 Apr 2026 01:10:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D1=83=20PWA/Push=20=D0=B8=20endpoint=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/firebase-messaging-sw.js | 11 +- shine-UI/js/app.js | 15 + shine-UI/js/pages/pwa-diagnostics-view.js | 533 ++++++++++++++++++ shine-UI/js/pages/settings-view.js | 2 + shine-UI/js/router.js | 3 +- shine-UI/js/services/auth-service.js | 11 + shine-UI/styles/components.css | 76 +++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../messages/Net_SendTestWebPush_Handler.java | 108 ++++ .../entyties/Net_SendTestWebPush_Request.java | 22 + .../Net_SendTestWebPush_Response.java | 30 + 11 files changed, 811 insertions(+), 4 deletions(-) create mode 100644 shine-UI/js/pages/pwa-diagnostics-view.js create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendTestWebPush_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendTestWebPush_Response.java 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; } +}