909 lines
33 KiB
JavaScript
909 lines
33 KiB
JavaScript
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,
|
||
readWalletProfileByLogin,
|
||
resolveShineServerByUserLogin,
|
||
} 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: {
|
||
selectedDeviceName: '',
|
||
devicesResolvedAtMs: 0,
|
||
},
|
||
currentWallet: null,
|
||
pendingApproval: null,
|
||
statusText: '',
|
||
statusKind: 'info',
|
||
};
|
||
|
||
const WALLET_RPC_REQUEST_TYPE = 9100;
|
||
const WALLET_RPC_RESPONSE_TYPE = 9101;
|
||
|
||
async function configureSidePanelBehavior() {
|
||
if (!chrome.sidePanel?.setPanelBehavior) {
|
||
return;
|
||
}
|
||
try {
|
||
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||
} catch (error) {
|
||
console.warn('Failed to configure SHiNE side panel behavior:', error);
|
||
}
|
||
}
|
||
|
||
function normalizeOrigin(value = '') {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return '';
|
||
try {
|
||
return new URL(raw).origin;
|
||
} catch {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
function makeCodeError(message, code) {
|
||
const error = new Error(String(message || 'Wallet error'));
|
||
error.code = String(code || '').trim().toUpperCase();
|
||
return error;
|
||
}
|
||
|
||
function setStatus(message = '', kind = 'info') {
|
||
state.statusText = String(message || '');
|
||
state.statusKind = kind === 'error' ? 'error' : 'info';
|
||
}
|
||
|
||
function makePendingApprovalSnapshot(payload = {}) {
|
||
return {
|
||
id: String(payload?.id || '').trim(),
|
||
kind: String(payload?.kind || 'sign_transaction').trim() || 'sign_transaction',
|
||
origin: String(payload?.origin || '').trim(),
|
||
publicKeyBase58: String(payload?.publicKeyBase58 || '').trim(),
|
||
comment: String(payload?.comment || '').trim(),
|
||
createdAtMs: Number(payload?.createdAtMs || Date.now()),
|
||
transactionSummary: payload?.transactionSummary && typeof payload.transactionSummary === 'object'
|
||
? { ...payload.transactionSummary }
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function clearPendingApproval({ rejectError = null } = {}) {
|
||
if (!state.pendingApproval) return;
|
||
const pending = state.pendingApproval;
|
||
state.pendingApproval = null;
|
||
if (pending.timeoutId) {
|
||
clearTimeout(pending.timeoutId);
|
||
}
|
||
if (rejectError && pending.abortController) {
|
||
try {
|
||
pending.abortController.abort(rejectError);
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
async function openSidePanelForSender(sender) {
|
||
if (!chrome.sidePanel?.open || !sender?.tab?.id) return;
|
||
try {
|
||
await chrome.sidePanel.open({ tabId: sender.tab.id });
|
||
} catch {}
|
||
}
|
||
|
||
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 loadStateFromStorage() {
|
||
const settings = await loadPluginSettings();
|
||
state.settings = {
|
||
serverLogin: String(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
|
||
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(),
|
||
connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [],
|
||
};
|
||
state.activeSession = await loadSessionMaterial();
|
||
state.walletProfile = state.activeSession?.walletProfile || null;
|
||
state.signing = {
|
||
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
|
||
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
|
||
};
|
||
state.currentWallet = state.activeSession?.currentWallet || null;
|
||
}
|
||
|
||
async function persistSettings(nextSettings = {}) {
|
||
state.settings = {
|
||
...state.settings,
|
||
...nextSettings,
|
||
};
|
||
if (!Array.isArray(state.settings.connectedOrigins)) {
|
||
state.settings.connectedOrigins = [];
|
||
}
|
||
await savePluginSettings(state.settings);
|
||
return state.settings;
|
||
}
|
||
|
||
function isOriginApproved(origin) {
|
||
const normalized = normalizeOrigin(origin);
|
||
return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized);
|
||
}
|
||
|
||
async function setOriginApproved(origin, approved) {
|
||
const normalized = normalizeOrigin(origin);
|
||
const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []);
|
||
if (approved) {
|
||
if (normalized) current.add(normalized);
|
||
} else if (normalized) {
|
||
current.delete(normalized);
|
||
}
|
||
await persistSettings({ connectedOrigins: [...current] });
|
||
}
|
||
|
||
async function resolveServerForLogin(login) {
|
||
const cleanLogin = String(login || state.settings.login || '').trim();
|
||
if (!cleanLogin) {
|
||
state.settings = {
|
||
...state.settings,
|
||
login: '',
|
||
serverLogin: '',
|
||
};
|
||
await savePluginSettings(state.settings);
|
||
return { ok: true, resolved: false };
|
||
}
|
||
|
||
const resolved = await resolveShineServerByUserLogin(cleanLogin);
|
||
state.settings = {
|
||
...state.settings,
|
||
login: cleanLogin,
|
||
serverLogin: resolved.serverLogin,
|
||
serverHttp: resolved.serverHttp,
|
||
serverUrl: resolved.serverUrl,
|
||
};
|
||
await savePluginSettings(state.settings);
|
||
return { ok: true, resolved: true, ...resolved };
|
||
}
|
||
|
||
async function saveActiveSessionRecord() {
|
||
if (!state.activeSession) return;
|
||
const nextRecord = {
|
||
...state.activeSession,
|
||
walletProfile: state.walletProfile,
|
||
selectedDeviceName: state.signing.selectedDeviceName,
|
||
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
|
||
currentWallet: state.currentWallet,
|
||
};
|
||
state.activeSession = nextRecord;
|
||
await saveSessionMaterial(nextRecord);
|
||
}
|
||
|
||
function shortKey(value = '', size = 10) {
|
||
const raw = String(value || '').trim();
|
||
return raw ? raw.slice(0, size) : '';
|
||
}
|
||
|
||
function extractErrorCode(message = '') {
|
||
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
|
||
return match ? String(match[1]).toUpperCase() : '';
|
||
}
|
||
|
||
function toWalletErrorMessage(error, fallback = 'Не удалось выполнить операцию кошелька.') {
|
||
const raw = String(error?.message || '').trim();
|
||
const code = String(error?.code || extractErrorCode(raw) || '').toUpperCase();
|
||
if (code === 'PAIRING_NOT_AVAILABLE') {
|
||
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
|
||
}
|
||
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
|
||
return 'Сейчас нет ни одной онлайн доверенной сессии этого пользователя. Откройте SHiNE на другом уже подключённом устройстве и держите его в сети.';
|
||
}
|
||
if (code === 'PAIRING_PASSWORD_INVALID') {
|
||
return 'Дополнительный пароль подключения не подходит.';
|
||
}
|
||
return raw || fallback;
|
||
}
|
||
|
||
function homeserverSessionNameFromClientInfo(value = '') {
|
||
const raw = String(value || '').trim();
|
||
const match = raw.match(/^ESP32 homeserver:(.+)$/i);
|
||
return match ? String(match[1] || '').trim() : '';
|
||
}
|
||
|
||
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);
|
||
const byName = new Map();
|
||
onlineHomeservers.forEach((item) => {
|
||
const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient);
|
||
if (sessionName) {
|
||
byName.set(sessionName, item);
|
||
}
|
||
});
|
||
|
||
return published.map((item) => {
|
||
const matched = byName.get(String(item?.sessionName || '').trim()) || null;
|
||
let onlineState = matched ? 'online' : 'offline';
|
||
let activeSessionId = matched?.sessionId ? String(matched.sessionId) : '';
|
||
if (!matched && published.length === 1 && onlineHomeservers.length === 1) {
|
||
onlineState = 'online';
|
||
activeSessionId = String(onlineHomeservers[0]?.sessionId || '');
|
||
}
|
||
return {
|
||
...item,
|
||
activeSessionId,
|
||
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 selectedDeviceName = state.signing.selectedDeviceName
|
||
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
|
||
|
||
state.walletProfile = {
|
||
...profile,
|
||
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
|
||
...item,
|
||
onlineState: 'unknown',
|
||
onlineLabel: 'unknown',
|
||
activeSessionId: '',
|
||
})) : [],
|
||
};
|
||
state.signing = {
|
||
...state.signing,
|
||
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,
|
||
serverLogin: sessionRecord.serverLogin,
|
||
serverHttp: sessionRecord.serverHttp,
|
||
serverUrl: sessionRecord.serverUrl,
|
||
});
|
||
state.connectionOnline = false;
|
||
state.currentWallet = null;
|
||
}
|
||
|
||
async function pollPairingStatus() {
|
||
if (!state.pairingId || !state.requesterMaterial) return;
|
||
try {
|
||
const payload = await ensureApi().getTrustedDeviceLoginStatus(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 }) {
|
||
const cleanLogin = String(login || '').trim();
|
||
if (!cleanLogin) {
|
||
throw new Error('Введите логин.');
|
||
}
|
||
|
||
await persistSettings({ login: cleanLogin });
|
||
await resolveServerForLogin(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.startTrustedDeviceLogin({
|
||
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 || '');
|
||
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 || ''),
|
||
expiresAtMs: state.expiresAtMs,
|
||
trustedSessionOnline: !!payload?.trustedSessionOnline,
|
||
};
|
||
}
|
||
|
||
async function cancelPairing() {
|
||
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
|
||
clearPairingState();
|
||
return { ok: true };
|
||
}
|
||
await ensureApi().cancelTrustedDeviceLogin(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 = {
|
||
selectedDeviceName: '',
|
||
devicesResolvedAtMs: 0,
|
||
};
|
||
state.currentWallet = null;
|
||
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({ selectedDeviceName } = {}) {
|
||
state.signing = {
|
||
...state.signing,
|
||
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
|
||
};
|
||
await saveActiveSessionRecord();
|
||
return { ok: true };
|
||
}
|
||
|
||
async function resolveSelectedHomeserverSession() {
|
||
if (!state.activeSession?.login) {
|
||
throw new Error('Сначала подключите wallet-session.');
|
||
}
|
||
if (!state.signing.selectedDeviceName) {
|
||
throw new Error('Не выбрано устройство homeserver.');
|
||
}
|
||
let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
|
||
if (!selectedDevice) {
|
||
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
|
||
}
|
||
if (!selectedDevice.activeSessionId) {
|
||
await refreshWalletDevices();
|
||
selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
|
||
}
|
||
if (!selectedDevice?.activeSessionId) {
|
||
throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.');
|
||
}
|
||
return selectedDevice;
|
||
}
|
||
|
||
async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
|
||
const selectedDevice = await resolveSelectedHomeserverSession();
|
||
const resumed = await resumeActiveSession({ keepConnected: true });
|
||
if (!resumed.ok) {
|
||
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
|
||
}
|
||
|
||
const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
|
||
const callId = `wallet-rpc-${requestId}`;
|
||
const payload = {
|
||
...requestData,
|
||
requestId,
|
||
timeMs: Number(requestData?.timeMs || Date.now()),
|
||
};
|
||
|
||
try {
|
||
const response = await new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
let timeoutId = 0;
|
||
let off = () => {};
|
||
let removeAbortListener = () => {};
|
||
const cleanup = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
off();
|
||
removeAbortListener();
|
||
};
|
||
timeoutId = setTimeout(() => {
|
||
cleanup();
|
||
reject(new Error('Таймаут ответа от ESP32.'));
|
||
}, timeoutMs);
|
||
off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
|
||
const eventPayload = evt?.payload || {};
|
||
if (String(eventPayload?.callId || '') !== callId) return;
|
||
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
|
||
cleanup();
|
||
try {
|
||
resolve(JSON.parse(String(eventPayload?.data || '{}')));
|
||
} catch {
|
||
reject(new Error('ESP32 вернул некорректный JSON.'));
|
||
}
|
||
});
|
||
if (abortSignal) {
|
||
const onAbort = () => {
|
||
cleanup();
|
||
reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Ожидание подписи отменено.'));
|
||
};
|
||
if (abortSignal.aborted) {
|
||
onAbort();
|
||
return;
|
||
}
|
||
abortSignal.addEventListener('abort', onAbort, { once: true });
|
||
removeAbortListener = () => {
|
||
abortSignal.removeEventListener('abort', onAbort);
|
||
};
|
||
}
|
||
ensureApi().callSignalToSession({
|
||
toLogin: state.activeSession.login,
|
||
targetSessionId: selectedDevice.activeSessionId,
|
||
callId,
|
||
type: WALLET_RPC_REQUEST_TYPE,
|
||
data: JSON.stringify(payload),
|
||
}).catch((error) => {
|
||
cleanup();
|
||
reject(error);
|
||
});
|
||
});
|
||
return { response, selectedDevice, requestId };
|
||
} finally {
|
||
ensureApi().close();
|
||
state.api = null;
|
||
state.connectionOnline = false;
|
||
}
|
||
}
|
||
|
||
function verifyWalletAgainstPda(wallet) {
|
||
const type = String(wallet?.type || '').trim();
|
||
const pub = String(wallet?.publicKeyBase58 || '').trim();
|
||
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
|
||
const clientKey = String(
|
||
state.walletProfile?.publicKeys?.clientKeyBase58 || '',
|
||
).trim();
|
||
if (type === 'client.key') {
|
||
return {
|
||
verified: !!clientKey && clientKey === pub,
|
||
verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
|
||
};
|
||
}
|
||
if (type === 'root.key') {
|
||
return {
|
||
verified: !!rootKey && rootKey === pub,
|
||
verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.',
|
||
};
|
||
}
|
||
return {
|
||
verified: null,
|
||
verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.',
|
||
};
|
||
}
|
||
|
||
async function requestCurrentWallet() {
|
||
const { response, selectedDevice, requestId } = await callWalletRpc({
|
||
v: 1,
|
||
operation: 'get_wallet_public_key',
|
||
requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||
});
|
||
if (!response?.ok) {
|
||
throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`);
|
||
}
|
||
state.currentWallet = {
|
||
type: String(response?.wallet?.type || '').trim(),
|
||
publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(),
|
||
homeserverName: selectedDevice.sessionName,
|
||
requestId: String(response?.requestId || requestId),
|
||
timeMs: Number(response?.timeMs || 0),
|
||
...verifyWalletAgainstPda(response?.wallet || {}),
|
||
};
|
||
await saveActiveSessionRecord();
|
||
setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info');
|
||
return { ok: true, wallet: state.currentWallet };
|
||
}
|
||
|
||
async function cancelPendingSiteApproval() {
|
||
clearPendingApproval({
|
||
rejectError: makeCodeError('User canceled request in extension.', 'USER_REJECTED'),
|
||
});
|
||
setStatus('Ожидание подписи отменено в расширении.', 'info');
|
||
return { ok: true };
|
||
}
|
||
|
||
async function markPendingSiteApprovalResolved() {
|
||
clearPendingApproval();
|
||
}
|
||
|
||
async function beginSiteTransactionFlow(payload = {}, sender = null) {
|
||
if (state.pendingApproval) {
|
||
throw makeCodeError('Another signing request is already pending.', 'APPROVAL_ALREADY_PENDING');
|
||
}
|
||
const abortController = new AbortController();
|
||
const pending = makePendingApprovalSnapshot({
|
||
...payload,
|
||
kind: 'sign_transaction',
|
||
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||
createdAtMs: Date.now(),
|
||
});
|
||
const timeoutId = setTimeout(() => {
|
||
clearPendingApproval({
|
||
rejectError: makeCodeError('Signing request timed out in extension.', 'USER_REJECTED'),
|
||
});
|
||
setStatus('Ожидание подписи истекло в расширении.', 'error');
|
||
}, 120000);
|
||
state.pendingApproval = {
|
||
...pending,
|
||
timeoutId,
|
||
abortController,
|
||
};
|
||
setStatus(`Сайт ${pending.origin} запросил подпись. Подтвердите или отмените на доверенном устройстве.`, 'info');
|
||
await openSidePanelForSender(sender);
|
||
return pending;
|
||
}
|
||
|
||
async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
|
||
const normalizedOrigin = normalizeOrigin(origin);
|
||
if (!normalizedOrigin) {
|
||
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
|
||
}
|
||
if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) {
|
||
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
|
||
}
|
||
const result = await requestCurrentWallet();
|
||
const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim();
|
||
if (!publicKeyBase58) {
|
||
throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE');
|
||
}
|
||
if (!isOriginApproved(normalizedOrigin)) {
|
||
await setOriginApproved(normalizedOrigin, true);
|
||
}
|
||
setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info');
|
||
return {
|
||
ok: true,
|
||
publicKeyBase58,
|
||
walletType: String(result?.wallet?.type || '').trim(),
|
||
};
|
||
}
|
||
|
||
async function siteDisconnect({ origin } = {}) {
|
||
const normalizedOrigin = normalizeOrigin(origin);
|
||
setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info');
|
||
return { ok: true };
|
||
}
|
||
|
||
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
|
||
const normalizedOrigin = normalizeOrigin(origin);
|
||
if (!normalizedOrigin) {
|
||
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
|
||
}
|
||
if (!isOriginApproved(normalizedOrigin)) {
|
||
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
|
||
}
|
||
const cleanPub = String(publicKeyBase58 || '').trim();
|
||
const cleanTx = String(transactionBase64 || '').trim();
|
||
if (!cleanPub || !cleanTx) {
|
||
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
|
||
}
|
||
const pending = await beginSiteTransactionFlow({
|
||
origin: normalizedOrigin,
|
||
publicKeyBase58: cleanPub,
|
||
comment: String(comment || '').trim(),
|
||
transactionSummary: transactionSummary || null,
|
||
}, sender);
|
||
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
|
||
try {
|
||
const { response } = await callWalletRpc({
|
||
v: 1,
|
||
operation: 'sign_transaction',
|
||
requestId,
|
||
publicKeyBase58: cleanPub,
|
||
transactionBase64: cleanTx,
|
||
comment: signComment,
|
||
}, 120000, state.pendingApproval?.id === pending.id ? state.pendingApproval.abortController.signal : null);
|
||
if (!response?.ok) {
|
||
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
|
||
if (errorCode === 'REJECTED_BY_USER') {
|
||
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
|
||
}
|
||
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
|
||
}
|
||
setStatus(`Подпись для ${normalizedOrigin} завершена.`, 'info');
|
||
return {
|
||
ok: true,
|
||
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
|
||
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
|
||
signatureBase58: String(response?.signatureBase58 || '').trim(),
|
||
};
|
||
} finally {
|
||
await markPendingSiteApprovalResolved();
|
||
}
|
||
}
|
||
|
||
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.activeSession,
|
||
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
|
||
currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
|
||
pendingApproval: state.pendingApproval ? makePendingApprovalSnapshot(state.pendingApproval) : 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:resolveServerInfo') {
|
||
const result = await resolveServerForLogin(String(message?.payload?.login || '').trim());
|
||
sendResponse({ ok: true, result, 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:requestCurrentWallet') {
|
||
const result = await requestCurrentWallet();
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
if (type === 'wallet:disconnectSession') {
|
||
const result = await disconnectSession();
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
if (type === 'wallet:cancelPendingSiteApproval') {
|
||
const result = await cancelPendingSiteApproval();
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
if (type === 'wallet:siteConnect') {
|
||
const result = await siteConnect(message?.payload || {});
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
if (type === 'wallet:siteDisconnect') {
|
||
const result = await siteDisconnect(message?.payload || {});
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
if (type === 'wallet:siteSignTransaction') {
|
||
const result = await siteSignTransaction(message?.payload || {}, _sender);
|
||
sendResponse({ ok: true, result, state: snapshot() });
|
||
return;
|
||
}
|
||
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
|
||
})().catch((error) => {
|
||
const message = toWalletErrorMessage(error, 'Unknown error');
|
||
setStatus(message, 'error');
|
||
sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() });
|
||
});
|
||
return true;
|
||
});
|
||
|
||
chrome.runtime.onInstalled.addListener(() => {
|
||
void configureSidePanelBehavior();
|
||
});
|
||
|
||
chrome.runtime.onStartup.addListener(() => {
|
||
void configureSidePanelBehavior();
|
||
});
|
||
|
||
void configureSidePanelBehavior();
|
||
|
||
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');
|
||
});
|