diff --git a/shine-UI/js/pages/entry-settings-view.js b/shine-UI/js/pages/entry-settings-view.js index 1f8e82f..ec1d2ab 100644 --- a/shine-UI/js/pages/entry-settings-view.js +++ b/shine-UI/js/pages/entry-settings-view.js @@ -1,5 +1,6 @@ import { renderHeader } from '../components/header.js'; -import { checkServerAvailability, saveEntrySettings, state } from '../state.js'; +import { saveEntrySettings, state } from '../state.js'; +import { checkServerAvailabilityByKey } from '../services/server-health-service.js'; export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false }; @@ -86,9 +87,17 @@ export function render({ navigate }) { } }; - const runCheck = () => { + const runCheck = async () => { draft[field.key] = input.value.trim(); - applyStatus(checkServerAvailability(input.value)); + checkButton.disabled = true; + checkButton.textContent = 'Проверка...'; + try { + const next = await checkServerAvailabilityByKey(field.key, input.value); + applyStatus(next); + } finally { + checkButton.disabled = false; + checkButton.textContent = 'Проверить'; + } }; applyStatus(draft.statuses[field.key]); @@ -98,13 +107,17 @@ export function render({ navigate }) { draft[field.key] = input.value; applyStatus('idle'); window.clearTimeout(timers.get(field.key)); - timers.set(field.key, window.setTimeout(runCheck, 3000)); + timers.set(field.key, window.setTimeout(() => { + void runCheck(); + }, 3000)); + }); + input.addEventListener('blur', () => { + void runCheck(); }); - input.addEventListener('blur', runCheck); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); - runCheck(); + void runCheck(); } }); diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index d28b7c3..93776be 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -1,12 +1,12 @@ -import { renderHeader } from '../components/header.js'; +import { renderHeader } from '../components/header.js'; import { authService, - refreshRegistrationBalance, setAuthError, setAuthInfo, state, } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; +import { deriveWalletFromPassword, formatSol, getBalanceSol } from '../services/solana-wallet-service.js'; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; @@ -39,7 +39,7 @@ export function render({ navigate }) { const walletValue = document.createElement('input'); walletValue.className = 'input'; walletValue.type = 'text'; - walletValue.value = state.registrationPayment.walletAddress; + walletValue.value = state.registrationPayment.walletAddress || ''; walletValue.addEventListener('input', () => { state.registrationPayment.walletAddress = walletValue.value; }); @@ -71,15 +71,36 @@ export function render({ navigate }) { balanceRow.className = 'row wrap-row'; const balanceValue = document.createElement('strong'); - balanceValue.textContent = `${state.registrationPayment.balanceSOL} SOL`; + balanceValue.textContent = `${formatSol(parseBalanceSol(state.registrationPayment.balanceSOL), 6)} SOL`; const refreshButton = document.createElement('button'); refreshButton.className = 'square-btn'; refreshButton.type = 'button'; refreshButton.textContent = '↻'; refreshButton.title = 'Обновить'; + + const refreshBalance = async () => { + const address = String(walletValue.value || '').trim(); + if (!address) return; + refreshButton.disabled = true; + try { + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address, + }); + state.registrationPayment.balanceSOL = String(balance.sol); + balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`; + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось обновить баланс: ${error?.message || 'unknown'}`; + status.style.display = ''; + } finally { + refreshButton.disabled = false; + } + }; + refreshButton.addEventListener('click', () => { - balanceValue.textContent = `${refreshRegistrationBalance()} SOL`; + void refreshBalance(); }); balanceRow.append(balanceValue, refreshButton); @@ -100,7 +121,7 @@ export function render({ navigate }) { const balanceSol = parseBalanceSol(state.registrationPayment.balanceSOL); if (balanceSol < MIN_REGISTER_BALANCE_SOL) { status.className = 'status-line is-unavailable'; - status.textContent = `Недостаточный баланс для регистрации: ${state.registrationPayment.balanceSOL} SOL. Нужно минимум ${MIN_REGISTER_BALANCE_SOL.toFixed(2)} SOL.`; + status.textContent = `Недостаточный баланс для регистрации: ${formatSol(balanceSol, 6)} SOL. Нужно минимум ${MIN_REGISTER_BALANCE_SOL.toFixed(2)} SOL.`; status.style.display = ''; return; } @@ -141,9 +162,9 @@ export function render({ navigate }) { card.innerHTML = `
Для регистрации в Solana нужно заплатить 0,01 SOL (примерно 1 доллар).
- +Для пополнения счета скопируйте номер кошелька.
+Кнопка «Пополнить» в кошельке будет переводить на отдельный сайт. Пока доступно тестовое пополнение.
${wallet.publicAddress}
-—
`; + card.append(balanceWrap, addressCard); + const actions = document.createElement('div'); actions.className = 'stack'; actions.innerHTML = ` @@ -50,29 +75,126 @@ export function render({ navigate }) { `; - actions.querySelector('#copy-address').addEventListener('click', async () => { + const copyBtn = actions.querySelector('#copy-address'); + const refreshBtn = actions.querySelector('#refresh-balance'); + const sendBtn = actions.querySelector('#send-sol'); + const topupBtn = actions.querySelector('#topup-sol'); + const addressEl = addressCard.querySelector('#wallet-address-value'); + + const setStatus = (text) => { + status.textContent = String(text || ''); + }; + + const refreshBalance = async () => { + if (!walletAddress) { + setStatus('Кошелёк не инициализирован.'); + return; + } + refreshBtn.disabled = true; try { - await navigator.clipboard.writeText(wallet.publicAddress); - updateStatus('Адрес скопирован в буфер обмена'); + const balance = await getBalanceSol({ + endpoint: state.entrySettings.solanaServer, + address: walletAddress, + }); + balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`; + updatedLabel.textContent = `Обновлено: ${nowRu()}`; + endpointLabel.textContent = `RPC: ${balance.endpoint}`; + setStatus('Баланс обновлён.'); + } catch (error) { + setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`); + } finally { + refreshBtn.disabled = false; + } + }; + + copyBtn.addEventListener('click', async () => { + if (!walletAddress) return; + try { + await navigator.clipboard.writeText(walletAddress); + setStatus('Адрес скопирован в буфер обмена'); } catch { - updateStatus('Не удалось скопировать в этом браузере'); + setStatus('Не удалось скопировать адрес в этом браузере'); } }); - actions.querySelector('#refresh-balance').addEventListener('click', () => { - updateStatus(`Баланс обновлен: ${wallet.balanceSOL} SOL`); + refreshBtn.addEventListener('click', () => { + void refreshBalance(); }); - actions.querySelector('#send-sol').addEventListener('click', () => { - updateStatus('Демо-функция: перевод будет добавлен позже'); + sendBtn.addEventListener('click', async () => { + if (!walletCtx?.keypair) { + setStatus('Перевод недоступен: wallet.key не загружен.'); + return; + } + const toAddress = window.prompt('Введите адрес получателя (Solana):', ''); + if (!toAddress) return; + const amountRaw = window.prompt('Введите сумму SOL для перевода:', '0.01'); + if (!amountRaw) return; + + sendBtn.disabled = true; + try { + const tx = await transferSol({ + endpoint: state.entrySettings.solanaServer, + fromKeypair: walletCtx.keypair, + toAddress, + amountSol: Number(String(amountRaw || '').replace(',', '.')), + }); + setStatus(`Перевод отправлен. Signature: ${tx.signature}`); + await refreshBalance(); + } catch (error) { + setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`); + } finally { + sendBtn.disabled = false; + } }); - actions.querySelector('#topup-sol').addEventListener('click', () => { - updateStatus('Демо-функция: пополнение будет добавлено позже'); + topupBtn.addEventListener('click', async () => { + if (!walletAddress) { + setStatus('Кошелёк не инициализирован.'); + return; + } + + const openSite = window.confirm( + 'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).' + ); + if (openSite) { + window.open(getTopupSiteUrl(), '_blank', 'noopener,noreferrer'); + setStatus('Открыт сайт пополнения. Пока также доступно тестовое пополнение.'); + return; + } + + topupBtn.disabled = true; + try { + const drop = await requestAirdropSol({ + endpoint: state.entrySettings.solanaServer, + address: walletAddress, + amountSol: 1, + }); + setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`); + await refreshBalance(); + } catch (error) { + setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`); + } finally { + topupBtn.disabled = false; + } }); - updateStatus(statusText); + (async () => { + try { + walletCtx = await getWalletFromStoredDeviceKey({ + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + }); + walletAddress = walletCtx.address; + addressEl.textContent = walletAddress; + await refreshBalance(); + } catch (error) { + addressEl.textContent = 'wallet.key недоступен'; + setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`); + } + })(); screen.append(card, actions, status); return screen; } + diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 430877e..aef69bd 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -645,7 +645,7 @@ export class AuthService { const addResp = await this.ws.request('AddUser', { login: cleanLogin, blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, - solanaKey: keyBundle.rootPair.publicKeyB64, + solanaKey: keyBundle.devicePair.publicKeyB64, blockchainKey: keyBundle.blockchainPair.publicKeyB64, deviceKey: keyBundle.devicePair.publicKeyB64, bchLimit: 1000000, diff --git a/shine-UI/js/services/server-health-service.js b/shine-UI/js/services/server-health-service.js new file mode 100644 index 0000000..7e9990a --- /dev/null +++ b/shine-UI/js/services/server-health-service.js @@ -0,0 +1,118 @@ +function normalizeUrl(value) { + return String(value || '').trim(); +} + +function normalizeShineWsUrl(rawUrl) { + const value = normalizeUrl(rawUrl); + if (!value) return ''; + if (value.startsWith('ws://') || value.startsWith('wss://')) { + try { + const parsed = new URL(value); + if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; + return parsed.toString(); + } catch { + return value; + } + } + if (value.startsWith('http://') || value.startsWith('https://')) { + try { + const parsed = new URL(value); + parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:'; + if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws'; + return parsed.toString(); + } catch { + return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`; + } + } + return value; +} + +async function checkSolanaRpc(url) { + const endpoint = normalizeUrl(url); + if (!endpoint) return false; + try { + const resp = await fetch(endpoint, { + method: 'POST', + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getVersion', + }), + }); + if (!resp.ok) return false; + const json = await resp.json(); + return Boolean(json?.result?.['solana-core'] || json?.result); + } catch { + return false; + } +} + +async function checkArweave(url) { + const base = normalizeUrl(url).replace(/\/+$/, ''); + if (!base) return false; + try { + const resp = await fetch(`${base}/info`, { method: 'GET', cache: 'no-store' }); + if (!resp.ok) return false; + const json = await resp.json(); + return Boolean(json?.network); + } catch { + return false; + } +} + +function checkShineWs(url, timeoutMs = 7000) { + const wsUrl = normalizeShineWsUrl(url); + if (!wsUrl) return Promise.resolve(false); + + return new Promise((resolve) => { + let done = false; + const finish = (ok) => { + if (done) return; + done = true; + window.clearTimeout(timer); + try { ws.close(); } catch {} + resolve(Boolean(ok)); + }; + + const timer = window.setTimeout(() => finish(false), timeoutMs); + let ws; + try { + ws = new WebSocket(wsUrl); + } catch { + finish(false); + return; + } + + ws.addEventListener('open', () => { + try { + ws.send(JSON.stringify({ + op: 'Ping', + requestId: `check-${Date.now()}`, + payload: { ts: Date.now() }, + })); + } catch { + finish(false); + } + }, { once: true }); + + ws.addEventListener('message', () => finish(true), { once: true }); + ws.addEventListener('error', () => finish(false), { once: true }); + ws.addEventListener('close', () => finish(false), { once: true }); + }); +} + +export async function checkServerAvailabilityByKey(key, url) { + if (key === 'solanaServer') { + return (await checkSolanaRpc(url)) ? 'available' : 'unavailable'; + } + if (key === 'shineServer') { + return (await checkShineWs(url)) ? 'available' : 'unavailable'; + } + if (key === 'arweaveServer') { + return (await checkArweave(url)) ? 'available' : 'unavailable'; + } + return 'unavailable'; +} + diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js new file mode 100644 index 0000000..7794009 --- /dev/null +++ b/shine-UI/js/services/solana-wallet-service.js @@ -0,0 +1,126 @@ +import { base64ToBytes, deriveEd25519FromPassword } from './crypto-utils.js'; +import { loadEncryptedUserSecrets } from './key-vault.js'; + +const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com'; +const TOPUP_SITE_URL = 'https://www.moonpay.com/buy/sol'; + +let solanaLibPromise = null; + +function normalizeEndpoint(url) { + const raw = String(url || '').trim(); + if (!raw) return DEFAULT_SOLANA_ENDPOINT; + return raw; +} + +async function loadSolanaLib() { + if (!solanaLibPromise) { + solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + } + return solanaLibPromise; +} + +function extractSeed32FromPkcs8B64(pkcs8B64) { + const bytes = base64ToBytes(String(pkcs8B64 || '').trim()); + if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key'); + return bytes.slice(bytes.length - 32); +} + +async function keypairFromPkcs8(pkcs8B64) { + const solana = await loadSolanaLib(); + const seed32 = extractSeed32FromPkcs8B64(pkcs8B64); + return solana.Keypair.fromSeed(seed32); +} + +export async function deriveWalletFromPassword(password) { + const keyBundle = await deriveEd25519FromPassword(String(password ?? ''), 'dev.key'); + const keypair = await keypairFromPkcs8(keyBundle.privatePkcs8B64); + return { + address: keypair.publicKey.toBase58(), + keypair, + devicePublicKeyB64: keyBundle.publicKeyB64, + devicePrivatePkcs8B64: keyBundle.privatePkcs8B64, + }; +} + +export async function getWalletFromStoredDeviceKey({ login, storagePwd }) { + const cleanLogin = String(login || '').trim(); + const cleanPwd = String(storagePwd || '').trim(); + if (!cleanLogin || !cleanPwd) { + throw new Error('Нет активной сессии для доступа к wallet.key'); + } + const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd); + const devicePrivate = String(secrets?.deviceKey || '').trim(); + if (!devicePrivate) { + throw new Error('На устройстве не найден device.key (wallet.key)'); + } + const keypair = await keypairFromPkcs8(devicePrivate); + return { + address: keypair.publicKey.toBase58(), + keypair, + devicePrivatePkcs8B64: devicePrivate, + }; +} + +export async function getBalanceSol({ endpoint, address }) { + const solana = await loadSolanaLib(); + const rpc = normalizeEndpoint(endpoint); + const conn = new solana.Connection(rpc, 'confirmed'); + const pubkey = new solana.PublicKey(String(address || '').trim()); + const lamports = await conn.getBalance(pubkey, 'confirmed'); + return { + endpoint: rpc, + lamports, + sol: lamports / solana.LAMPORTS_PER_SOL, + }; +} + +export async function requestAirdropSol({ endpoint, address, amountSol = 1 }) { + const solana = await loadSolanaLib(); + const rpc = normalizeEndpoint(endpoint); + const conn = new solana.Connection(rpc, 'confirmed'); + const pubkey = new solana.PublicKey(String(address || '').trim()); + const lamports = Math.max(1, Math.floor(Number(amountSol) * solana.LAMPORTS_PER_SOL)); + const signature = await conn.requestAirdrop(pubkey, lamports); + await conn.confirmTransaction(signature, 'confirmed'); + return { endpoint: rpc, signature, lamports }; +} + +export async function transferSol({ endpoint, fromKeypair, toAddress, amountSol }) { + const solana = await loadSolanaLib(); + const rpc = normalizeEndpoint(endpoint); + const cleanTo = String(toAddress || '').trim(); + const amount = Number(amountSol); + if (!cleanTo) throw new Error('Не указан адрес получателя'); + if (!Number.isFinite(amount) || amount <= 0) throw new Error('Сумма перевода должна быть больше 0'); + + const conn = new solana.Connection(rpc, 'confirmed'); + const lamports = Math.floor(amount * solana.LAMPORTS_PER_SOL); + if (lamports <= 0) throw new Error('Сумма слишком мала'); + + const tx = new solana.Transaction().add( + solana.SystemProgram.transfer({ + fromPubkey: fromKeypair.publicKey, + toPubkey: new solana.PublicKey(cleanTo), + lamports, + }), + ); + + const signature = await solana.sendAndConfirmTransaction(conn, tx, [fromKeypair], { + commitment: 'confirmed', + }); + return { endpoint: rpc, signature, lamports }; +} + +export function formatSol(value, digits = 6) { + const n = Number(value); + if (!Number.isFinite(n)) return '0'; + return n.toLocaleString('ru-RU', { + minimumFractionDigits: 0, + maximumFractionDigits: digits, + }); +} + +export function getTopupSiteUrl() { + return TOPUP_SITE_URL; +} + diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index c8ac30b..d06baa1 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1,4 +1,3 @@ -import { wallet } from './mock-data.js'; import { AuthService } from './services/auth-service.js'; import { clearClientAuthData } from './services/key-vault.js'; import { clearStoredMessages, listStoredMessages, putStoredMessage } from './services/message-store.js'; @@ -7,6 +6,7 @@ const clone = (value) => JSON.parse(JSON.stringify(value)); const SESSION_STORAGE_KEY = 'shine-ui-current-session-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'; const CHANNEL_NOTIFY_KEY = 'shine-channels-notify-v1'; const CHANNELS_DEMO_KEY = 'shine-channels-demo'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; @@ -77,7 +77,9 @@ function inferTunnelWsUrl() { } const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl(); +const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com'; const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws'; +const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; function loadStoredSession() { try { @@ -125,6 +127,37 @@ function clearStoredSession() { } } +function loadStoredEntrySettings() { + try { + const raw = localStorage.getItem(ENTRY_SETTINGS_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch { + return null; + } +} + +function persistEntrySettings(settings) { + try { + const payload = { + language: String(settings?.language || 'ru'), + solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER), + shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER), + arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), + statuses: { + solanaServer: String(settings?.statuses?.solanaServer || 'idle'), + shineServer: String(settings?.statuses?.shineServer || 'idle'), + arweaveServer: String(settings?.statuses?.arweaveServer || 'idle'), + }, + }; + localStorage.setItem(ENTRY_SETTINGS_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // ignore storage errors + } +} + function clearBrowserClientData() { const localKeys = [ SESSION_STORAGE_KEY, @@ -151,6 +184,7 @@ function clearBrowserClientData() { function createInitialState({ withStoredSession = true } = {}) { const storedSession = withStoredSession ? loadStoredSession() : null; const storedReactions = loadStoredReactions(); + const storedEntrySettings = loadStoredEntrySettings(); const initialShineServer = LOCAL_WS_OVERRIDE_URL || DEFAULT_SHINE_SERVER; return { @@ -170,14 +204,14 @@ function createInitialState({ withStoredSession = true } = {}) { }, startHint: '', entrySettings: { - language: 'ru', - solanaServer: 'https://api.mainnet-beta.solana.com', - shineServer: initialShineServer, - arweaveServer: 'https://arweave.net', + language: String(storedEntrySettings?.language || 'ru'), + solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER), + shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer), + arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), statuses: { - solanaServer: 'idle', - shineServer: 'idle', - arweaveServer: 'idle', + solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'), + shineServer: String(storedEntrySettings?.statuses?.shineServer || 'idle'), + arweaveServer: String(storedEntrySettings?.statuses?.arweaveServer || 'idle'), }, }, registrationDraft: { @@ -194,8 +228,8 @@ function createInitialState({ withStoredSession = true } = {}) { password: '', }, registrationPayment: { - walletAddress: wallet.publicAddress, - balanceSOL: '0.0068', + walletAddress: '', + balanceSOL: '0.0000', }, keyStorage: { rootKey: 'Ключ root хранится в зашифрованном виде', @@ -478,12 +512,9 @@ export function ensureChat(chatId) { } export function checkServerAvailability(address) { - const normalized = address.trim().toLowerCase(); + const normalized = String(address || '').trim().toLowerCase(); if (!normalized) return 'unavailable'; - - const looksLikeUrl = /^(https?:\/\/|wss?:\/\/)[a-z0-9.-]+/i.test(normalized); - const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized); - return looksLikeUrl && !blockedWord ? 'available' : 'unavailable'; + return /^(https?:\/\/|wss?:\/\/)/i.test(normalized) ? 'available' : 'unavailable'; } export async function saveEntrySettings(nextSettings) { @@ -497,6 +528,7 @@ export async function saveEntrySettings(nextSettings) { ...(nextSettings.statuses || {}), }, }; + persistEntrySettings(state.entrySettings); await authService.reconnect(state.entrySettings.shineServer); state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.'; }