import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js'; import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js'; import { ShineApiClient } from './js/lib/shine-api.js'; import { DEFAULT_SHINE_SERVER_LOGIN, buildHttpBase, normalizeServerLogin, readWalletProfileByLogin, resolveShineServerByServerLogin, } from './js/lib/shine-server-resolver.js'; const state = { api: null, settings: { serverLogin: DEFAULT_SHINE_SERVER_LOGIN, serverHttp: buildHttpBase('shineup.me'), serverUrl: 'wss://shineup.me/ws', login: '', }, requesterMaterial: null, pairingId: '', expiresAtMs: 0, shortCode: '', trustedSessionOnline: false, pollTimer: 0, activeSession: null, connectionOnline: false, walletProfile: null, signing: { selectedKeyId: 'device', selectedDeviceName: '', devicesResolvedAtMs: 0, }, statusText: '', statusKind: 'info', }; function setStatus(message = '', kind = 'info') { state.statusText = String(message || ''); state.statusKind = kind === 'error' ? 'error' : 'info'; } function stopPoll() { if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = 0; } } function clearPairingState() { stopPoll(); state.requesterMaterial = null; state.pairingId = ''; state.expiresAtMs = 0; state.shortCode = ''; state.trustedSessionOnline = false; } function ensureApi(serverUrl = state.settings.serverUrl) { const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws'; if (!state.api || state.api.serverUrl !== normalized) { state.api?.close(); state.api = new ShineApiClient(normalized); } return state.api; } async function resolveSettingsServer(nextSettings = {}) { const serverLogin = normalizeServerLogin(nextSettings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN) || DEFAULT_SHINE_SERVER_LOGIN; const resolved = await resolveShineServerByServerLogin(serverLogin); return { serverLogin: resolved.serverLogin, serverHttp: resolved.serverHttp, serverUrl: resolved.serverUrl, }; } async function loadStateFromStorage() { const settings = await loadPluginSettings(); const storedServerLogin = normalizeServerLogin(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN) || DEFAULT_SHINE_SERVER_LOGIN; state.settings = { serverLogin: storedServerLogin, serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'), serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws', 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 = {}) { const resolved = await resolveSettingsServer(nextSettings); state.settings = { ...state.settings, ...nextSettings, ...resolved, }; await savePluginSettings(state.settings); return state.settings; } 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) { state.connectionOnline = false; setStatus('Wallet-session ещё не подключена.', 'info'); return { ok: true, connected: false }; } try { await persistSettings({ serverLogin: String(sessionRecord?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(), serverHttp: String(sessionRecord?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim(), serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(), login: String(sessionRecord?.login || state.settings.login || '').trim(), }); const resumed = await ensureApi().resumeSession(sessionRecord); 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; setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error'); return { ok: false, connected: false, error: state.statusText }; } } async function attachApprovedSession(payload) { if (String(payload?.type || '') !== 'shine-esp-session-attach') { throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.'); } const login = String(payload?.login || state.settings.login || '').trim(); const approvedSession = payload?.session || {}; const sessionRecord = { login, sessionId: String(approvedSession?.sessionId || '').trim(), sessionKey: state.requesterMaterial?.sessionKey || '', sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '', sessionType: Number(approvedSession?.sessionType || 50) || 50, serverLogin: state.settings.serverLogin, serverHttp: state.settings.serverHttp, serverUrl: state.settings.serverUrl, }; if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) { throw new Error('Получен неполный session-only payload'); } await clearSessionMaterial(); state.activeSession = sessionRecord; await hydrateWalletProfile(login); await saveActiveSessionRecord(); await persistSettings({ login: sessionRecord.login, serverUrl: sessionRecord.serverUrl, }); state.connectionOnline = false; } async function pollPairingStatus() { if (!state.pairingId || !state.requesterMaterial) return; try { const payload = await ensureApi().getEspPairingStatus(state.pairingId); const stateValue = String(payload?.state || ''); if (stateValue === 'created') { state.pollTimer = setTimeout(() => { void pollPairingStatus(); }, 2200); return; } if (stateValue === 'approved') { const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial); await attachApprovedSession(decoded); clearPairingState(); setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info'); return; } if (stateValue === 'rejected') { clearPairingState(); setStatus('Заявка отклонена на доверенном устройстве.', 'error'); return; } if (stateValue === 'expired' || stateValue === 'canceled') { clearPairingState(); setStatus('Ожидание подключения завершено.', 'error'); return; } state.pollTimer = setTimeout(() => { void pollPairingStatus(); }, 2200); } catch (error) { clearPairingState(); setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error'); } } async function startPairing({ login, usePassword, password, serverLogin }) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) { throw new Error('Введите логин.'); } await persistSettings({ serverLogin: String(serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(), login: cleanLogin, }); clearPairingState(); setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info'); const api = ensureApi(); const user = await api.getUser(cleanLogin); if (user?.exists !== true) { throw new Error('Пользователь не найден.'); } state.requesterMaterial = await createRequesterPairingMaterial(); const passwordHash = usePassword ? await deriveEspPairingPasswordHash(cleanLogin, String(password || '')) : ''; const payload = await api.startEspPairing({ login: cleanLogin, passwordHash, requesterSessionKey: state.requesterMaterial.sessionKey, payloadType: 1, }); state.pairingId = String(payload?.pairingId || '').trim(); state.expiresAtMs = Number(payload?.expiresAtMs || 0); state.shortCode = String(payload?.shortCode || '0000000'); state.trustedSessionOnline = !!payload?.trustedSessionOnline; if (!state.pairingId) { throw new Error('Сервер не вернул pairingId.'); } state.pollTimer = setTimeout(() => { void pollPairingStatus(); }, 1800); setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info'); return { pairingId: state.pairingId, shortCode: String(payload?.shortCode || '0000000'), expiresAtMs: state.expiresAtMs, trustedSessionOnline: !!payload?.trustedSessionOnline, }; } async function cancelPairing() { if (!state.pairingId || !state.requesterMaterial?.sessionKey) { clearPairingState(); return { ok: true }; } await ensureApi().cancelEspPairing(state.pairingId, state.requesterMaterial.sessionKey); clearPairingState(); setStatus('Ожидание подключения отменено.', 'info'); return { ok: true }; } 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 }, pairing: { active: !!state.pairingId, pairingId: state.pairingId, expiresAtMs: state.expiresAtMs, shortCode: state.shortCode, trustedSessionOnline: state.trustedSessionOnline, }, 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, }, }; } chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { (async () => { const type = String(message?.type || ''); if (type === 'wallet:getState') { await loadStateFromStorage(); sendResponse({ ok: true, state: snapshot() }); return; } if (type === 'wallet:saveSettings') { await persistSettings(message?.payload || {}); sendResponse({ ok: true, state: snapshot() }); return; } if (type === 'wallet:startPairing') { const result = await startPairing(message?.payload || {}); sendResponse({ ok: true, result, state: snapshot() }); return; } if (type === 'wallet:cancelPairing') { const result = await cancelPairing(); sendResponse({ ok: true, result, state: snapshot() }); return; } if (type === 'wallet:resumeSession') { const result = await resumeActiveSession(); 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() }); return; } sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' }); })().catch((error) => { setStatus(error?.message || 'Unknown error', 'error'); sendResponse({ ok: false, error: error?.message || 'Unknown error', state: snapshot() }); }); return true; }); 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'); });