import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js'; import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'; import { initPwaPush } from './services/pwa-push-service.js'; import { initCallUiOverlay } from './services/call-ui-service.js'; import { showToast } from './services/channels-ux.js'; import { handleCallPushAction, handleIncomingCallInvite, handleIncomingCallPush, handleIncomingCallSignal, handleStopCallPush, setCallDebugReporter, startDebugConnectionAsInitiator, startDebugConnectionAsResponder, } from './services/call-service.js'; import { authService, addAppLogEntry, authorizeSession, hydrateMessagesFromStore, isSessionInvalidError, refreshSessions, setSessionAuthorizedHandler, setSessionResetHandler, state, terminateCurrentSession, addSignedMessageToChat, markIncomingReadByBaseKey, markOutgoingReadByBaseKey, setContacts, cancelAddAccountFlow, } from './state.js'; import * as startView from './pages/start-view.js'; import * as entrySettingsView from './pages/entry-settings-view.js'; import * as registerView from './pages/register-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js'; import * as registrationKeysView from './pages/registration-keys-view.js'; import * as topupView from './pages/topup-view.js'; import * as loginView from './pages/login-view.js'; import * as loginCameraView from './pages/login-camera-view.js'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; import * as profileView from './pages/profile-view.js'; import * as accountSwitcherView from './pages/account-switcher-view.js'; import * as profileEditView from './pages/profile-edit-view.js'; import * as walletView from './pages/wallet-view.js'; import * as settingsView from './pages/settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js'; import * as connectDeviceView from './pages/connect-device-view.js'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; 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'; import * as userProfileView from './pages/user-profile-view.js'; import * as channelsList from './pages/channels-list.js'; import * as channelView from './pages/channel-view.js'; import * as channelThreadView from './pages/channel-thread-view.js'; import * as addChannelView from './pages/add-channel-view.js'; import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as networkView from './pages/network-view.js'; import * as notificationsView from './pages/notifications-view.js'; const routes = { 'start-view': startView, 'entry-settings-view': entrySettingsView, 'register-view': registerView, 'registration-payment-view': registrationPaymentView, 'registration-keys-view': registrationKeysView, 'topup-view': topupView, 'login-view': loginView, 'login-camera-view': loginCameraView, 'login-password-view': loginPasswordView, 'key-storage-view': keyStorageView, 'profile-view': profileView, 'account-switcher-view': accountSwitcherView, 'profile-edit-view': profileEditView, 'wallet-view': walletView, 'settings-view': settingsView, 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, 'tools-settings-view': toolsSettingsView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, 'device-qr-view': deviceQrView, 'device-camera-view': deviceCameraView, 'show-keys-view': showKeysView, '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, 'user-profile-view': userProfileView, 'channels-list': channelsList, 'channel-view': channelView, 'channel-thread-view': channelThreadView, 'add-channel-view': addChannelView, 'add-personal-public-chat-view': addPersonalPublicChatView, 'network-view': networkView, 'notifications-view': notificationsView, }; const screenEl = document.getElementById('app-screen'); const toolbarEl = document.getElementById('toolbar-slot'); const appShellEl = document.querySelector('.app-shell'); const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; let currentCleanup = null; let pingIntervalId = null; let reconnectIntervalId = null; let sessionRuntimeStarted = false; let connectionState = ''; let connectionStatusText = ''; let connectionRetryBannerEl = null; let connectionStatusCountdownId = null; let connectionNextRetryAtMs = 0; let connectionCheckInFlight = false; let wsSessionRestoreInFlight = null; let uiUpdateReloadScheduled = false; let pwaUpdateCheckAttempted = false; let uiVersionCheckInFlight = false; let uiVersionPeriodicIntervalId = null; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; setClientErrorTransport((payload) => authService.reportClientUiError(payload)); setClientErrorSentNotifier((payload) => { const login = String(state.session.login || 'guest').trim(); const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString(); showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`); }); initPwaInstallPromptHandling(); initCallUiOverlay(); setCallDebugReporter((payload) => authService.reportClientDebug(payload)); function ensureConnectionIndicatorEl() { return document.getElementById('toolbar-connection-indicator'); } function ensureConnectionRetryBannerEl() { if (connectionRetryBannerEl) return connectionRetryBannerEl; if (!appShellEl) return null; const el = document.createElement('div'); el.id = 'connection-retry-banner'; el.className = 'connection-retry-banner is-connecting'; el.textContent = 'Соединяюсь…'; el.addEventListener('click', () => { void triggerImmediateConnectionRetry(); }); appShellEl.append(el); connectionRetryBannerEl = el; return el; } function stopConnectionCountdown() { if (connectionStatusCountdownId) { window.clearInterval(connectionStatusCountdownId); connectionStatusCountdownId = null; } } function getConnectionRetrySecondsLeft() { const leftMs = Math.max(0, Number(connectionNextRetryAtMs || 0) - Date.now()); return Math.ceil(leftMs / 1000); } function refreshConnectionUi() { const state = String(connectionState || 'connecting').trim(); const indicatorEl = ensureConnectionIndicatorEl(); if (indicatorEl) { indicatorEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating', 'is-unknown'); indicatorEl.classList.add(`is-${state || 'unknown'}`); } const bannerEl = ensureConnectionRetryBannerEl(); if (!bannerEl) return; if (state === 'connected' || state === 'updating') { bannerEl.hidden = true; stopConnectionCountdown(); return; } bannerEl.hidden = false; bannerEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating'); bannerEl.classList.add(`is-${state}`); if (connectionStatusText) { bannerEl.textContent = connectionStatusText; return; } if (state === 'connecting') { bannerEl.textContent = 'Соединяюсь… Нажмите, чтобы попробовать сразу'; return; } if (state === 'disconnected') { const secs = getConnectionRetrySecondsLeft(); bannerEl.textContent = `Нет соединения. Повтор через ${secs}с. Нажмите для попытки сейчас`; return; } bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора'; } function startConnectionCountdown() { if (connectionStatusCountdownId) return; connectionStatusCountdownId = window.setInterval(() => { if (connectionState !== 'disconnected') return; const secs = getConnectionRetrySecondsLeft(); if (secs <= 0 && !connectionCheckInFlight) { connectionStatusText = ''; setConnectionStatus('connecting', 'Соединяюсь…'); void checkConnectionHealth(); return; } refreshConnectionUi(); }, 1000); } function savePendingCallPushAction(action, payload = {}) { try { const item = { action: String(action || '').trim().toLowerCase(), payload: payload || {}, savedAtMs: Date.now(), }; localStorage.setItem(CALL_PUSH_PENDING_ACTION_KEY, JSON.stringify(item)); } catch { // ignore localStorage errors } } function loadPendingCallPushAction() { try { const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY); if (!raw) return null; const parsed = JSON.parse(raw); const action = String(parsed?.action || '').trim().toLowerCase(); if (action !== 'accept' && action !== 'decline') return null; return { action, payload: parsed?.payload || {}, }; } catch { return null; } } function clearPendingCallPushAction() { try { localStorage.removeItem(CALL_PUSH_PENDING_ACTION_KEY); } catch { // ignore localStorage errors } } function consumeCallPushActionFromUrlIfAny() { try { const params = new URLSearchParams(window.location.search || ''); const action = String(params.get('callPushAction') || '').trim().toLowerCase(); const rawPayload = String(params.get('callPushPayload') || ''); if (action !== 'accept' && action !== 'decline') return; let payload = {}; if (rawPayload) { try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch { payload = {}; } } savePendingCallPushAction(action, payload); params.delete('callPushAction'); params.delete('callPushPayload'); const nextQuery = params.toString(); const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`; window.history.replaceState({}, '', nextUrl); } catch { // ignore URL parsing errors } } async function processPendingCallPushActionIfPossible() { if (!state.session.isAuthorized) return; const pending = loadPendingCallPushAction(); if (!pending) return; clearPendingCallPushAction(); try { await handleCallPushAction(pending.action, pending.payload || {}); } catch (error) { addAppLogEntry({ level: 'warn', source: 'web-push', message: 'Не удалось выполнить действие звонка из push', details: { action: pending.action, error: error?.message || 'unknown' }, }); } } function setConnectionStatus(nextState, text = '') { const state = String(nextState || '').trim(); if (!state) return; connectionState = state; connectionStatusText = String(text || '').trim(); if (state === 'disconnected') { if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) { connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; } startConnectionCountdown(); } else { stopConnectionCountdown(); } refreshConnectionUi(); } async function triggerImmediateConnectionRetry() { if (connectionCheckInFlight) return; connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; setConnectionStatus('connecting', 'Соединяюсь…'); await checkConnectionHealth(); } function extractBuildHashFromHtml(htmlText) { const html = String(htmlText || ''); if (!html) return ''; const match = html.match(UI_BUILD_HASH_PATTERN); return String(match?.[1] || '').trim(); } async function fetchCurrentHostUiBuildHash() { try { const url = `./index.html?build_probe=${Date.now()}`; const response = await fetch(url, { cache: 'no-store' }); if (!response?.ok) return ''; const html = await response.text(); return extractBuildHashFromHtml(html); } catch { return ''; } } async function checkAndReloadIfUiUpdated(remoteHashRaw) { const remoteHash = String(remoteHashRaw || '').trim(); if (!remoteHash || !CURRENT_BUILD_HASH || remoteHash === CURRENT_BUILD_HASH) return; if (uiVersionCheckInFlight) return; uiVersionCheckInFlight = true; try { const latestHostHash = await fetchCurrentHostUiBuildHash(); if (!latestHostHash || latestHostHash === CURRENT_BUILD_HASH) { addAppLogEntry({ level: 'info', source: 'version-check', message: `Ping сообщил другую версию UI (${remoteHash}), но текущий хост всё ещё отдаёт ${CURRENT_BUILD_HASH}. Reload пропущен, чтобы избежать цикла.`, }); return; } scheduleUiReload({ source: 'version-check', message: `Обнаружена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash} (ping: ${remoteHash})`, delayMs: 600, activateWaitingWorker: true, }); } finally { uiVersionCheckInFlight = false; } } async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) { if (!('serviceWorker' in navigator)) return; try { const registrations = await navigator.serviceWorker.getRegistrations(); await Promise.all(registrations.map(async (registration) => { try { await registration.update(); } catch {} if (activateWaitingWorker && registration.waiting) { try { registration.waiting.postMessage({ type: 'SKIP_WAITING' }); } catch {} } })); } catch { // ignore service worker update failures } } function scheduleUiReload({ source = 'ui-update', message = 'Запрошено обновление интерфейса', delayMs = 700, activateWaitingWorker = true, } = {}) { if (uiUpdateReloadScheduled) return; uiUpdateReloadScheduled = true; addAppLogEntry({ level: 'info', source, message, }); setConnectionStatus('updating'); void refreshServiceWorkers({ activateWaitingWorker }); const ms = Math.min(15_000, Math.max(200, Number(delayMs || 700))); window.setTimeout(() => { window.location.reload(); }, ms); } async function tryUpdatePwaOnFirstConnectedPing() { if (pwaUpdateCheckAttempted) return; pwaUpdateCheckAttempted = true; await refreshServiceWorkers({ activateWaitingWorker: false }); } async function runPeriodicUiVersionCheck() { if (uiUpdateReloadScheduled) return; if (uiVersionCheckInFlight) return; try { const latestHostHash = await fetchCurrentHostUiBuildHash(); if (!latestHostHash || !CURRENT_BUILD_HASH || latestHostHash === CURRENT_BUILD_HASH) return; scheduleUiReload({ source: 'ui-periodic-version-check', message: `Найдена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash}`, delayMs: 600, activateWaitingWorker: true, }); } catch { // ignore periodic check errors } } function startPeriodicUiVersionCheck() { if (uiVersionPeriodicIntervalId) return; // ВРЕМЕННО: частая проверка обновления UI (каждые 5 минут) для диагностики проблем с обновлением клиента. // Позже интервал нужно увеличить или вернуть проверку только по ручному действию. uiVersionPeriodicIntervalId = window.setInterval(() => { void runPeriodicUiVersionCheck(); }, UI_VERSION_PERIODIC_CHECK_MS); } async function checkConnectionHealth() { if (connectionCheckInFlight) return; connectionCheckInFlight = true; if (connectionState !== 'connected') { setConnectionStatus('connecting'); } try { const wasOpen = wsIsOpen(); if (!wasOpen) { await authService.ws.open(); const restored = await ensureSessionAfterWsReconnect(); if (!restored) { connectionStatusText = ''; setConnectionStatus('disconnected'); return; } } const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || ''; void checkAndReloadIfUiUpdated(remoteUiBuildHash); await tryUpdatePwaOnFirstConnectedPing(); setConnectionStatus('connected'); } catch { connectionStatusText = ''; setConnectionStatus('disconnected'); } finally { connectionCheckInFlight = false; } } function startConnectionMonitor() { if (pingIntervalId) { window.clearInterval(pingIntervalId); pingIntervalId = null; } connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; void checkConnectionHealth(); pingIntervalId = window.setInterval(() => { connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; if (connectionState === 'disconnected') { refreshConnectionUi(); } void checkConnectionHealth(); }, CONNECTION_CHECK_INTERVAL_MS); } function isReconnectAllowedNow() { const pageId = getRoute().pageId || ''; return !PRE_AUTH_PAGES.includes(pageId); } function wsIsOpen() { const ws = authService?.ws?.ws; return !!(ws && ws.readyState === WebSocket.OPEN); } async function ensureSessionAfterWsReconnect() { if (!state.session.isAuthorized) return true; if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight; wsSessionRestoreInFlight = (async () => { try { const resumed = await authService.resumeSession(state.session.login, state.session.sessionId); authorizeSession({ login: resumed.login || state.session.login, sessionId: resumed.sessionId || state.session.sessionId, storagePwd: resumed.storagePwd || state.session.storagePwdInMemory, }); addAppLogEntry({ level: 'info', source: 'ws-reconnect', message: 'Сессия восстановлена после переподключения WS', }); return true; } catch (error) { if (isSessionInvalidError(error)) { await terminateCurrentSession({ infoMessage: 'Ваша сессия устарела. Авторизируйтесь заново.', }); return false; } throw error; } })().finally(() => { wsSessionRestoreInFlight = null; }); return wsSessionRestoreInFlight; } function showGlobalErrorAlert(title, details = {}) { const lines = [title]; if (details.message) lines.push(`Сообщение: ${details.message}`); if (details.pageId) lines.push(`Экран: ${details.pageId}`); if (details.sourceUrl) lines.push(`Источник: ${details.sourceUrl}`); if (Number.isFinite(details.lineNumber)) lines.push(`Строка: ${details.lineNumber}`); if (Number.isFinite(details.columnNumber)) lines.push(`Колонка: ${details.columnNumber}`); if (details.reasonType) lines.push(`Тип: ${details.reasonType}`); if (details.stack) lines.push(`Stack:\n${details.stack}`); window.alert(lines.join('\n')); } window.addEventListener('error', (event) => { const pageId = getRoute().pageId || ''; addAppLogEntry({ level: 'error', source: 'global_error', message: event.message || 'Global JS error', details: { pageId, sourceUrl: event.filename || '', line: event.lineno, column: event.colno, stack: event.error?.stack || '', }, }); captureClientError({ kind: 'global_error', message: event.message || 'Global JS error', stack: event.error?.stack || '', sourceUrl: event.filename || '', lineNumber: event.lineno, columnNumber: event.colno, context: { pageId, }, }); showGlobalErrorAlert('Поймана глобальная ошибка UI', { message: event.message || 'Global JS error', stack: event.error?.stack || '', sourceUrl: event.filename || '', lineNumber: event.lineno, columnNumber: event.colno, pageId, }); }); window.addEventListener('unhandledrejection', (event) => { const reason = event.reason; const pageId = getRoute().pageId || ''; addAppLogEntry({ level: 'error', source: 'unhandled_rejection', message: reason?.message || String(reason || 'Unhandled promise rejection'), details: { pageId, reasonType: reason?.constructor?.name || typeof reason, stack: reason?.stack || '', }, }); captureClientError({ kind: 'unhandled_rejection', message: reason?.message || String(reason || 'Unhandled promise rejection'), stack: reason?.stack || '', context: { pageId, reasonType: reason?.constructor?.name || typeof reason, }, }); showGlobalErrorAlert('Пойман необработанный Promise reject', { message: reason?.message || String(reason || 'Unhandled promise rejection'), stack: reason?.stack || '', pageId, reasonType: reason?.constructor?.name || typeof reason, }); }); function renderPageFailureFallback(pageId, error) { captureClientError({ kind: 'page_render_failure', message: error?.message || 'Page render failed', stack: error?.stack || '', context: { pageId, routeHash: window.location.hash || '', }, }); screenEl.innerHTML = ''; const wrap = document.createElement('section'); wrap.className = 'stack'; const card = document.createElement('div'); card.className = 'card stack channels-status'; const title = document.createElement('strong'); title.textContent = 'Не удалось отрисовать экран'; const details = document.createElement('p'); details.className = 'meta-muted'; details.textContent = `Экран: ${pageId || 'неизвестно'}. Попробуйте повторить.`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'primary-btn'; retry.textContent = 'Повторить'; retry.addEventListener('click', () => renderApp()); card.append(title, details, retry); wrap.append(card); screenEl.append(wrap); screenEl.classList.toggle('no-app-chrome', false); toolbarEl.innerHTML = ''; refreshConnectionUi(); } function renderApp() { const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) { navigate('start-view'); return; } if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !state.accountAddingMode) { navigate('messages-list'); return; } if (state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && state.accountAddingMode) { cancelAddAccountFlow(); } const page = routes[pageId] || routes['start-view']; if (typeof currentCleanup === 'function') { currentCleanup(); currentCleanup = null; } try { screenEl.innerHTML = ''; const screen = page.render({ route, navigate }); if (!(screen instanceof Node)) { throw new Error('Page render returned invalid node'); } screenEl.append(screen); currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; const showAppChrome = page.pageMeta?.showAppChrome !== false; screenEl.classList.toggle('no-app-chrome', !showAppChrome); toolbarEl.innerHTML = ''; if (showAppChrome) { toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); } refreshConnectionUi(); } catch (error) { console.error('[renderApp] controlled fallback', error); renderPageFailureFallback(pageId, error); } } async function tryAutoLogin() { if (!state.session.login || !state.session.sessionId) return; try { await authService.reconnect(state.entrySettings.shineServer); const resumed = await authService.resumeSession(state.session.login, state.session.sessionId); authorizeSession(resumed); await refreshSessions(); try { const contacts = await authService.listContacts(); setContacts(contacts.contacts || []); } catch {} } catch (error) { if (isSessionInvalidError(error)) { await terminateCurrentSession({ infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', }); } } } async function ensureSessionRuntimeStarted() { if (!state.session.isAuthorized || sessionRuntimeStarted) return; sessionRuntimeStarted = true; await initPwaPush({ authService, onLog: (entry) => addAppLogEntry(entry), }); startConnectionMonitor(); if (reconnectIntervalId) { window.clearInterval(reconnectIntervalId); reconnectIntervalId = null; } reconnectIntervalId = window.setInterval(async () => { if (!state.session.isAuthorized) return; if (!isReconnectAllowedNow()) return; if (wsIsOpen()) return; try { await authService.ws.open(); const restored = await ensureSessionAfterWsReconnect(); if (!restored) return; addAppLogEntry({ level: 'info', source: 'ws-reconnect', message: 'WS переподключен автоматически', }); } catch (error) { addAppLogEntry({ level: 'warn', source: 'ws-reconnect', message: 'Попытка автопереподключения не удалась', details: { error: error?.message || 'unknown' }, }); } }, 15_000); await processPendingCallPushActionIfPossible(); } async function init() { consumeCallPushActionFromUrlIfAny(); addAppLogEntry({ level: 'info', source: 'app', message: 'Инициализация UI запущена', }); setSessionResetHandler(() => { sessionRuntimeStarted = false; startConnectionMonitor(); if (reconnectIntervalId) { window.clearInterval(reconnectIntervalId); reconnectIntervalId = null; } navigate('start-view'); }); setSessionAuthorizedHandler(() => { void ensureSessionRuntimeStarted(); void processPendingCallPushActionIfPossible(); }); if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', (event) => { const data = event?.data || {}; if (data.type === 'SHINE_CALL_PUSH_ACTION') { const action = String(data.action || '').trim().toLowerCase(); const payload = data.payload || {}; if (action === 'accept' || action === 'decline') { savePendingCallPushAction(action, payload); void processPendingCallPushActionIfPossible(); } return; } 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, }); if (kind === 'incoming_call' && !payload.stale && state.session.isAuthorized) { void handleIncomingCallPush(payload); } else if (kind === 'stop_call' && state.session.isAuthorized) { void handleStopCallPush(payload); } window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload })); }); } authService.onEvent('SessionRevoked', async () => { await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' }); }); authService.onEvent('ForceUiReload', async (evt) => { const payload = evt?.payload || {}; const reason = String(payload.reason || 'server_debug_api').trim() || 'server_debug_api'; const reloadAfterMs = Number(payload.reloadAfterMs || 700); scheduleUiReload({ source: 'server-ui-reload', message: `Сервер запросил обновление UI (${reason})`, delayMs: reloadAfterMs, activateWaitingWorker: true, }); }); authService.onEvent('SignedMessageArrived', async (evt) => { const payload = evt?.payload || {}; const messageKey = String(payload.messageKey || '').trim(); const blobB64 = String(payload.blobB64 || '').trim(); if (!messageKey || !blobB64) return; let parsed; try { parsed = authService.parseSignedMessageBlob(blobB64); } catch (error) { addAppLogEntry({ level: 'warn', source: 'signed-dm', message: 'Не удалось распарсить входящий signed message', details: { messageKey, error: error?.message || 'unknown' }, }); return; } const fromLogin = parsed.fromLogin || ''; const toLogin = parsed.toLogin || ''; const messageType = Number(parsed.messageType || 0); const chatId = messageType === 2 ? toLogin : fromLogin; const text = (messageType === 1 || messageType === 2) ? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0)) : ''; let shouldRefreshToolbarUnread = false; if (messageType === 1 || messageType === 2) { const isIncomingForCurrent = messageType === 1; const added = addSignedMessageToChat({ chatId, messageKey, baseKey: parsed.baseKey, from: isIncomingForCurrent ? 'in' : 'out', text, messageType, unread: isIncomingForCurrent, rawBlobB64: blobB64, }); if (added) { addAppLogEntry({ level: 'info', source: 'signed-dm', message: isIncomingForCurrent ? `Новое входящее сообщение от ${fromLogin}` : `Синхронизирована исходящая копия в чате ${chatId}`, details: { messageKey, baseKey: parsed.baseKey, messageType }, }); } if (added && isIncomingForCurrent) { shouldRefreshToolbarUnread = true; } if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) { try { new Notification(`Сообщение от ${fromLogin}`, { body: text || '' }); } catch {} } } else if (messageType === 3 || messageType === 4) { let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); if (!refBaseKey) { try { const ref = authService.parseReadReceiptPayload(parsed.payloadBytes); refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; } catch {} } if (refBaseKey) { if (messageType === 3) { markOutgoingReadByBaseKey(refBaseKey); } else { markIncomingReadByBaseKey(refBaseKey); } } addAppLogEntry({ level: 'info', source: 'signed-dm', message: 'Получено подтверждение прочтения', details: { messageKey, baseKey: parsed.baseKey, messageType }, }); } try { await authService.ackSessionDelivery(messageKey); } catch (error) { addAppLogEntry({ level: 'warn', source: 'signed-dm', message: 'Не удалось отправить ACK доставки по сессии', details: { messageKey, error: error?.message || 'unknown' }, }); } const pageId = getRoute().pageId || ''; if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) { renderApp(); } }); authService.onEvent('IncomingCallInvite', async (evt) => { try { await handleIncomingCallInvite(evt); } catch {} }); authService.onEvent('IncomingCallSignal', async (evt) => { try { await handleIncomingCallSignal(evt); } catch {} }); authService.onEvent('DebugConnectPrepareResponder', async (evt) => { try { const p = evt?.payload || {}; await startDebugConnectionAsResponder({ runId: p.runId, callId: p.callId, peerLogin: p.peerLogin, peerSessionId: p.peerSessionId, }); addAppLogEntry({ level: 'info', source: 'debug-connect', message: 'Получена команда debug responder', details: p, }); } catch (error) { addAppLogEntry({ level: 'error', source: 'debug-connect', message: 'Ошибка запуска debug responder', details: { error: error?.message || 'unknown' }, }); await authService.reportClientDebug({ runId: evt?.payload?.runId || '', level: 'error', message: 'debug_responder_failed', details: error?.message || 'unknown', }); } }); authService.onEvent('DebugConnectStartInitiator', async (evt) => { try { const p = evt?.payload || {}; await startDebugConnectionAsInitiator({ runId: p.runId, callId: p.callId, peerLogin: p.peerLogin, peerSessionId: p.peerSessionId, }); addAppLogEntry({ level: 'info', source: 'debug-connect', message: 'Получена команда debug initiator', details: p, }); } catch (error) { addAppLogEntry({ level: 'error', source: 'debug-connect', message: 'Ошибка запуска debug initiator', details: { error: error?.message || 'unknown' }, }); await authService.reportClientDebug({ runId: evt?.payload?.runId || '', level: 'error', message: 'debug_initiator_failed', details: error?.message || 'unknown', }); } }); await tryAutoLogin(); await hydrateMessagesFromStore(); startConnectionMonitor(); startPeriodicUiVersionCheck(); await ensureSessionRuntimeStarted(); if (!window.location.hash) { navigate(state.session.isAuthorized ? 'messages-list' : 'start-view'); } else { renderApp(); } window.addEventListener('hashchange', renderApp); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; void checkConnectionHealth(); }); } init();