SHiNE-server/shine-UI/js/app.js

1220 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
normalizeDmChatId,
setContacts,
} from './state.js';
import * as startView from './pages/start-view.js?v=202606142105';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
import * as registerView from './pages/register-view.js?v=202606201650';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
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?v=202606281930';
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?v=202606161240';
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
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 clientSessionView from './pages/device-session-view.js?v=202606131435';
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 solanaUsersInitView from './pages/solana-users-init-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-faq-view': registrationFaqView,
'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView,
'registration-draft-keys-view': registrationDraftKeysView,
'topup-view': topupView,
'devnet-topup-view': devnetTopupView,
'login-view': loginView,
'login-camera-view': loginCameraView,
'login-other-device-view': loginOtherDeviceView,
'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,
'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-pairing-view': clientPairingView,
'trusted-device-login-settings-view': trustedDeviceLoginSettingsView,
'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView,
'device-session-view': clientSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
'solana-users-init-view': solanaUsersInitView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
user: 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;
let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
'entry-settings-view',
'network-view',
'channels-list',
'channel-view',
'channel-thread-view',
'user',
'contact-search-view',
]);
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));
async function unlockHiddenDmAudio() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return false;
if (!hiddenDmAudioContext) {
hiddenDmAudioContext = new Ctx();
}
if (hiddenDmAudioContext.state === 'suspended') {
await hiddenDmAudioContext.resume();
}
hiddenDmAudioUnlocked = hiddenDmAudioContext.state === 'running';
return hiddenDmAudioUnlocked;
} catch {
return false;
}
}
async function playDmSignal({ extended = false } = {}) {
try {
if (!hiddenDmAudioUnlocked || !hiddenDmAudioContext) return false;
if (hiddenDmAudioContext.state === 'suspended') {
await hiddenDmAudioContext.resume();
}
if (hiddenDmAudioContext.state !== 'running') return false;
const now = hiddenDmAudioContext.currentTime;
const gain = hiddenDmAudioContext.createGain();
gain.connect(hiddenDmAudioContext.destination);
gain.gain.setValueAtTime(0.0001, now);
const pulse = (offsetSec, freqHz, durationSec, peakGain) => {
const osc = hiddenDmAudioContext.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freqHz, now + offsetSec);
osc.connect(gain);
gain.gain.exponentialRampToValueAtTime(peakGain, now + offsetSec + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + offsetSec + durationSec);
osc.start(now + offsetSec);
osc.stop(now + offsetSec + durationSec + 0.02);
};
if (extended) {
pulse(0, 880, 0.18, 0.032);
pulse(0.24, 1174, 0.2, 0.026);
pulse(0.52, 1567, 0.24, 0.02);
} else {
pulse(0, 1046, 0.12, 0.028);
pulse(0.17, 1318, 0.14, 0.022);
}
return true;
} catch {
return false;
}
}
async function notifyHiddenIncomingMessage(fromLogin, text) {
const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`;
const title = `Сообщение от ${fromLogin}`;
try {
const registration = await navigator.serviceWorker?.getRegistration?.();
if (registration?.showNotification) {
await registration.showNotification(title, {
body,
tag: `shine-hidden-dm-${String(fromLogin || '').trim().toLowerCase() || 'unknown'}`,
renotify: true,
data: {
kind: 'new_message',
fromLogin,
},
});
} else {
new Notification(title, { body });
}
} catch {
// ignore notification errors
}
try {
if (typeof navigator.vibrate === 'function') {
navigator.vibrate([140, 80, 220, 90, 180]);
}
} catch {
// ignore vibration errors
}
void playDmSignal({ extended: true });
}
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 isCallPushTargetForCurrentSession(payload = {}) {
const targetSessionId = String(payload?.targetSessionId || '').trim();
if (!targetSessionId) return true;
const currentSessionId = String(state?.session?.sessionId || '').trim();
return Boolean(currentSessionId) && currentSessionId === targetSessionId;
}
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;
if (!isCallPushTargetForCurrentSession(pending.payload || {})) 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) && !GUEST_ALLOWED_PAGES.has(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);
}
}
function refreshToolbarOnly() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
refreshConnectionUi();
}
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') {
if (!isCallPushTargetForCurrentSession(payload)) return;
savePendingCallPushAction(action, payload);
void processPendingCallPushActionIfPossible();
}
return;
}
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
if (!isCallPushTargetForCurrentSession(payload)) return;
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 = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
const text = (messageType === 1 || messageType === 2)
? String(parsed.text || '')
: '';
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,
revisionTimeMs: Number(parsed.revisionTimeMs || 0),
deleted: Boolean(parsed.deleted),
});
if (added) {
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: isIncomingForCurrent
? `Обновлено входящее сообщение от ${fromLogin}`
: `Синхронизирована исходящая копия в чате ${chatId}`,
details: { messageKey, baseKey: parsed.baseKey, messageType, revisionTimeMs: parsed.revisionTimeMs || 0, deleted: !!parsed.deleted },
});
}
if (added && isIncomingForCurrent) {
shouldRefreshToolbarUnread = true;
}
if (added && isIncomingForCurrent && !payload.backlog) {
if (document.visibilityState === 'visible') {
void playDmSignal({ extended: false });
} else if (Notification.permission === 'granted') {
try {
void notifyHiddenIncomingMessage(fromLogin, text || '');
} catch {}
} else {
void playDmSignal({ extended: true });
}
}
} 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') {
window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', {
detail: {
chatId,
messageType,
messageKey,
},
}));
if (shouldRefreshToolbarUnread) {
refreshToolbarOnly();
}
} else if (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',
});
}
});
// Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"),
// а сетевые/авторизационные шаги выполняем фоном.
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
}
renderApp();
void (async () => {
try {
await tryAutoLogin();
await hydrateMessagesFromStore();
startConnectionMonitor();
startPeriodicUiVersionCheck();
await ensureSessionRuntimeStarted();
} finally {
renderApp();
}
})();
window.addEventListener('popstate', renderApp);
document.addEventListener('pointerdown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
document.addEventListener('keydown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
void checkConnectionHealth();
});
}
init();