diff --git a/SHiNE-browser-plugin-wallet/background.js b/SHiNE-browser-plugin-wallet/background.js index dd51774..dd23775 100644 --- a/SHiNE-browser-plugin-wallet/background.js +++ b/SHiNE-browser-plugin-wallet/background.js @@ -30,6 +30,7 @@ const state = { devicesResolvedAtMs: 0, }, currentWallet: null, + pendingApproval: null, statusText: '', statusKind: 'info', }; @@ -69,6 +70,41 @@ function setStatus(message = '', kind = 'info') { 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); @@ -506,7 +542,7 @@ async function resolveSelectedHomeserverSession() { return selectedDevice; } -async function callWalletRpc(requestData, timeoutMs = 8000) { +async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) { const selectedDevice = await resolveSelectedHomeserverSession(); const resumed = await resumeActiveSession({ keepConnected: true }); if (!resumed.ok) { @@ -523,22 +559,46 @@ async function callWalletRpc(requestData, timeoutMs = 8000) { try { const response = await new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { + 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); - const off = ensureApi().onEvent('IncomingCallSignal', (evt) => { + 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; - clearTimeout(timeoutId); - off(); + 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, @@ -546,8 +606,7 @@ async function callWalletRpc(requestData, timeoutMs = 8000) { type: WALLET_RPC_REQUEST_TYPE, data: JSON.stringify(payload), }).catch((error) => { - clearTimeout(timeoutId); - off(); + cleanup(); reject(error); }); }); @@ -606,6 +665,45 @@ async function requestCurrentWallet() { 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) { @@ -636,7 +734,7 @@ async function siteDisconnect({ origin } = {}) { return { ok: true }; } -async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment } = {}) { +async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) { const normalizedOrigin = normalizeOrigin(origin); if (!normalizedOrigin) { throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN'); @@ -649,29 +747,40 @@ async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, 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`; - const { response } = await callWalletRpc({ - v: 1, - operation: 'sign_transaction', - requestId, - publicKeyBase58: cleanPub, - transactionBase64: cleanTx, - comment: signComment, - }, 120000); - 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'); + 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'); } - 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(); } - return { - ok: true, - publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(), - signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(), - signatureBase58: String(response?.signatureBase58 || '').trim(), - }; } function snapshot() { @@ -688,6 +797,7 @@ function snapshot() { 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, @@ -749,6 +859,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { 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() }); @@ -760,7 +875,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return; } if (type === 'wallet:siteSignTransaction') { - const result = await siteSignTransaction(message?.payload || {}); + const result = await siteSignTransaction(message?.payload || {}, _sender); sendResponse({ ok: true, result, state: snapshot() }); return; } diff --git a/SHiNE-browser-plugin-wallet/content-script.js b/SHiNE-browser-plugin-wallet/content-script.js index c2ca1f1..8fa7d79 100644 --- a/SHiNE-browser-plugin-wallet/content-script.js +++ b/SHiNE-browser-plugin-wallet/content-script.js @@ -1,5 +1,6 @@ const PAGE_REQUEST = 'shine-wallet-page-request'; const PAGE_RESPONSE = 'shine-wallet-page-response'; +const PAGE_MESSAGE_TARGET_ORIGIN = '*'; function injectProviderBridge() { const root = document.head || document.documentElement; @@ -20,14 +21,21 @@ function respondToPage(id, ok, result, error, code) { result: result || null, error: error ? String(error) : '', code: code ? String(code) : '', - }, window.location.origin); + }, PAGE_MESSAGE_TARGET_ORIGIN); } function sendRuntimeMessage(type, payload = {}) { return new Promise((resolve, reject) => { chrome.runtime.sendMessage({ type, payload }, (response) => { if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed')); + const raw = String(chrome.runtime.lastError.message || 'Runtime message failed'); + if (/Extension context invalidated/i.test(raw)) { + const error = new Error('Расширение было перезагружено или отключено. Обновите страницу и откройте кошелёк заново.'); + error.code = 'EXTENSION_CONTEXT_INVALIDATED'; + reject(error); + return; + } + reject(new Error(raw)); return; } if (!response?.ok) { @@ -71,6 +79,7 @@ window.addEventListener('message', (event) => { publicKeyBase58: String(params?.publicKeyBase58 || '').trim(), transactionBase64: String(params?.transactionBase64 || '').trim(), comment: String(params?.comment || '').trim(), + transactionSummary: params?.transactionSummary || null, }); respondToPage(id, true, response.result || null); return; diff --git a/SHiNE-browser-plugin-wallet/popup.css b/SHiNE-browser-plugin-wallet/popup.css index fe1e0f5..e1b677a 100644 --- a/SHiNE-browser-plugin-wallet/popup.css +++ b/SHiNE-browser-plugin-wallet/popup.css @@ -198,6 +198,12 @@ select { gap: 8px; } +.detail-list { + display: flex; + flex-direction: column; + gap: 8px; +} + .device-row { padding: 8px 10px; border: 1px solid #243446; @@ -205,6 +211,30 @@ select { background: #0d141d; } +.detail-row { + display: grid; + gap: 4px; + padding: 8px 10px; + border: 1px solid #243446; + border-radius: 8px; + background: #0d141d; +} + +.detail-label { + font-size: 12px; + color: #9aabbd; +} + +.detail-value { + color: #e8eef6; + word-break: break-word; +} + +.detail-value.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #bed5f5; +} + .device-state { font-size: 12px; text-transform: lowercase; diff --git a/SHiNE-browser-plugin-wallet/popup.html b/SHiNE-browser-plugin-wallet/popup.html index 773a2ae..41cf9e4 100644 --- a/SHiNE-browser-plugin-wallet/popup.html +++ b/SHiNE-browser-plugin-wallet/popup.html @@ -18,7 +18,6 @@

Сервер SHiNE: —

-

Адрес: —

Подключение
@@ -50,9 +49,6 @@ + +