diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index b882a32d..1ceb4566 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1274,7 +1274,8 @@ function renderApp() { return; } - if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) { + const addingProfile = state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles'; + if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId) && !addingProfile) { navigate('messages-list'); return; } diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index 1ba503c9..4b2434c3 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js'; import { authService, authorizeSession, + consumeAuthReturnPage, + isAddingProfileLogin, clearAuthMessages, clearBrowserClientData, refreshSessions, @@ -205,10 +207,13 @@ export function render({ navigate }) { try { await authService.reconnect(state.entrySettings.shineServer); const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys); - await terminateCurrentSession({ closeServerSession: true }); + const addingProfile = isAddingProfileLogin(); + if (!addingProfile) { + await terminateCurrentSession({ closeServerSession: true }); + clearBrowserClientData(); + await clearClientAuthData().catch(() => {}); + } await clearStoredMessages(session.login).catch(() => {}); - clearBrowserClientData(); - await clearClientAuthData().catch(() => {}); await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys); await authService.persistSessionMaterial(session.login, session.sessionMaterial); const resumed = await authService.resumeSession(session.login, session.sessionId); @@ -221,7 +226,7 @@ export function render({ navigate }) { state.loginDraft.password = ''; await refreshSessions(); setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`); - navigate('profile-view'); + navigate(consumeAuthReturnPage('profile-view')); } catch (error) { const message = toUserMessage(error, 'Не удалось войти по QR-коду.'); setAuthError(message); diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 6886f4c7..82e2b4d2 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -1,6 +1,8 @@ import { authService, authorizeSession, + consumeAuthReturnPage, + isAddingProfileLogin, clearAuthMessages, clearBrowserClientData, refreshSessions, @@ -182,10 +184,13 @@ export function render({ navigate }) { const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); - await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); + const addingProfile = isAddingProfileLogin(); + if (!addingProfile) { + await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); + clearBrowserClientData(); + await clearClientAuthData().catch(() => {}); + } await clearStoredMessages(session.login).catch(() => {}); - clearBrowserClientData(); - await clearClientAuthData().catch(() => {}); await saveEncryptedUserSecrets(session.login, session.storagePwd, keys); await authService.persistSessionMaterial(session.login, session.sessionMaterial); const resumed = await authService.resumeSession(session.login, session.sessionId); @@ -199,7 +204,7 @@ export function render({ navigate }) { await refreshSessions(); setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`); showToast(`Устройство подключено для @${resumed.login || session.login}`); - navigate('profile-view'); + navigate(consumeAuthReturnPage('profile-view')); }; const finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => { @@ -215,10 +220,13 @@ export function render({ navigate }) { sessionType: Number(payloadSession?.sessionType || 50) || 50, }; - await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); + const addingProfile = isAddingProfileLogin(); + if (!addingProfile) { + await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); + clearBrowserClientData(); + await clearClientAuthData().catch(() => {}); + } await clearStoredMessages(login).catch(() => {}); - clearBrowserClientData(); - await clearClientAuthData().catch(() => {}); await authService.persistSessionMaterial(login, sessionMaterial); const resumed = await authService.resumeSession(login, sessionId); authorizeSession({ @@ -231,7 +239,7 @@ export function render({ navigate }) { await refreshSessions(); setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`); showToast(`Wallet-session подключена для @${resumed.login || login}`); - navigate('profile-view'); + navigate(consumeAuthReturnPage('profile-view')); }; const schedulePoll = () => { diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js index 8aac6f37..121018b5 100644 --- a/shine-UI/js/pages/login-view.js +++ b/shine-UI/js/pages/login-view.js @@ -1,6 +1,7 @@ import { renderHeader } from '../components/header.js'; import { authService, + cancelAddProfileLogin, clearAuthMessages, setAuthBusy, setAuthError, @@ -155,7 +156,17 @@ export function render({ navigate }) { screen.append( renderHeader({ title: '', - leftAction: { label: '←', onClick: () => navigate('start-view') }, + leftAction: { + label: '←', + onClick: async () => { + if (state.authReturnHash === '/profiles') { + await cancelAddProfileLogin(); + navigate('profiles-view'); + return; + } + navigate('start-view'); + }, + }, }), panel, ); diff --git a/shine-UI/js/pages/profiles-view.js b/shine-UI/js/pages/profiles-view.js index 5a4c0fc2..9e25775f 100644 --- a/shine-UI/js/pages/profiles-view.js +++ b/shine-UI/js/pages/profiles-view.js @@ -41,9 +41,19 @@ export function render({ navigate }) { addButton.type = 'button'; addButton.className = 'secondary-btn'; addButton.textContent = 'Добавить профиль'; - addButton.addEventListener('click', () => { - prepareAddProfileLogin(); - navigate('login-view'); + addButton.addEventListener('click', async () => { + addButton.disabled = true; + status.hidden = false; + status.className = 'status-line'; + status.textContent = 'Подготавливаем вход в новый профиль…'; + try { + await prepareAddProfileLogin(); + navigate('login-view'); + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось начать добавление профиля: ${error?.message || 'unknown'}`; + addButton.disabled = false; + } }); const closeAllButton = document.createElement('button'); diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index dbf2d1e7..75e969f4 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -1,6 +1,8 @@ import { renderHeader } from '../components/header.js'; import { authService, + cancelAddProfileLogin, + consumeAuthReturnPage, authorizeSession, refreshSessions, resetRegistrationFlow, @@ -103,7 +105,12 @@ export function render({ navigate }) { cancelButton.className = 'ghost-btn'; cancelButton.type = 'button'; cancelButton.textContent = 'Отмена'; - cancelButton.addEventListener('click', () => { + cancelButton.addEventListener('click', async () => { + if (state.authReturnHash === '/profiles') { + await cancelAddProfileLogin(); + navigate('profiles-view'); + return; + } resetRegistrationFlow(); navigate('start-view'); }); @@ -174,13 +181,7 @@ export function render({ navigate }) { setAuthInfo(isLoginFlow ? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».` : `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`); - const nextHash = String(state.authReturnHash || '').trim(); - state.authReturnHash = ''; - if (nextHash.startsWith('/')) { - navigate(nextHash.slice(1)); - } else { - navigate('profile-view'); - } + navigate(consumeAuthReturnPage('profile-view')); } catch (error) { const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.'); setAuthError(message); @@ -196,7 +197,12 @@ export function render({ navigate }) { title: 'Сохранение ключей', leftAction: { label: '←', - onClick: () => { + onClick: async () => { + if (state.authReturnHash === '/profiles') { + await cancelAddProfileLogin(); + navigate('profiles-view'); + return; + } resetRegistrationFlow(); navigate('start-view'); }, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 7a5ab7be..cb0e73da 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -999,6 +999,8 @@ export class AuthService { constructor(serverUrl) { this.serverUrl = normalizeServerUrl(serverUrl); this.ws = new WsJsonClient(this.serverUrl); + this.eventListeners = new Map(); + this.wsEventUnsubscribers = new Map(); this.headerHashCache = new Map(); this.writeLocks = new Map(); this.passwordKeyBundleCache = new Map(); @@ -1008,14 +1010,39 @@ export class AuthService { this.remoteAddBlockSessionId = ''; } - async reconnect(serverUrl) { + bindRegisteredEventsToCurrentWs() { + this.wsEventUnsubscribers.forEach((unsubscribe) => { + try { unsubscribe?.(); } catch {} + }); + this.wsEventUnsubscribers.clear(); + + this.eventListeners.forEach((_handlers, op) => { + const unsubscribe = this.ws.onEvent(op, (data) => { + const handlers = this.eventListeners.get(op); + if (!handlers) return; + handlers.forEach((handler) => { + try { handler(data); } catch {} + }); + }); + this.wsEventUnsubscribers.set(op, unsubscribe); + }); + } + + resetConnection(serverUrl = this.serverUrl, { clearSessionContext = true } = {}) { const normalized = normalizeServerUrl(serverUrl); - if (normalized === this.serverUrl) return; - this.ws.close(); + try { this.ws?.close(); } catch {} this.serverUrl = normalized; this.ws = new WsJsonClient(this.serverUrl); this.headerHashCache = new Map(); this.writeLocks.clear(); + this.bindRegisteredEventsToCurrentWs(); + if (clearSessionContext) this.clearActiveSessionContext(); + } + + async reconnect(serverUrl) { + const normalized = normalizeServerUrl(serverUrl); + if (normalized === this.serverUrl) return; + this.resetConnection(normalized, { clearSessionContext: false }); } setActiveSessionContext({ login = '', sessionId = '' } = {}) { @@ -2514,7 +2541,28 @@ export class AuthService { onEvent(op, handler) { - return this.ws.onEvent(op, handler); + if (!op || typeof handler !== 'function') return () => {}; + if (!this.eventListeners.has(op)) { + this.eventListeners.set(op, new Set()); + const unsubscribe = this.ws.onEvent(op, (data) => { + const handlers = this.eventListeners.get(op); + if (!handlers) return; + handlers.forEach((callback) => { + try { callback(data); } catch {} + }); + }); + this.wsEventUnsubscribers.set(op, unsubscribe); + } + const handlers = this.eventListeners.get(op); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (handlers.size) return; + this.eventListeners.delete(op); + const unsubscribe = this.wsEventUnsubscribers.get(op); + try { unsubscribe?.(); } catch {} + this.wsEventUnsubscribers.delete(op); + }; } async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) { diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index dbabd2af..2677aeb3 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1206,14 +1206,23 @@ export async function switchToSavedProfile(login) { if (!target) throw new Error('Профиль не найден на этом устройстве'); if (targetLogin === normalizeProfileLogin(state.session.login)) return target; + const origin = { + login: String(state.session.login || '').trim(), + sessionId: String(state.session.sessionId || '').trim(), + isLocalDemo: Boolean(state.session.isLocalDemo), + server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(), + }; const targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); - const probe = new AuthService(targetServer); + + // В каждый момент времени держим только один WebSocket: сначала полностью + // закрываем transport активного профиля, затем создаём новый для target. + authService.resetConnection(targetServer, { clearSessionContext: true }); try { - await probe.reconnect(targetServer); - const resumed = await probe.resumeSession(target.login, target.sessionId); + const resumed = await authService.resumeSession(target.login, target.sessionId); target.login = resumed.login || target.login; target.sessionId = resumed.sessionId || target.sessionId; target.updatedAtMs = Date.now(); + authService.setActiveSessionContext({ login: target.login, sessionId: target.sessionId }); persistProfileStoreRaw(loadProfileStoreRaw().map((item) => ( normalizeProfileLogin(item.login) === targetLogin ? target : item ))); @@ -1223,8 +1232,23 @@ export async function switchToSavedProfile(login) { persistEntrySettings({ ...state.entrySettings, ...target.entrySettings }); } return target; - } finally { - probe.close(); + } catch (switchError) { + // Если новый профиль не поднялся — создаём новый socket обратно для старого. + try { + authService.resetConnection(origin.server, { clearSessionContext: true }); + if (origin.login && origin.sessionId && !origin.isLocalDemo) { + const restored = await authService.resumeSession(origin.login, origin.sessionId); + authService.setActiveSessionContext({ + login: restored?.login || origin.login, + sessionId: restored?.sessionId || origin.sessionId, + }); + } else if (origin.login) { + authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId }); + } + } catch (restoreError) { + console.warn('[profiles] failed to restore previous profile connection after switch failure', restoreError); + } + throw switchError; } } @@ -1233,20 +1257,40 @@ async function closeSavedProfileSessionBestEffort(profile) { const cleanSessionId = String(profile.sessionId || '').trim(); if (!cleanSessionId) return; const normalized = normalizeProfileLogin(profile.login); - if (normalized === normalizeProfileLogin(state.session.login) && state.session.isAuthorized) { + const activeNormalized = normalizeProfileLogin(state.session.login); + if (normalized === activeNormalized && state.session.isAuthorized) { try { await authService.closeSession(cleanSessionId); } catch {} return; } + + const origin = { + login: String(state.session.login || '').trim(), + sessionId: String(state.session.sessionId || '').trim(), + isLocalDemo: Boolean(state.session.isLocalDemo), + server: String(state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(), + }; const targetServer = String(profile?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); - const probe = new AuthService(targetServer); try { - await probe.reconnect(targetServer); - await probe.resumeSession(profile.login, cleanSessionId); - await probe.closeSession(cleanSessionId); + authService.resetConnection(targetServer, { clearSessionContext: true }); + await authService.resumeSession(profile.login, cleanSessionId); + await authService.closeSession(cleanSessionId); } catch { // Закрытие профиля на устройстве не блокируем из-за недоступного сервера. } finally { - probe.close(); + try { + authService.resetConnection(origin.server, { clearSessionContext: true }); + if (origin.login && origin.sessionId && !origin.isLocalDemo) { + const restored = await authService.resumeSession(origin.login, origin.sessionId); + authService.setActiveSessionContext({ + login: restored?.login || origin.login, + sessionId: restored?.sessionId || origin.sessionId, + }); + } else if (origin.login) { + authService.setActiveSessionContext({ login: origin.login, sessionId: origin.sessionId }); + } + } catch (error) { + console.warn('[profiles] failed to restore active profile after closing another profile', error); + } } } @@ -1281,8 +1325,16 @@ export async function closeSavedProfile(login) { export async function closeAllSavedProfiles() { const items = loadProfileStoreRaw(); for (const item of items) { - await closeSavedProfileSessionBestEffort(item); await clearStoredMessages(item.login).catch(() => {}); + if (item.isLocalDemo || !String(item.sessionId || '').trim()) continue; + const targetServer = String(item?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim(); + try { + authService.resetConnection(targetServer, { clearSessionContext: true }); + await authService.resumeSession(item.login, item.sessionId); + await authService.closeSession(item.sessionId); + } catch { + // Все локальные профили всё равно закрываем, даже если один сервер недоступен. + } } persistProfileStoreRaw([]); setActiveProfileLoginRaw(''); @@ -1291,10 +1343,53 @@ export async function closeAllSavedProfiles() { authService.clearActiveSessionContext(); } -export function prepareAddProfileLogin() { +export function isAddingProfileLogin() { + return state.session.isAuthorized && String(state.authReturnHash || '').trim() === '/profiles'; +} + +export async function prepareAddProfileLogin() { state.loginDraft.login = ''; state.loginDraft.password = ''; + clearAuthMessages(); + // While an existing profile stays authorized, PRE_AUTH login pages are normally + // blocked by app.js. This return target also acts as an explicit add-profile mode. + state.authReturnHash = '/profiles'; + + // Не пытаемся авторизовать второй login через уже authenticated socket. + // Старую серверную сессию НЕ закрываем: закрываем только локальный transport. + authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); +} + +export async function cancelAddProfileLogin() { + const wasAddingProfile = isAddingProfileLogin(); + const shouldRestoreActiveConnection = wasAddingProfile + && state.session.isAuthorized + && Boolean(String(state.session.login || '').trim()) + && Boolean(String(state.session.sessionId || '').trim()); + state.authReturnHash = ''; + state.loginDraft.login = ''; + state.loginDraft.password = ''; + resetRegistrationFlow(); + clearAuthMessages(); + + if (shouldRestoreActiveConnection) { + try { + authService.resetConnection(state.entrySettings.shineServer, { clearSessionContext: true }); + await authService.resumeSession(state.session.login, state.session.sessionId); + authService.setActiveSessionContext({ login: state.session.login, sessionId: state.session.sessionId }); + } catch (error) { + console.warn('[profiles] failed to restore active profile connection after cancelling add-profile flow', error); + } + } + return wasAddingProfile; +} + +export function consumeAuthReturnPage(fallback = 'profile-view') { + const nextHash = String(state.authReturnHash || '').trim(); + state.authReturnHash = ''; + if (nextHash.startsWith('/')) return nextHash.slice(1) || fallback; + return fallback; } export function refreshRegistrationBalance() {