Wallet plugin: офлайн wallet-session и выбор homeserver\n\nСделано:\n- wallet plugin сохраняет PDA-профиль и остаётся офлайн до действия;\n- добавлен каркас выбора ключа подписи и homeserver-устройства;\n- добавлен ручной refresh trusted devices через ListSessions;\n- на регистрации показан первый сервер SHiNE и его адрес;\n- обновлены pending notes для ручной проверки.\n\nЕщё не проверено / не доделано:\n- end-to-end ручная проверка plugin после этих правок не завершена;\n- signaling запроса подписи и ответ подписи ещё не реализованы;\n- локальный browser plugin нужно отдельно reload в Chrome/Opera.

This commit is contained in:
AidarKC 2026-06-18 11:04:34 +04:00
parent f8a76bcd7f
commit 2225c2d173
10 changed files with 440 additions and 19 deletions

View File

@ -0,0 +1,17 @@
# Wallet plugin: PDA-ключи и выбор homeserver
- краткое описание:
`SHiNE-browser-plugin-wallet` после session-only подключения сохраняет `login`, публичные `root/device/blockchain` ключи из PDA и список опубликованных `homeserver`-сессий. Постоянное подключение не удерживается: plugin остаётся офлайн, а список trusted devices обновляет по запросу.
- что проверять:
1. После успешного pairing plugin показывает сохранённую wallet-session без автоматического постоянного подключения.
2. В карточке wallet-session виден сокращённый `deviceKey`.
3. Кнопка `Обновить устройства` подтягивает homeserver-сессии из PDA и показывает их список со статусом `online/offline/unknown`.
4. В селекте ключа подписи доступен `deviceKey (ed25519, ...)`.
5. Кнопка `Запросить подпись` не падает и честно сообщает, что signaling подписи ещё не доделан.
- ожидаемый результат:
кошелёк хранит PDA-метаданные локально, не висит всё время онлайн и показывает каркас выбора ключа и устройства перед будущим этапом signaling подписи.
- статус:
pending

View File

@ -5,6 +5,7 @@ import {
DEFAULT_SHINE_SERVER_LOGIN,
buildHttpBase,
normalizeServerLogin,
readWalletProfileByLogin,
resolveShineServerByServerLogin,
} from './js/lib/shine-server-resolver.js';
@ -24,6 +25,12 @@ const state = {
pollTimer: 0,
activeSession: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
},
statusText: '',
statusKind: 'info',
};
@ -80,6 +87,12 @@ async function loadStateFromStorage() {
login: String(settings?.login || '').trim(),
};
state.activeSession = await loadSessionMaterial();
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
}
async function persistSettings(nextSettings = {}) {
@ -93,7 +106,89 @@ async function persistSettings(nextSettings = {}) {
return state.settings;
}
async function resumeActiveSession() {
async function saveActiveSessionRecord() {
if (!state.activeSession) return;
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
}
function shortKey(value = '', size = 10) {
const raw = String(value || '').trim();
return raw ? raw.slice(0, size) : '';
}
function buildSigningKeyOptions(walletProfile) {
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
if (!deviceKey) return [];
return [{
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
}];
}
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : [];
const homeserverSessions = Array.isArray(serverSessions)
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
return published.map((item) => {
let onlineState = 'unknown';
if (published.length === 1) {
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
} else if (onlineHomeservers.length === 0) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
onlineState = 'online';
}
return {
...item,
onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
};
});
}
async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin);
const signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
})) : [],
};
state.signing = {
...state.signing,
selectedKeyId,
selectedDeviceName,
};
await saveActiveSessionRecord();
return state.walletProfile;
}
async function resumeActiveSession({ keepConnected = false } = {}) {
const sessionRecord = await loadSessionMaterial();
state.activeSession = sessionRecord;
if (!sessionRecord) {
@ -110,8 +205,14 @@ async function resumeActiveSession() {
login: String(sessionRecord?.login || state.settings.login || '').trim(),
});
const resumed = await ensureApi().resumeSession(sessionRecord);
state.connectionOnline = true;
state.connectionOnline = !!keepConnected;
if (!keepConnected) {
ensureApi().close();
state.api = null;
setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info');
} else {
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
}
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
} catch (error) {
state.connectionOnline = false;
@ -142,13 +243,14 @@ async function attachApprovedSession(payload) {
}
await clearSessionMaterial();
await saveSessionMaterial(sessionRecord);
state.activeSession = sessionRecord;
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverUrl: sessionRecord.serverUrl,
});
await resumeActiveSession();
state.connectionOnline = false;
}
async function pollPairingStatus() {
@ -166,7 +268,7 @@ async function pollPairingStatus() {
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
await attachApprovedSession(decoded);
clearPairingState();
setStatus('Wallet-session создана и сохранена.', 'info');
setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info');
return;
}
if (stateValue === 'rejected') {
@ -251,13 +353,87 @@ async function cancelPairing() {
}
async function disconnectSession() {
ensureApi().close();
state.api = null;
await clearSessionMaterial();
state.activeSession = null;
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
async function refreshWalletDevices() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
await hydrateWalletProfile(state.activeSession.login);
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
try {
const sessions = await ensureApi().listSessions();
state.walletProfile = {
...state.walletProfile,
homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions),
};
state.signing.devicesResolvedAtMs = Date.now();
if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) {
state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName;
}
await saveActiveSessionRecord();
setStatus('Список доверенных homeserver-устройств обновлён.', 'info');
return {
ok: true,
devices: state.walletProfile.homeserverSessions,
};
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function prepareSignSignal() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
setStatus(
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
'info',
);
return {
ok: true,
pending: true,
};
}
function snapshot() {
return {
settings: { ...state.settings },
@ -270,6 +446,8 @@ function snapshot() {
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
signing: { ...state.signing },
status: {
text: state.statusText,
kind: state.statusKind,
@ -305,6 +483,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:refreshWalletDevices') {
const result = await refreshWalletDevices();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:updateSigningSelection') {
const result = await updateSigningSelection(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:prepareSignSignal') {
const result = await prepareSignSignal();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:disconnectSession') {
const result = await disconnectSession();
sendResponse({ ok: true, result, state: snapshot() });
@ -318,6 +511,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true;
});
void loadStateFromStorage().then(() => resumeActiveSession()).catch((error) => {
void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info');
}
}).catch((error) => {
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
});

View File

@ -69,6 +69,12 @@ export class ShineApiClient {
return response.payload || {};
}
async listSessions() {
const response = await this.ws.request('ListSessions', {});
if (response.status !== 200) throw opError('ListSessions', response);
return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : [];
}
async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim();

View File

@ -69,21 +69,32 @@ function parseServerFieldsFromUserPda(dataBytes) {
let isServer = false;
let serverAddress = '';
let accessServers = [];
let rootKey32 = null;
let deviceKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
for (let i = 0; i < blocksCount; i += 1) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) {
cursorRef.value += 32;
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) deviceKey32 = key32;
continue;
}
if (blockType === 3) {
const count = readU8(bytes, cursorRef);
for (let j = 0; j < count; j += 1) {
cursorRef.value += 1;
readStrU8(bytes, cursorRef);
cursorRef.value += 32;
const currentBlockchainName = readStrU8(bytes, cursorRef);
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
if (!blockchainKey32) {
blockchainKey32 = currentBlockchainKey32;
blockchainName = currentBlockchainName;
}
cursorRef.value += 8 + 8 + 4 + 32 + 64;
const arPresent = readU8(bytes, cursorRef);
if (arPresent === 1) readStrU8(bytes, cursorRef);
@ -111,9 +122,19 @@ function parseServerFieldsFromUserPda(dataBytes) {
cursorRef.value += 1;
const sessionsCount = readU8(bytes, cursorRef);
for (let j = 0; j < sessionsCount; j += 1) {
cursorRef.value += 1 + 1;
readStrU8(bytes, cursorRef);
cursorRef.value += 32;
const sessionType = readU8(bytes, cursorRef);
const sessionVersion = readU8(bytes, cursorRef);
const sessionName = readStrU8(bytes, cursorRef);
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
if (sessionType === 100) {
homeserverSessions.push({
sessionType,
sessionVersion,
sessionName,
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
});
}
}
continue;
}
@ -128,6 +149,13 @@ function parseServerFieldsFromUserPda(dataBytes) {
isServer,
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
},
homeserverSessions,
};
}
@ -176,6 +204,17 @@ export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoin
};
}
export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
publicKeys: parsed.publicKeys,
homeserverSessions: parsed.homeserverSessions,
};
}
export {
DEFAULT_SHINE_SERVER_ADDRESS,
DEFAULT_SHINE_SERVER_LOGIN,

View File

@ -88,7 +88,8 @@ body {
}
input[type="text"],
input[type="password"] {
input[type="password"],
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
@ -178,3 +179,33 @@ input[type="password"] {
.hidden {
display: none;
}
.device-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.device-state {
font-size: 12px;
text-transform: lowercase;
}
.device-state-online {
color: #b7f5ce;
}
.device-state-offline {
color: #ffc4cf;
}
.device-state-unknown {
color: #f8e2a0;
}

View File

@ -28,12 +28,36 @@
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Переподключить</button>
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="signing-card" class="card hidden">
<div class="card-title">Подготовка подписи</div>
<label class="field">
<span>Ключ подписи</span>
<select id="sign-key-select"></select>
</label>
<label class="field">
<span>Устройство homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<p class="muted small">
Для выбора доступны homeserver-сессии, опубликованные в PDA аккаунта. Online-статус определяется без постоянного удержания соединения.
</p>
<div class="actions">
<button id="prepare-sign-btn" class="btn primary" type="button">Запросить подпись</button>
</div>
<p class="muted small">
Сам signaling подтверждения подписи ещё не доделан. Сейчас доступен только каркас выбора ключа и устройства.
</p>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<label class="field">

View File

@ -16,8 +16,15 @@ const els = {
sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
deviceKeyShort: document.querySelector('#device-key-short'),
resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
signingCard: document.querySelector('#signing-card'),
signKeySelect: document.querySelector('#sign-key-select'),
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
prepareSignBtn: document.querySelector('#prepare-sign-btn'),
connectionPill: document.querySelector('#connection-pill'),
};
@ -62,6 +69,34 @@ function formatRemaining(ms) {
return `${minutes} мин ${seconds} сек`;
}
function shortKey(value, size = 10) {
const raw = String(value || '').trim();
return raw ? `${raw.slice(0, size)}...` : '—';
}
function renderHomeserverList(items = []) {
els.homeserverList.innerHTML = '';
if (!items.length) {
const empty = document.createElement('p');
empty.className = 'muted small';
empty.textContent = 'В PDA пока нет опубликованных homeserver-сессий.';
els.homeserverList.append(empty);
return;
}
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'summary-row device-row';
const label = document.createElement('span');
label.textContent = `${item.sessionName} (${shortKey(item.sessionPubKeyBase58, 8)})`;
const badge = document.createElement('strong');
const stateValue = String(item.onlineState || 'unknown');
badge.textContent = stateValue;
badge.className = `device-state device-state-${stateValue}`;
row.append(label, badge);
els.homeserverList.append(row);
});
}
function applyState(nextState) {
state = nextState || state;
const serverValue = String(state?.settings?.serverLogin || 'shineupme');
@ -78,18 +113,46 @@ function applyState(nextState) {
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
if (session) {
els.sessionCard.classList.remove('hidden');
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
els.signingCard.classList.remove('hidden');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
els.deviceKeyShort.textContent = '—';
els.signingCard.classList.add('hidden');
}
const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : [];
els.signKeySelect.innerHTML = '';
signKeyOptions.forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.label;
option.selected = item.id === signing.selectedKeyId;
els.signKeySelect.append(option);
});
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
els.deviceSelect.innerHTML = '';
homeservers.forEach((item) => {
const option = document.createElement('option');
option.value = item.sessionName;
option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`;
option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option);
});
renderHomeserverList(homeservers);
els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName;
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
@ -201,6 +264,35 @@ async function disconnectSession() {
}
}
async function refreshDevices() {
setStatus('Обновляем trusted homeserver-устройства...', 'info');
try {
await sendMessage('wallet:refreshWalletDevices');
} catch (error) {
setStatus(error.message || 'Не удалось обновить список устройств.', 'error');
}
}
async function updateSigningSelection() {
try {
await sendMessage('wallet:updateSigningSelection', {
selectedKeyId: String(els.signKeySelect.value || '').trim(),
selectedDeviceName: String(els.deviceSelect.value || '').trim(),
});
} catch (error) {
setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error');
}
}
async function prepareSignSignal() {
setStatus('Готовим каркас запроса подписи...', 'info');
try {
await sendMessage('wallet:prepareSignSignal');
} catch (error) {
setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error');
}
}
function startUiRefreshLoop() {
stopUiRefreshLoop();
refreshTimer = window.setInterval(() => {
@ -229,7 +321,11 @@ function bindUi() {
els.startBtn.addEventListener('click', () => { void startPairing(); });
els.cancelBtn.addEventListener('click', () => { void cancelPairing(); });
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); });
els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); });
els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); });
}
async function init() {

View File

@ -1,2 +1,2 @@
client.version=1.2.206
server.version=1.2.195
client.version=1.2.207
server.version=1.2.196

View File

@ -7,7 +7,7 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260616124000';
window.__SHINE_BUILD_HASH__ = '20260616130000';
window.__SHINE_CLIENT_VERSION__ = '1.2.8';
</script>
<script>

View File

@ -41,6 +41,16 @@ export function render({ navigate }) {
statusText.className = 'meta-muted';
statusText.textContent = 'Проверка логина: не выполнена';
const serverNotice = document.createElement('div');
serverNotice.className = 'card stack';
serverNotice.innerHTML = `
<p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
<p class="meta-muted">При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.</p>
<p class="meta-muted">При желании его можно изменить в настройках до регистрации или позже. В будущем будет поддерживаться мультисерверная модель, но сейчас используется один основной сервер.</p>
`;
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
@ -201,7 +211,7 @@ export function render({ navigate }) {
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(checkButton, statusText, advanced, formError);
form.append(serverNotice, checkButton, statusText, advanced, formError);
actions.innerHTML = '';
actions.append(backButton, nextButton);
backButton.disabled = false;