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

517 lines
16 KiB
JavaScript

import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaPush } from './services/pwa-push-service.js';
import {
handleIncomingCallInvite,
handleIncomingCallSignal,
setCallDebugReporter,
startDebugConnectionAsInitiator,
startDebugConnectionAsResponder,
} from './services/call-service.js';
import {
authService,
addAppLogEntry,
authorizeSession,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
setSessionResetHandler,
state,
terminateCurrentSession,
addIncomingMessage,
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 walletView from './pages/wallet-view.js';
import * as settingsView from './pages/settings-view.js';
import * as serverSettingsView from './pages/server-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 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 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,
'wallet-view': walletView,
'settings-view': settingsView,
'server-settings-view': serverSettingsView,
'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,
'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,
'network-view': networkView,
'notifications-view': notificationsView,
};
const screenEl = document.getElementById('app-screen');
const toolbarEl = document.getElementById('toolbar-slot');
let currentCleanup = null;
let pingIntervalId = null;
let reconnectIntervalId = null;
let sessionRuntimeStarted = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
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);
}
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 = '';
}
function renderApp() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : '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('profile-view');
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));
}
} 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),
});
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
pingIntervalId = window.setInterval(async () => {
if (!state.session.isAuthorized) return;
try {
await authService.ws.request('Ping', { ts: Date.now() });
} catch {
// silent keep-alive
}
}, 60_000);
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();
addAppLogEntry({
level: 'info',
source: 'ws-reconnect',
message: 'WS переподключен автоматически',
});
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'ws-reconnect',
message: 'Попытка автопереподключения не удалась',
details: { error: error?.message || 'unknown' },
});
}
}, 15_000);
}
async function init() {
addAppLogEntry({
level: 'info',
source: 'app',
message: 'Инициализация UI запущена',
});
setSessionResetHandler(() => {
sessionRuntimeStarted = false;
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
if (reconnectIntervalId) {
window.clearInterval(reconnectIntervalId);
reconnectIntervalId = null;
}
navigate('start-view');
});
setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
});
}
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
authService.onEvent('IncomingDirectMessage', async (evt) => {
const payload = evt?.payload || {};
const fromLogin = payload.fromLogin || 'unknown';
const messageId = payload.messageId || '';
const eventId = payload.eventId || evt?.requestId || '';
let text = payload.text || '';
if (!text && payload.blobB64) {
try {
const bytes = Uint8Array.from(atob(payload.blobB64), (ch) => ch.charCodeAt(0));
const msgLen = (bytes[bytes.length - 66] << 8) | bytes[bytes.length - 65];
const msgStart = bytes.length - 64 - msgLen;
const msgBytes = bytes.slice(msgStart, msgStart + msgLen);
text = new TextDecoder().decode(msgBytes);
} catch {
text = '[binary message]';
}
}
const added = addIncomingMessage(fromLogin, text, messageId);
if (added) {
addAppLogEntry({
level: 'info',
source: 'incoming-dm',
message: `Входящее сообщение от ${fromLogin}`,
details: { messageId, text },
});
}
if (added && Notification.permission === 'granted') {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
if (eventId) {
try {
await authService.ackIncomingMessage(eventId, messageId);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'incoming-dm',
message: 'Не удалось отправить ACK на входящее сообщение',
details: { eventId, messageId, error: error?.message || 'unknown' },
});
}
}
});
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 ensureSessionRuntimeStarted();
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
} else {
renderApp();
}
window.addEventListener('hashchange', renderApp);
}
init();