534 lines
21 KiB
JavaScript
534 lines
21 KiB
JavaScript
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;
|
||
}
|