1049 lines
34 KiB
JavaScript
1049 lines
34 KiB
JavaScript
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,
|
||
} 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 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,
|
||
'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.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.pathname || '',
|
||
},
|
||
});
|
||
|
||
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)) {
|
||
navigate('messages-list');
|
||
return;
|
||
}
|
||
|
||
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.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
||
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||
renderApp();
|
||
} else {
|
||
renderApp();
|
||
}
|
||
|
||
window.addEventListener('popstate', renderApp);
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState !== 'visible') return;
|
||
void checkConnectionHealth();
|
||
});
|
||
}
|
||
|
||
init();
|