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;
}