diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md
index 1b657b29..e58e29d5 100644
--- a/docs/Personal_Messages/Протокол_DM_v1.md
+++ b/docs/Personal_Messages/Протокол_DM_v1.md
@@ -204,6 +204,13 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
+## Мультипрофильный клиент (UI, 2026-09-03)
+- На одном устройстве клиент может хранить несколько авторизованных профилей, но одновременно использует только один активный runtime/WebSocket для DM.
+- При переключении профиля новая сохранённая сессия сначала проверяется отдельным временным соединением. Текущий профиль не заменяется, если проверка неуспешна.
+- Web Push может быть зарегистрирован для нескольких профилей на одном браузерном push endpoint. Поле `toLogin` определяет, какому профилю относится событие.
+- При клике по push-сообщению другого сохранённого профиля UI сначала спрашивает подтверждение переключения. Сам клик по системному уведомлению не является `read-receipt` и не помечает DM прочитанным.
+- Локальный IndexedDB-кэш DM логически разделён по `ownerLogin`, чтобы сообщения разных сохранённых профилей не смешивались.
+
## UI: видимость пустого диалога после DeleteConversation
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md
index 778ceb1f..a5842884 100644
--- a/docs/Personal_Messages/Формат_DM_v1.md
+++ b/docs/Personal_Messages/Формат_DM_v1.md
@@ -357,6 +357,9 @@ ReadReceiptBody_v1_0
## Примечание UI списка чатов (2026-08-28)
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
+## Примечание о мультипрофиле (2026-09-03)
+Мультипрофильность клиента не меняет байтовый формат DM v1 и не добавляет полей в подписанный DM-блок. Разделение профилей выполняется только на уровне клиентской сессии, push-маршрутизации по уже существующему `toLogin` и локального кэша сообщений (`ownerLogin`).
+
## UI-семантика `type=7/8` в списке диалогов
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js
index a07c9467..855678ee 100644
--- a/shine-UI/firebase-messaging-sw.js
+++ b/shine-UI/firebase-messaging-sw.js
@@ -202,19 +202,13 @@ self.addEventListener('notificationclick', (event) => {
}
const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
- const existing = allClients.find((client) => {
- try {
- return client.url.includes('/index.html') || client.url.endsWith('/');
- } catch {
- return false;
- }
- });
+ const existing = allClients[0] || null;
const openUrlBase = './index.html';
const encodedPayload = encodeCallPushPayloadForUrl(payload);
const openUrl = (action === 'accept' || action === 'decline')
? `${openUrlBase}?callPushAction=${encodeURIComponent(action)}&callPushPayload=${encodedPayload}`
- : openUrlBase;
+ : `${openUrlBase}?pushOpenPayload=${encodedPayload}`;
if (existing) {
try {
@@ -224,6 +218,11 @@ self.addEventListener('notificationclick', (event) => {
action,
payload,
});
+ } else {
+ existing.postMessage({
+ type: 'SHINE_NOTIFICATION_CLICK',
+ payload,
+ });
}
} catch {}
await existing.focus();
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 428a1db5..b882a32d 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -33,6 +33,9 @@ import {
addAppLogEntry,
authorizeSession,
hydrateMessagesFromStore,
+ getSavedProfiles,
+ closeSavedProfile,
+ switchToSavedProfile,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
@@ -67,6 +70,7 @@ import * as publicSupportQueueView from './pages/public-support-queue-view.js';
import * as profileView from './pages/profile-view.js?v=202607150910';
import * as profileEditView from './pages/profile-edit-view.js';
+import * as profilesView from './pages/profiles-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
@@ -132,6 +136,7 @@ const routes = {
queue: publicSupportQueueView,
'profile-view': profileView,
'profile-edit-view': profileEditView,
+ 'profiles-view': profilesView,
'wallet-view': walletView,
'settings-view': settingsView,
'access-servers-view': accessServersView,
@@ -213,6 +218,7 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
'settings-view',
+ 'profiles-view',
]);
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
@@ -745,6 +751,77 @@ function consumeCallPushActionFromUrlIfAny() {
}
}
+
+function pushTargetLogin(payload = {}) {
+ return String(payload?.toLogin || '').trim();
+}
+
+function pushTargetPath(payload = {}) {
+ const kind = String(payload?.kind || '').trim();
+ const fromLogin = String(payload?.fromLogin || '').trim();
+ if (kind === 'new_message' && fromLogin) return `/chat/${encodeURIComponent(fromLogin)}`;
+ return '/profile';
+}
+
+function savedProfileExists(login) {
+ const normalized = String(login || '').trim().toLowerCase();
+ if (!normalized) return false;
+ return getSavedProfiles().some((item) => String(item.login || '').trim().toLowerCase() === normalized);
+}
+
+async function ensurePushTargetProfile(payload = {}, { action = '' } = {}) {
+ const targetLogin = pushTargetLogin(payload);
+ const currentLogin = String(state.session.login || '').trim();
+ if (!targetLogin || targetLogin.toLowerCase() === currentLogin.toLowerCase()) return true;
+ if (!savedProfileExists(targetLogin)) {
+ showToast(`Уведомление пришло профилю ${targetLogin}, который не сохранён на этом устройстве.`);
+ return false;
+ }
+
+ const kind = String(payload?.kind || '').trim();
+ const question = kind === 'incoming_call'
+ ? `Входящий звонок для профиля «${targetLogin}». Переключиться на этот профиль?`
+ : `Это сообщение пришло профилю «${targetLogin}». Переключиться, чтобы открыть его?`;
+ if (!window.confirm(question)) return false;
+
+ try {
+ await switchToSavedProfile(targetLogin);
+ if (action === 'accept' || action === 'decline') {
+ savePendingCallPushAction(action, payload);
+ window.location.assign(pushTargetPath(payload));
+ } else {
+ window.location.assign(pushTargetPath(payload));
+ }
+ return false;
+ } catch (error) {
+ showToast(`Не удалось переключить профиль: ${error?.message || 'unknown'}`);
+ return false;
+ }
+}
+
+async function handleNotificationClick(payload = {}) {
+ const canOpen = await ensurePushTargetProfile(payload);
+ if (!canOpen) return;
+ const path = pushTargetPath(payload);
+ navigate(path.replace(/^\//, ''));
+}
+
+function consumeNotificationOpenFromUrlIfAny() {
+ try {
+ const params = new URLSearchParams(window.location.search || '');
+ const rawPayload = String(params.get('pushOpenPayload') || '');
+ if (!rawPayload) return null;
+ let payload = {};
+ try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch {}
+ params.delete('pushOpenPayload');
+ const nextQuery = params.toString();
+ window.history.replaceState({}, '', `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`);
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
async function processPendingCallPushActionIfPossible() {
if (!state.session.isAuthorized) return;
const pending = loadPendingCallPushAction();
@@ -1286,6 +1363,11 @@ async function tryAutoLogin() {
} catch {}
} catch (error) {
if (isSessionInvalidError(error)) {
+ const result = await closeSavedProfile(state.session.login);
+ if (result?.nextProfile) {
+ window.location.assign('/profile');
+ return;
+ }
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
@@ -1338,6 +1420,7 @@ async function ensureSessionRuntimeStarted() {
async function init() {
consumeCallPushActionFromUrlIfAny();
+ const initialNotificationOpenPayload = consumeNotificationOpenFromUrlIfAny();
void tryLockPortraitOrientation();
if (state.session.isLocalDemo) {
@@ -1373,12 +1456,20 @@ async function init() {
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();
+ void (async () => {
+ const canHandle = await ensurePushTargetProfile(payload, { action });
+ if (!canHandle) return;
+ if (!isCallPushTargetForCurrentSession(payload)) return;
+ savePendingCallPushAction(action, payload);
+ await processPendingCallPushActionIfPossible();
+ })();
}
return;
}
+ if (data.type === 'SHINE_NOTIFICATION_CLICK') {
+ void handleNotificationClick(data.payload || {});
+ return;
+ }
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
@@ -1411,7 +1502,8 @@ async function init() {
}
authService.onEvent('SessionRevoked', async () => {
- await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
+ const result = await closeSavedProfile(state.session.login);
+ window.location.assign(result?.nextProfile ? '/profile' : '/start');
});
authService.onEvent('ForceUiReload', async (evt) => {
@@ -1714,6 +1806,9 @@ async function init() {
void (async () => {
try {
await tryAutoLogin();
+ if (initialNotificationOpenPayload) {
+ await handleNotificationClick(initialNotificationOpenPayload);
+ }
await hydrateMessagesFromStore();
if (!state.session.isLocalDemo) {
startConnectionMonitor();
diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js
index 53ecb9f0..1ba503c9 100644
--- a/shine-UI/js/pages/login-camera-view.js
+++ b/shine-UI/js/pages/login-camera-view.js
@@ -206,7 +206,7 @@ export function render({ navigate }) {
await authService.reconnect(state.entrySettings.shineServer);
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
await terminateCurrentSession({ closeServerSession: true });
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(session.login).catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js
index 2eb2a313..6886f4c7 100644
--- a/shine-UI/js/pages/login-other-device-view.js
+++ b/shine-UI/js/pages/login-other-device-view.js
@@ -183,7 +183,7 @@ export function render({ navigate }) {
const finalizeAuthorizedLogin = async (keys, login) => {
const session = await authService.createSessionFromImportedSecrets(login, keys);
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(session.login).catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
@@ -216,7 +216,7 @@ export function render({ navigate }) {
};
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(login).catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await authService.persistSessionMaterial(login, sessionMaterial);
diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js
index 5aea0834..6302bc6e 100644
--- a/shine-UI/js/pages/profile-view.js
+++ b/shine-UI/js/pages/profile-view.js
@@ -91,6 +91,7 @@ export function render({ navigate, chrome }) {
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
+ { label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
});
chrome?.setTopbar(topbar);
diff --git a/shine-UI/js/pages/profiles-view.js b/shine-UI/js/pages/profiles-view.js
new file mode 100644
index 00000000..5a4c0fc2
--- /dev/null
+++ b/shine-UI/js/pages/profiles-view.js
@@ -0,0 +1,146 @@
+import { renderHeader } from '../components/header.js';
+import {
+ closeAllSavedProfiles,
+ closeSavedProfile,
+ getSavedProfiles,
+ prepareAddProfileLogin,
+ state,
+ switchToSavedProfile,
+} from '../state.js';
+
+export const pageMeta = { id: 'profiles-view', title: 'Профили' };
+
+function reloadTo(path) {
+ const clean = String(path || '/profile').trim() || '/profile';
+ window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
+}
+
+export function render({ navigate }) {
+ const screen = document.createElement('section');
+ screen.className = 'stack profiles-screen';
+
+ screen.append(renderHeader({
+ title: 'Профили',
+ leftAction: { label: '←', onClick: () => navigate('profile-view') },
+ }));
+
+ const intro = document.createElement('div');
+ intro.className = 'meta-muted profiles-summary';
+
+ const list = document.createElement('div');
+ list.className = 'stack profiles-list';
+
+ const status = document.createElement('div');
+ status.className = 'status-line';
+ status.hidden = true;
+
+ const actions = document.createElement('div');
+ actions.className = 'stack profiles-actions';
+
+ const addButton = document.createElement('button');
+ addButton.type = 'button';
+ addButton.className = 'secondary-btn';
+ addButton.textContent = 'Добавить профиль';
+ addButton.addEventListener('click', () => {
+ prepareAddProfileLogin();
+ navigate('login-view');
+ });
+
+ const closeAllButton = document.createElement('button');
+ closeAllButton.type = 'button';
+ closeAllButton.className = 'secondary-btn profiles-close-all';
+ closeAllButton.textContent = 'Закрыть все профили';
+ closeAllButton.addEventListener('click', async () => {
+ const profiles = getSavedProfiles();
+ if (!profiles.length) return;
+ const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
+ if (!confirmed) return;
+ closeAllButton.disabled = true;
+ status.hidden = false;
+ status.textContent = 'Закрываем профили…';
+ try {
+ await closeAllSavedProfiles();
+ reloadTo('/start');
+ } catch (error) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = `Не удалось закрыть профили: ${error?.message || 'unknown'}`;
+ closeAllButton.disabled = false;
+ }
+ });
+
+ actions.append(addButton, closeAllButton);
+ screen.append(intro, list, status, actions);
+
+ const renderList = () => {
+ const profiles = getSavedProfiles();
+ const active = profiles.find((item) => item.isActive);
+ intro.textContent = profiles.length
+ ? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
+ : 'На устройстве нет сохранённых профилей.';
+ closeAllButton.disabled = profiles.length === 0;
+ list.innerHTML = '';
+
+ profiles.forEach((profile) => {
+ const row = document.createElement('div');
+ row.className = `card profiles-row${profile.isActive ? ' is-active' : ''}`;
+
+ const select = document.createElement('button');
+ select.type = 'button';
+ select.className = 'profiles-select';
+ select.innerHTML = `${profile.login}${profile.isActive ? 'Активный' : ''}`;
+ select.disabled = profile.isActive;
+ select.addEventListener('click', async () => {
+ if (profile.isActive) return;
+ const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
+ if (!confirmed) return;
+ status.hidden = false;
+ status.className = 'status-line';
+ status.textContent = `Подключаем профиль ${profile.login}…`;
+ try {
+ await switchToSavedProfile(profile.login);
+ reloadTo('/profile');
+ } catch (error) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = `Не удалось переключить профиль: ${error?.message || 'unknown'}`;
+ }
+ });
+
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'profiles-close';
+ close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
+ close.textContent = '×';
+ close.addEventListener('click', async () => {
+ const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
+ const message = profile.isActive
+ ? (others.length
+ ? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
+ : `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
+ : `Закрыть профиль «${profile.login}» на этом устройстве?`;
+ if (!window.confirm(message)) return;
+
+ status.hidden = false;
+ status.className = 'status-line';
+ status.textContent = `Закрываем профиль ${profile.login}…`;
+ try {
+ const result = await closeSavedProfile(profile.login);
+ if (profile.isActive) {
+ reloadTo(result.nextProfile ? '/profile' : '/start');
+ return;
+ }
+ status.hidden = true;
+ renderList();
+ } catch (error) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = `Не удалось закрыть профиль: ${error?.message || 'unknown'}`;
+ }
+ });
+
+ row.append(select, close);
+ list.append(row);
+ });
+ };
+
+ renderList();
+ return screen;
+}
diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js
index 70c21511..dbf2d1e7 100644
--- a/shine-UI/js/pages/registration-keys-view.js
+++ b/shine-UI/js/pages/registration-keys-view.js
@@ -143,7 +143,7 @@ export function render({ navigate }) {
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
}
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(state.registrationDraft.login).catch(() => {});
authorizeSession({
login: state.registrationDraft.login,
diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js
index 29087031..d6b1e172 100644
--- a/shine-UI/js/pages/registration-payment-view.js
+++ b/shine-UI/js/pages/registration-payment-view.js
@@ -107,7 +107,7 @@ async function completeRegistrationLogin({ navigate, keyBundle }) {
},
);
await authService.persistSessionMaterial(result.login, result.sessionMaterial);
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(state.registrationDraft.login).catch(() => {});
const resumed = await authService.resumeSession(result.login, result.sessionId);
const resumedLogin = resumed.login || result.login;
diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js
index 61c042c8..57604766 100644
--- a/shine-UI/js/pages/settings-view.js
+++ b/shine-UI/js/pages/settings-view.js
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js';
-import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
+import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -77,7 +77,7 @@ export function render({ navigate }) {
const signOutBtn = card.querySelector('#settings-signout');
signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm(
- 'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
+ 'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
);
if (!confirmed) return;
@@ -88,9 +88,8 @@ export function render({ navigate }) {
source: 'session',
message: 'Запрошено завершение текущей сессии',
});
- await closeCurrentSessionAndSignOut({
- infoMessage: 'Сеанс завершён. Выполните вход заново.',
- });
+ const result = await closeSavedProfile(state.session.login);
+ window.location.assign(result?.nextProfile ? '/profile' : '/start');
} finally {
signOutBtn.disabled = false;
}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 90bc794c..d4b87b0d 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -19,6 +19,7 @@ const PRETTY_PATHS = new Map([
['key-storage-view', 'key-storage'],
['profile-view', 'profile'],
['profile-edit-view', 'profile/edit'],
+ ['profiles-view', 'profiles'],
['messages-list', 'messages'],
['contact-search-view', 'contacts'],
['chat-view', 'chat'],
@@ -248,6 +249,10 @@ export function parseRouteFromPath(pathname = '') {
return { pageId: 'profile-view', params: {} };
}
+ if (pageId === 'profiles') {
+ return { pageId: 'profiles-view', params: {} };
+ }
+
if (pageId === 'messages') {
return { pageId: 'messages-list', params: {} };
}
@@ -437,6 +442,7 @@ export function resolveToolbarActive(pageId) {
) return pageId;
if (
pageId === 'profile-edit-view' ||
+ pageId === 'profiles-view' ||
pageId === 'wallet-view' ||
pageId === 'settings-view' ||
pageId === 'access-servers-view' ||
diff --git a/shine-UI/js/services/message-store.js b/shine-UI/js/services/message-store.js
index 2d5c6720..7c8c18b2 100644
--- a/shine-UI/js/services/message-store.js
+++ b/shine-UI/js/services/message-store.js
@@ -1,16 +1,67 @@
const DB_NAME = 'shine-ui-messages-v1';
-const DB_VERSION = 1;
-const STORE_MESSAGES = 'messages';
+const DB_VERSION = 3;
+const STORE_MESSAGES = 'messages_by_profile';
+const LEGACY_STORE_MESSAGES = 'messages';
+const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
+const LEGACY_SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
+
+function normalizeOwnerLogin(value) {
+ return String(value || '').trim().toLowerCase();
+}
+
+function storageKey(ownerLogin, messageKey) {
+ const owner = normalizeOwnerLogin(ownerLogin);
+ const key = String(messageKey || '').trim();
+ return owner && key ? `${owner}|${key}` : '';
+}
+
+function migrationOwnerLogin() {
+ try {
+ const active = normalizeOwnerLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
+ if (active) return active;
+ const legacy = JSON.parse(localStorage.getItem(LEGACY_SESSION_STORAGE_KEY) || '{}');
+ return normalizeOwnerLogin(legacy?.login);
+ } catch {
+ return '';
+ }
+}
+
+function ensureIndexes(store) {
+ if (!store.indexNames.contains('by_chat')) store.createIndex('by_chat', 'chatId', { unique: false });
+ if (!store.indexNames.contains('by_ts')) store.createIndex('by_ts', 'ts', { unique: false });
+ if (!store.indexNames.contains('by_owner')) store.createIndex('by_owner', 'ownerLogin', { unique: false });
+}
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
+ let store;
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
- const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
- store.createIndex('by_chat', 'chatId', { unique: false });
- store.createIndex('by_ts', 'ts', { unique: false });
+ store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'storageKey' });
+ } else {
+ store = request.transaction.objectStore(STORE_MESSAGES);
+ }
+ ensureIndexes(store);
+
+ // Однократная миграция старого single-profile кэша в пространство текущего профиля.
+ if (db.objectStoreNames.contains(LEGACY_STORE_MESSAGES)) {
+ const owner = migrationOwnerLogin();
+ if (owner) {
+ const legacy = request.transaction.objectStore(LEGACY_STORE_MESSAGES);
+ const cursorReq = legacy.openCursor();
+ cursorReq.onsuccess = () => {
+ const cursor = cursorReq.result;
+ if (!cursor) return;
+ const row = cursor.value || {};
+ const messageKey = String(row.messageKey || '').trim();
+ const rowOwner = normalizeOwnerLogin(row.ownerLogin) || owner;
+ const key = storageKey(rowOwner, messageKey);
+ if (key) store.put({ ...row, ownerLogin: rowOwner, storageKey: key });
+ cursor.continue();
+ };
+ }
}
};
request.onsuccess = () => resolve(request.result);
@@ -36,31 +87,55 @@ async function withStore(mode, callback) {
export async function putStoredMessage(record) {
if (!record || !record.messageKey) return;
+ const ownerLogin = normalizeOwnerLogin(record.ownerLogin);
+ const key = storageKey(ownerLogin, record.messageKey);
+ if (!key) return;
await withStore('readwrite', (store) => {
- store.put(record);
+ store.put({ ...record, ownerLogin, storageKey: key });
});
}
-export async function deleteStoredMessage(messageKey) {
- if (!messageKey) return;
+export async function deleteStoredMessage(messageKey, ownerLogin = '') {
+ const key = storageKey(ownerLogin, messageKey);
+ if (!key) return;
await withStore('readwrite', (store) => {
- store.delete(messageKey);
+ store.delete(key);
});
}
-export async function listStoredMessages() {
+export async function listStoredMessages(ownerLogin = '') {
+ const owner = normalizeOwnerLogin(ownerLogin);
+ if (!owner) return [];
return withStore('readonly', (store) => new Promise((resolve, reject) => {
- const req = store.getAll();
+ const req = store.index('by_owner').getAll(owner);
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
- req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
+ req.onerror = () => reject(req.error || new Error('IndexedDB getAll by owner failed'));
}));
}
-export async function clearStoredMessages() {
- await new Promise((resolve, reject) => {
- const request = indexedDB.deleteDatabase(DB_NAME);
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
- request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
- });
+export async function clearStoredMessages(ownerLogin = '') {
+ const owner = normalizeOwnerLogin(ownerLogin);
+ if (!owner) {
+ await new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(DB_NAME);
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error || new Error('IndexedDB delete failed'));
+ request.onblocked = () => reject(new Error('IndexedDB delete blocked'));
+ });
+ return;
+ }
+ await withStore('readwrite', (store) => new Promise((resolve, reject) => {
+ const index = store.index('by_owner');
+ const req = index.openKeyCursor(IDBKeyRange.only(owner));
+ req.onsuccess = () => {
+ const cursor = req.result;
+ if (!cursor) {
+ resolve();
+ return;
+ }
+ store.delete(cursor.primaryKey);
+ cursor.continue();
+ };
+ req.onerror = () => reject(req.error || new Error('IndexedDB clear by owner failed'));
+ }));
}
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index 661c7c0e..dbabd2af 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -11,6 +11,8 @@ import { emptyPasswordWords } from './services/password-words.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
+const PROFILES_STORAGE_KEY = 'shine-ui-profiles-v1';
+const ACTIVE_PROFILE_STORAGE_KEY = 'shine-ui-active-profile-v1';
const REACTIONS_STORAGE_KEY = 'shine-ui-message-reactions-v2';
const WEB_PUSH_SUBSCRIPTION_KEY = 'shine-ui-webpush-subscription-v1';
const ENTRY_SETTINGS_STORAGE_KEY = 'shine-ui-entry-settings-v1';
@@ -122,7 +124,121 @@ function normalizeToolsSettings(rawTools) {
};
}
+
+function normalizeProfileLogin(value) {
+ return String(value || '').trim().toLowerCase();
+}
+
+function loadProfileStoreRaw() {
+ try {
+ const raw = localStorage.getItem(PROFILES_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((item) => item && normalizeProfileLogin(item.login) && String(item.sessionId || '').trim());
+ } catch {
+ return [];
+ }
+}
+
+function persistProfileStoreRaw(items) {
+ try {
+ localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(Array.isArray(items) ? items : []));
+ } catch {
+ // ignore storage errors
+ }
+}
+
+function getActiveProfileLoginRaw() {
+ try {
+ return normalizeProfileLogin(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY));
+ } catch {
+ return '';
+ }
+}
+
+function setActiveProfileLoginRaw(login) {
+ const normalized = normalizeProfileLogin(login);
+ try {
+ if (normalized) localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, normalized);
+ else localStorage.removeItem(ACTIVE_PROFILE_STORAGE_KEY);
+ } catch {
+ // ignore storage errors
+ }
+}
+
+function profileEntrySettingsSnapshot(settings = {}) {
+ return {
+ solanaServer: String(settings.solanaServer || ''),
+ shineServer: String(settings.shineServer || ''),
+ shineServerLogin: String(settings.shineServerLogin || ''),
+ shineServerHttp: String(settings.shineServerHttp || ''),
+ arweaveServer: String(settings.arweaveServer || ''),
+ callPreflightTimeoutMs: Number(settings.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS),
+ remoteAddBlockSessionId: String(settings.remoteAddBlockSessionId || ''),
+ };
+}
+
+function upsertSavedProfileInternal({ login, sessionId, isLocalDemo = false, entrySettings = null } = {}) {
+ const normalized = normalizeProfileLogin(login);
+ const cleanSessionId = String(sessionId || '').trim();
+ if (!normalized || !cleanSessionId) return;
+ const items = loadProfileStoreRaw();
+ const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
+ const previous = index >= 0 ? items[index] : {};
+ const next = {
+ ...previous,
+ login: String(login || '').trim(),
+ sessionId: cleanSessionId,
+ isLocalDemo: Boolean(isLocalDemo),
+ entrySettings: entrySettings ? profileEntrySettingsSnapshot(entrySettings) : (previous.entrySettings || {}),
+ updatedAtMs: Date.now(),
+ };
+ if (index >= 0) items[index] = next;
+ else items.push(next);
+ persistProfileStoreRaw(items);
+ setActiveProfileLoginRaw(normalized);
+}
+
+function migrateLegacySessionToProfileStore() {
+ const existing = loadProfileStoreRaw();
+ if (existing.length) return;
+ try {
+ const raw = localStorage.getItem(SESSION_STORAGE_KEY);
+ if (!raw) return;
+ const legacy = JSON.parse(raw);
+ if (!legacy?.login || !legacy?.sessionId) return;
+ let entrySettings = {};
+ try {
+ entrySettings = JSON.parse(localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY) || '{}') || {};
+ } catch {}
+ upsertSavedProfileInternal({
+ login: legacy.login,
+ sessionId: legacy.sessionId,
+ isLocalDemo: legacy.isLocalDemo,
+ entrySettings,
+ });
+ } catch {
+ // ignore migration errors
+ }
+}
+
function loadStoredSession() {
+ migrateLegacySessionToProfileStore();
+ const profiles = loadProfileStoreRaw();
+ if (profiles.length) {
+ const activeLogin = getActiveProfileLoginRaw();
+ const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
+ if (active) {
+ setActiveProfileLoginRaw(active.login);
+ return {
+ isAuthorized: false,
+ isLocalDemo: Boolean(active.isLocalDemo),
+ login: String(active.login || '').trim(),
+ sessionId: String(active.sessionId || '').trim(),
+ };
+ }
+ }
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
@@ -169,6 +285,15 @@ function clearStoredSession() {
}
function loadStoredEntrySettings() {
+ migrateLegacySessionToProfileStore();
+ const profiles = loadProfileStoreRaw();
+ if (profiles.length) {
+ const activeLogin = getActiveProfileLoginRaw();
+ const active = profiles.find((item) => normalizeProfileLogin(item.login) === activeLogin) || profiles[0];
+ if (active?.entrySettings && typeof active.entrySettings === 'object') {
+ return active.entrySettings;
+ }
+ }
try {
const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY);
if (!raw) return null;
@@ -199,6 +324,19 @@ function persistEntrySettings(settings) {
tools: normalizeToolsSettings(settings?.tools),
};
localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload));
+ const activeLogin = getActiveProfileLoginRaw();
+ if (activeLogin) {
+ const profiles = loadProfileStoreRaw();
+ const index = profiles.findIndex((item) => normalizeProfileLogin(item.login) === activeLogin);
+ if (index >= 0) {
+ profiles[index] = {
+ ...profiles[index],
+ entrySettings: profileEntrySettingsSnapshot(payload),
+ updatedAtMs: Date.now(),
+ };
+ persistProfileStoreRaw(profiles);
+ }
+ }
} catch {
// ignore storage errors
}
@@ -402,6 +540,7 @@ function persistMessageRecord(chatId, row) {
const resolvedTs = resolveChatMessageTimeMs(row);
void putStoredMessage({
messageKey: row.messageKey,
+ ownerLogin: String(state.session.login || '').trim().toLowerCase(),
chatId: normalizedChatId,
from: row.from || 'in',
text: String(row.text || ''),
@@ -422,12 +561,12 @@ function persistMessageRecord(chatId, row) {
function removeStoredMessageRecord(messageKey) {
if (!messageKey) return;
- void deleteStoredMessage(messageKey).catch(() => {});
+ void deleteStoredMessage(messageKey, state.session.login).catch(() => {});
}
export async function hydrateMessagesFromStore() {
try {
- const rows = await listStoredMessages();
+ const rows = await listStoredMessages(state.session.login);
const touchedChats = new Set();
rows
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
@@ -928,6 +1067,7 @@ export function authorizeSession({
login,
sessionId,
});
+ upsertSavedProfileInternal({ login, sessionId, isLocalDemo: localDemo, entrySettings: state.entrySettings });
authService.setActiveSessionContext({ login, sessionId });
state.startHint = '';
if (onSessionAuthorized) {
@@ -1020,13 +1160,14 @@ async function tryCloseCurrentSessionOnServer() {
}
export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false, notifySessionReset = true } = {}) {
+ const signedOutLogin = String(state.session.login || '').trim();
if (closeServerSession) {
await tryCloseCurrentSessionOnServer();
}
clearStoredSession();
resetStateForSignedOut();
- await clearStoredMessages().catch(() => {});
+ await clearStoredMessages(signedOutLogin).catch(() => {});
authService.close();
authService.clearActiveSessionContext();
if (infoMessage) {
@@ -1046,6 +1187,116 @@ export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
await terminateCurrentSession({ infoMessage, closeServerSession: true });
}
+
+export function getSavedProfiles() {
+ migrateLegacySessionToProfileStore();
+ const activeLogin = getActiveProfileLoginRaw();
+ return loadProfileStoreRaw().map((item) => ({
+ login: String(item.login || '').trim(),
+ sessionId: String(item.sessionId || '').trim(),
+ isLocalDemo: Boolean(item.isLocalDemo),
+ isActive: normalizeProfileLogin(item.login) === activeLogin,
+ entrySettings: item.entrySettings || {},
+ }));
+}
+
+export async function switchToSavedProfile(login) {
+ const targetLogin = normalizeProfileLogin(login);
+ const target = loadProfileStoreRaw().find((item) => normalizeProfileLogin(item.login) === targetLogin);
+ if (!target) throw new Error('Профиль не найден на этом устройстве');
+ if (targetLogin === normalizeProfileLogin(state.session.login)) return target;
+
+ const targetServer = String(target?.entrySettings?.shineServer || state.entrySettings.shineServer || DEFAULT_SHINE_SERVER).trim();
+ const probe = new AuthService(targetServer);
+ try {
+ await probe.reconnect(targetServer);
+ const resumed = await probe.resumeSession(target.login, target.sessionId);
+ target.login = resumed.login || target.login;
+ target.sessionId = resumed.sessionId || target.sessionId;
+ target.updatedAtMs = Date.now();
+ persistProfileStoreRaw(loadProfileStoreRaw().map((item) => (
+ normalizeProfileLogin(item.login) === targetLogin ? target : item
+ )));
+ setActiveProfileLoginRaw(target.login);
+ persistSession({ isAuthorized: true, isLocalDemo: Boolean(target.isLocalDemo), login: target.login, sessionId: target.sessionId });
+ if (target.entrySettings && typeof target.entrySettings === 'object') {
+ persistEntrySettings({ ...state.entrySettings, ...target.entrySettings });
+ }
+ return target;
+ } finally {
+ probe.close();
+ }
+}
+
+async function closeSavedProfileSessionBestEffort(profile) {
+ if (!profile || profile.isLocalDemo) return;
+ const cleanSessionId = String(profile.sessionId || '').trim();
+ if (!cleanSessionId) return;
+ const normalized = normalizeProfileLogin(profile.login);
+ if (normalized === normalizeProfileLogin(state.session.login) && state.session.isAuthorized) {
+ try { await authService.closeSession(cleanSessionId); } catch {}
+ return;
+ }
+ 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);
+ } catch {
+ // Закрытие профиля на устройстве не блокируем из-за недоступного сервера.
+ } finally {
+ probe.close();
+ }
+}
+
+export async function closeSavedProfile(login) {
+ const normalized = normalizeProfileLogin(login);
+ const items = loadProfileStoreRaw();
+ const index = items.findIndex((item) => normalizeProfileLogin(item.login) === normalized);
+ if (index < 0) return { closed: false, nextProfile: null };
+ const target = items[index];
+ await closeSavedProfileSessionBestEffort(target);
+ await clearStoredMessages(target.login).catch(() => {});
+
+ const nextItems = items.filter((_, itemIndex) => itemIndex !== index);
+ persistProfileStoreRaw(nextItems);
+ const wasActive = normalized === getActiveProfileLoginRaw();
+ if (!wasActive) return { closed: true, nextProfile: null };
+
+ const next = nextItems[index] || nextItems[index - 1] || nextItems[0] || null;
+ if (!next) {
+ setActiveProfileLoginRaw('');
+ clearStoredSession();
+ return { closed: true, nextProfile: null };
+ }
+ setActiveProfileLoginRaw(next.login);
+ persistSession({ isAuthorized: true, isLocalDemo: Boolean(next.isLocalDemo), login: next.login, sessionId: next.sessionId });
+ if (next.entrySettings && typeof next.entrySettings === 'object') {
+ persistEntrySettings({ ...state.entrySettings, ...next.entrySettings });
+ }
+ return { closed: true, nextProfile: next };
+}
+
+export async function closeAllSavedProfiles() {
+ const items = loadProfileStoreRaw();
+ for (const item of items) {
+ await closeSavedProfileSessionBestEffort(item);
+ await clearStoredMessages(item.login).catch(() => {});
+ }
+ persistProfileStoreRaw([]);
+ setActiveProfileLoginRaw('');
+ clearStoredSession();
+ authService.close();
+ authService.clearActiveSessionContext();
+}
+
+export function prepareAddProfileLogin() {
+ state.loginDraft.login = '';
+ state.loginDraft.password = '';
+ state.authReturnHash = '';
+}
+
export function refreshRegistrationBalance() {
const next = (0.005 + Math.random() * 0.03).toFixed(4);
state.registrationPayment.balanceSOL = next;
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 43fd6e78..a40c1283 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -11676,3 +11676,49 @@ body.chat-topbar-overlay .page-header.app-topbar-shell .header-center {
filter: blur(6px);
transform: translate(-50%, -50%) scale(1);
}
+
+/* Saved profiles */
+.profiles-screen { gap: 14px; }
+.profiles-summary { padding: 0 2px; }
+.profiles-list { gap: 10px; }
+.profiles-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 44px;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 8px 8px 14px;
+}
+.profiles-row.is-active { border-color: rgba(255, 255, 255, 0.34); }
+.profiles-select {
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 0;
+ text-align: left;
+ font: inherit;
+}
+.profiles-select:not(:disabled) { cursor: pointer; }
+.profiles-login {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 650;
+}
+.profiles-close {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.24);
+ background: rgba(255, 255, 255, 0.06);
+ color: inherit;
+ font-size: 27px;
+ line-height: 1;
+ cursor: pointer;
+}
+.profiles-actions { margin-top: 4px; }
+.profiles-close-all { margin-top: 4px; }