diff --git a/VERSION.properties b/VERSION.properties index 0271d5b..7d99cbe 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.327 -server.version=1.2.299 +client.version=1.2.329 +server.version=1.2.301 diff --git a/build.gradle b/build.gradle index 4dcf664..3c786e1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id 'application' - id 'com.github.johnrengelman.shadow' version '8.1.1' +проверь ещё id 'com.github.johnrengelman.shadow' version '8.1.1' } def appVersionProps = new Properties() diff --git a/docs/Pending_Features/2026-07-16_2035_ожидание_подтверждения_solana_при_регистрации.md b/docs/Pending_Features/2026-07-16_2035_ожидание_подтверждения_solana_при_регистрации.md new file mode 100644 index 0000000..d546b67 --- /dev/null +++ b/docs/Pending_Features/2026-07-16_2035_ожидание_подтверждения_solana_при_регистрации.md @@ -0,0 +1,20 @@ +## Кратко +- Переделан финальный flow регистрации: сначала экран ожидания подтверждения Solana с прогрессом и повторными проверками, потом отдельный экран успешной регистрации с кнопкой входа в стандартный экран сохранения ключей. + +## Что проверять +- После отправки регистрации открывается экран ожидания с текстом про подтверждение Solana и кликабельным `Tx ID`. +- Прогресс-бар сначала идёт быстрее, потом заметно замедляется. +- Первая проверка регистрации начинается примерно через 4 секунды, далее повторяется раз в 2 секунды. +- Пока подтверждения нет, снизу показываются понятные статусы ожидания. +- После подтверждения открывается экран «Поздравляем с регистрацией» с одной кнопкой `Войти в аккаунт`. +- Кнопка `Войти в аккаунт` открывает штатный экран сохранения ключей с тремя галочками. +- Стрелка назад в левом верхнем углу на обоих экранах возвращает в главное меню. +- После успешного сохранения ключей регистрационный черновик и адрес кошелька очищаются. + +## Ожидаемый результат +- Пользователь не видит преждевременное поздравление до подтверждения регистрации в Solana. +- Финальный успех показывается только после появления подтверждения регистрации. +- Вход после регистрации идёт через тот же стандартный сценарий сохранения ключей, что и в обычном login-flow. + +## Статус +- pending diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index f66f697..7189a89 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -162,6 +162,9 @@ export function render({ navigate }) { state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; state.registrationDraft.pendingSessionMaterial = null; + state.registrationDraft.preGeneratedKeyBundle = null; + state.registrationPayment.walletAddress = ''; + state.registrationPayment.balanceSOL = '0.0000'; await refreshSessions(); setAuthInfo(isLoginFlow diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 02ccdb0..31b6fd8 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -1,8 +1,6 @@ import { renderHeader } from '../components/header.js'; import { authService, - authorizeSession, - refreshSessions, setAuthError, setAuthInfo, state, @@ -16,6 +14,7 @@ import { } from '../services/solana-wallet-service.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { + checkLoginExistsOnSolana, formatSolanaErrorDetails, isUserAlreadyExistsSolanaError, registerUserOnSolana, @@ -24,9 +23,11 @@ import { defaultServerLogin } from '../deploy-config.js'; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; const MIN_REQUIRED_SOL = 0.01; -const AUTO_LOGIN_INITIAL_DELAY_MS = 10000; -const AUTO_LOGIN_RETRY_MS = 2000; -const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => ''); +const REGISTRATION_PROGRESS_DURATION_MS = 12000; +const REGISTRATION_POLL_START_DELAY_MS = 4000; +const REGISTRATION_POLL_INTERVAL_MS = 2000; +const REGISTRATION_CONFIRM_TIMEOUT_MS = 25000; +const REGISTRATION_SUCCESS_SETTLE_DELAY_MS = 2000; function getExplorerClusterName(endpoint) { const source = String(endpoint || '').trim().toLowerCase(); @@ -60,70 +61,23 @@ function getCryptoRuntimeState() { return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext }; } -async function completeRegistrationLogin({ navigate, keyBundle }) { +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +async function prepareRegistrationLoginForKeyStorage({ keyBundle }) { await authService.reconnect(state.entrySettings.shineServer); const result = await authService.createSessionForExistingUser( state.registrationDraft.login, state.registrationDraft.password, ); - - state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot); - state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain); - - await authService.persistSelectedKeys( - result.login, - result.storagePwd, - keyBundle, - { - saveRoot: state.keyStorage.saveRoot, - saveBlockchain: state.keyStorage.saveBlockchain, - }, - ); - await authService.persistSessionMaterial(result.login, result.sessionMaterial); - await clearStoredMessages().catch(() => {}); - - const resumed = await authService.resumeSession(result.login, result.sessionId); - const resumedLogin = resumed.login || result.login; - const resumedSessionId = resumed.sessionId || result.sessionId; - const resumedStoragePwd = resumed.storagePwd || result.storagePwd; - - authorizeSession({ - login: resumedLogin, - sessionId: resumedSessionId, - storagePwd: resumedStoragePwd, - }); - - state.loginDraft.login = resumedLogin; - state.loginDraft.password = ''; - state.loginDraft.passwordMode = 'single'; - state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); - - state.registrationDraft.flowType = ''; - state.registrationDraft.password = ''; - state.registrationDraft.passwordMode = 'single'; - state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); - state.registrationDraft.passwordWordsLinked = false; - state.registrationDraft.usePromoCode = false; - state.registrationDraft.promoCode = ''; - state.registrationDraft.storagePwd = ''; - state.registrationDraft.sessionId = ''; - state.registrationDraft.pendingKeyBundle = null; - state.registrationDraft.pendingSessionMaterial = null; - state.registrationDraft.preGeneratedKeyBundle = null; - - state.registrationPayment.walletAddress = ''; - state.registrationPayment.balanceSOL = '0.0000'; - - await refreshSessions(); - setAuthInfo(`Регистрация завершена. Вы автоматически вошли как @${resumedLogin}.`); - - const nextHash = String(state.authReturnHash || '').trim(); - state.authReturnHash = ''; - if (nextHash.startsWith('/')) { - navigate(nextHash.slice(1)); - } else { - navigate('profile-view'); - } + state.registrationDraft.flowType = 'register'; + state.registrationDraft.login = result.login; + state.registrationDraft.sessionId = result.sessionId; + state.registrationDraft.storagePwd = result.storagePwd; + state.registrationDraft.pendingKeyBundle = keyBundle || result.keyBundle; + state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; + return result; } export function render({ navigate }) { @@ -299,7 +253,7 @@ export function render({ navigate }) { } } - renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId }); + renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId }); } catch (error) { const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); setAuthError(message); @@ -345,7 +299,7 @@ export function render({ navigate }) { return screen; } -function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) { +function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) { const screen = document.querySelector('section.stack'); if (!screen) return; const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn'); @@ -353,19 +307,24 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = if (!card) return; card.classList.add('registration-finish-card'); - let initialDelayId = null; - let retryTimerId = null; - let loginInFlight = false; - let loginCompleted = false; + let progressTimerId = null; + let pollStartTimerId = null; + let pollTimerId = null; + let successTimerId = null; + let confirmationInFlight = false; + let stageClosed = false; + let successPending = false; + let timeoutShown = false; + const stageStartedAt = Date.now(); const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer); const title = document.createElement('h2'); title.className = 'registration-finish-title'; - title.textContent = 'Поздравляем, регистрация завершена'; + title.textContent = 'Идёт регистрация в блокчейне...'; const hint = document.createElement('p'); hint.className = 'auth-copy registration-finish-text'; - hint.textContent = 'Подождите 10 секунд, пока обновится транзакция вашей регистрации в блокчейне Solana. После этого вход в аккаунт произойдёт автоматически.'; + hint.textContent = 'Ждём, пока транзакция будет полностью одобрена сетью Solana.'; const txIdLine = document.createElement('p'); txIdLine.className = 'meta-muted registration-finish-tx'; @@ -383,58 +342,218 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись'; } + const progressWrap = document.createElement('div'); + progressWrap.className = 'registration-progress'; + + const progressBar = document.createElement('div'); + progressBar.className = 'registration-progress-bar'; + progressWrap.append(progressBar); + const progress = document.createElement('p'); progress.className = 'meta-muted registration-finish-progress'; - progress.textContent = 'Подготавливаем проверку регистрации...'; + progress.textContent = 'Подготавливаем проверку подтверждения...'; - const stopAutoLogin = () => { - if (initialDelayId) { - window.clearTimeout(initialDelayId); - initialDelayId = null; + const pollStatus = document.createElement('p'); + pollStatus.className = 'meta-muted registration-finish-progress'; + pollStatus.textContent = 'Через несколько секунд начнём проверять подтверждение регистрации.'; + + const stopTimers = () => { + if (progressTimerId) { + window.clearInterval(progressTimerId); + progressTimerId = null; } - if (retryTimerId) { - window.clearInterval(retryTimerId); - retryTimerId = null; + if (pollStartTimerId) { + window.clearTimeout(pollStartTimerId); + pollStartTimerId = null; + } + if (pollTimerId) { + window.clearInterval(pollTimerId); + pollTimerId = null; + } + if (successTimerId) { + window.clearTimeout(successTimerId); + successTimerId = null; } }; - const tryAutoLogin = async () => { - if (loginCompleted || loginInFlight) return; - loginInFlight = true; - progress.textContent = 'Проверяем, прошла ли регистрация...'; + const updateVisualProgress = () => { + const elapsed = Date.now() - stageStartedAt; + let widthPercent = 0; + if (elapsed <= REGISTRATION_PROGRESS_DURATION_MS) { + const phaseRatio = clamp(elapsed / REGISTRATION_PROGRESS_DURATION_MS, 0, 1); + widthPercent = 100 * (1 - ((1 - phaseRatio) ** 1.85)); + } else { + const extraRatio = 1 - Math.exp(-(elapsed - REGISTRATION_PROGRESS_DURATION_MS) / 9000); + widthPercent = 88 + (10 * clamp(extraRatio, 0, 1)); + } + progressBar.style.width = `${clamp(widthPercent, 0, 98)}%`; + + if (elapsed < REGISTRATION_POLL_START_DELAY_MS) { + progress.textContent = 'Отправили регистрацию в сеть. Даём транзакции несколько секунд на обработку.'; + return; + } + if (elapsed < REGISTRATION_CONFIRM_TIMEOUT_MS) { + progress.textContent = 'Идёт ожидание подтверждения Solana. Индикатор может замедлиться, это нормально.'; + return; + } + progress.textContent = 'Подтверждение затянулось. Продолжаем автоматическую проверку.'; + }; + + const finalizeSuccess = () => { + if (stageClosed || successPending) return; + successPending = true; + stopTimers(); + progress.textContent = 'Подтверждение получено. Завершаем регистрацию...'; + pollStatus.textContent = 'Ещё секунду: запись уже найдена в Solana, синхронизируем финальный экран.'; status.style.display = 'none'; + progressBar.style.transition = `width ${REGISTRATION_SUCCESS_SETTLE_DELAY_MS}ms ease-out`; + progressBar.style.width = '100%'; + successTimerId = window.setTimeout(() => { + if (stageClosed) return; + stageClosed = true; + renderRegistrationSuccessStage({ navigate, status, keyBundle, registrationTxId }); + }, REGISTRATION_SUCCESS_SETTLE_DELAY_MS); + }; + + const showTimeoutState = () => { + if (timeoutShown || stageClosed) return; + timeoutShown = true; + status.className = 'status-line is-unavailable'; + status.textContent = 'Подтверждение не пришло вовремя. Возможно, регистрация уже прошла, а возможно ещё нет. Проверьте Tx ID ниже: мы продолжим автоматическую проверку.'; + status.style.display = ''; + pollStatus.textContent = 'Пока подтверждения нет. Подождите ещё секундочку, мы продолжаем проверять регистрацию.'; + }; + + const tryCheckRegistration = async () => { + if (stageClosed || confirmationInFlight) return; + confirmationInFlight = true; + progress.textContent = 'Проверяем подтверждение регистрации в Solana...'; + status.style.display = timeoutShown ? '' : 'none'; try { - await completeRegistrationLogin({ navigate, keyBundle }); - loginCompleted = true; - stopAutoLogin(); + const result = await checkLoginExistsOnSolana({ + login: state.registrationDraft.login, + solanaEndpoint: state.entrySettings.solanaServer, + }); + if (result?.exists) { + finalizeSuccess(); + return; + } + pollStatus.textContent = 'Пока ещё не прошла, подождите ещё секундочку...'; + if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { + showTimeoutState(); + } } catch (error) { - if (!loginCompleted) { - progress.textContent = 'Пока ещё не прошла, подождите ещё секундочку...'; - console.warn('Auto login after registration is not ready yet', toUserMessage(error, 'auto-login-wait')); + console.warn('Registration confirmation check failed', toUserMessage(error, 'registration-confirmation')); + pollStatus.textContent = 'Проверяем регистрацию повторно...'; + if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { + showTimeoutState(); } } finally { - loginInFlight = false; + confirmationInFlight = false; } }; if (headerBackButton) { const replacement = headerBackButton.cloneNode(true); replacement.addEventListener('click', () => { - loginCompleted = true; - stopAutoLogin(); + stageClosed = true; + stopTimers(); navigate('start-view'); }); headerBackButton.replaceWith(replacement); } card.innerHTML = ''; - card.append(title, hint, txIdLine, progress, status); + status.style.display = 'none'; + card.append(title, hint, txIdLine, progressWrap, progress, pollStatus, status); - initialDelayId = window.setTimeout(() => { - if (loginCompleted) return; - void tryAutoLogin(); - retryTimerId = window.setInterval(() => { - void tryAutoLogin(); - }, AUTO_LOGIN_RETRY_MS); - }, AUTO_LOGIN_INITIAL_DELAY_MS); + updateVisualProgress(); + progressTimerId = window.setInterval(() => { + if (stageClosed) return; + updateVisualProgress(); + if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { + showTimeoutState(); + } + }, 160); + + pollStartTimerId = window.setTimeout(() => { + if (stageClosed) return; + void tryCheckRegistration(); + pollTimerId = window.setInterval(() => { + void tryCheckRegistration(); + }, REGISTRATION_POLL_INTERVAL_MS); + }, REGISTRATION_POLL_START_DELAY_MS); +} + +function renderRegistrationSuccessStage({ navigate, status, keyBundle, registrationTxId = '' }) { + const screen = document.querySelector('section.stack'); + if (!screen) return; + const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn'); + const card = screen.querySelector('.card.stack'); + if (!card) return; + + const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer); + let loginInFlight = false; + + const title = document.createElement('h2'); + title.className = 'registration-finish-title'; + title.textContent = 'Поздравляем с регистрацией'; + + const hint = document.createElement('p'); + hint.className = 'auth-copy registration-finish-text'; + hint.textContent = 'Регистрация подтверждена в блокчейне Solana. Теперь можно войти в аккаунт и выбрать, какие ключи сохранить на устройстве.'; + + const txIdLine = document.createElement('p'); + txIdLine.className = 'meta-muted registration-finish-tx'; + if (registrationTxId && txExplorerUrl) { + const txLabel = document.createElement('span'); + txLabel.textContent = 'Tx ID регистрации: '; + const txLink = document.createElement('a'); + txLink.className = 'registration-finish-tx-link'; + txLink.href = txExplorerUrl; + txLink.target = '_blank'; + txLink.rel = 'noopener noreferrer'; + txLink.textContent = registrationTxId; + txIdLine.append(txLabel, txLink); + } else { + txIdLine.textContent = 'Регистрация подтверждена. Tx ID недоступен.'; + } + + const enterButton = document.createElement('button'); + enterButton.className = 'primary-btn'; + enterButton.type = 'button'; + enterButton.textContent = 'Войти в аккаунт'; + enterButton.addEventListener('click', async () => { + if (loginInFlight) return; + loginInFlight = true; + status.className = 'status-line'; + status.textContent = 'Готовим вход и экран сохранения ключей...'; + status.style.display = ''; + enterButton.disabled = true; + try { + await prepareRegistrationLoginForKeyStorage({ keyBundle }); + await clearStoredMessages().catch(() => {}); + setAuthInfo(`Регистрация подтверждена для @${state.registrationDraft.login}. Выберите, какие ключи сохранить на устройстве.`); + navigate('registration-keys-view'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось подготовить вход после регистрации.'); + setAuthError(message); + status.className = 'status-line is-unavailable'; + status.textContent = message; + status.style.display = ''; + } finally { + loginInFlight = false; + enterButton.disabled = false; + } + }); + + if (headerBackButton) { + const replacement = headerBackButton.cloneNode(true); + replacement.addEventListener('click', () => navigate('start-view')); + headerBackButton.replaceWith(replacement); + } + + card.innerHTML = ''; + status.style.display = 'none'; + card.append(title, hint, txIdLine, enterButton, status); } diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index b318ef7..c6a9c88 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -763,7 +763,7 @@ .registration-finish-title { margin: 0; - font-size: clamp(1.7rem, 6vw, 2.15rem); + font-size: clamp(1.9rem, 7vw, 2.45rem); line-height: 1.18; text-wrap: balance; } @@ -776,6 +776,10 @@ word-break: break-word; } +.registration-finish-card .primary-btn { + width: 100%; +} + .registration-finish-tx-link { color: #9fd8ff; text-decoration: underline;