1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 09:23:21 +01:00

Add friendly error handling when background throws an error before listening for connection (#14461)

* When background port closes, UI should display a user friendly error.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Remove console.log

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

A couple of fixes
    1. Use timeout in metaRPCClientFactory to check if UI can't
       communicate with bg
    2. Refactor locale setup
    3. Fixed wording/capitalization
    4. Fix locales usage so that linting works
    5. Refactor CSS

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

do not simulate errorwq

Refactor loading css

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Remove the onDisconnect event handler in ui as this is handled in
metarpcclientfactory

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Do not throw in bg

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Fix PR comments

Remove unused message 'failedToLoadMessage'

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Move usage of locales to shared/** so that linter can see it.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Do not simulate error.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

metarpc can handle multiple requests, responseHandled should be a map.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

reload metamask button on critical error

Use metamask state (if available) to the locale, else read locale files
manually.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

use constant and numeric separator

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

refactor error utils

remove error simulation

Memoize setupLocale function

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

test cases

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Do not simulate error

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

1. store should be metamask state
2. code refactorings.
Tests: mock setupLocale

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Mock fetchLocale instead
Test setup locale

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

UI/CSS changes.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

Do not simulate failure

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* spell MetaMask correctly

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Rename state to mockStore

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* we should clean up this.responseHandled[id] in the error case.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* Fixed PR comments.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>

* clean up response handled.

Signed-off-by: Akintayo A. Olusegun <akintayo.segun@gmail.com>
This commit is contained in:
Olusegun Akintayo 2022-06-08 00:37:15 +04:00 committed by GitHub
parent 2e1a37b47d
commit 4fa4930c8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 336 additions and 27 deletions

View File

@ -2652,6 +2652,9 @@
"resetWalletWarning": {
"message": "Make sure youre using the correct Secret Recovery Phrase before proceeding. You will not be able to undo this."
},
"restartMetamask": {
"message": "Restart MetaMask"
},
"restore": {
"message": "Restore"
},
@ -2836,6 +2839,9 @@
"sendAmount": {
"message": "Send Amount"
},
"sendBugReport": {
"message": "Send us a bug report."
},
"sendSpecifiedTokens": {
"message": "Send $1",
"description": "Symbol of the specified token"
@ -3109,6 +3115,9 @@
"message": "Connect your wallet directly to your computer. Unlock your Ledger and open the Ethereum app. For more on using your hardware wallet device, $1.",
"description": "$1 represents the `hardwareWalletSupportLinkConversion` localization key"
},
"stillGettingMessage": {
"message": "Still getting this message?"
},
"storePhrase": {
"message": "Store this phrase in a password manager like 1Password."
},
@ -3802,6 +3811,9 @@
"message": "We had trouble connecting to your $1, try reviewing $2 and try again.",
"description": "$1 is the wallet device name; $2 is a link to wallet connection guide"
},
"troubleStarting": {
"message": "MetaMask had trouble starting. This error could be intermittent, so try restarting the extension."
},
"troubleTokenBalances": {
"message": "We had trouble loading your token balances. You can view them ",
"description": "Followed by a link (here) to view token balances"

View File

@ -8,7 +8,10 @@
<link rel="stylesheet" type="text/css" href="./index-rtl.css" title="rtl" disabled>
</head>
<body>
<div id="app-content"><div id="app-loader">Loading...</div></div>
<div id="app-content">
<img class="loading-logo" src="./images/logo/metamask-fox.svg" alt="" />
<img class="loading-spinner" src="./images/spinner.gif" alt="" />
</div>
<div id="popover-content"></div>
<script src="./globalthis.js" type="text/javascript" charset="utf-8"></script>
<script src="./sentry-install.js" type="text/javascript" charset="utf-8"></script>

View File

@ -8,7 +8,10 @@
<link rel="stylesheet" type="text/css" href="./index-rtl.css" title="rtl" disabled>
</head>
<body style="width:357px; height:600px;">
<div id="app-content"></div>
<div id="app-content">
<img class="loading-logo" src="./images/logo/metamask-fox.svg" alt="" />
<img class="loading-spinner" src="./images/spinner.gif" alt="" />
</div>
<div id="popover-content"></div>
<script src="./globalthis.js" type="text/javascript" charset="utf-8"></script>
<script src="./sentry-install.js" type="text/javascript" charset="utf-8"></script>

View File

@ -1,6 +1,7 @@
import { EthereumRpcError } from 'eth-rpc-errors';
import SafeEventEmitter from 'safe-event-emitter';
import createRandomId from '../../../shared/modules/random-id';
import { TEN_SECONDS_IN_MILLISECONDS } from '../../../ui/helpers/constants/critical-error';
class MetaRPCClient {
constructor(connectionStream) {
@ -10,6 +11,23 @@ class MetaRPCClient {
this.requests = new Map();
this.connectionStream.on('data', this.handleResponse.bind(this));
this.connectionStream.on('end', this.close.bind(this));
this.responseHandled = {};
}
send(id, payload, cb) {
this.requests.set(id, cb);
this.connectionStream.write(payload);
this.responseHandled[id] = false;
setTimeout(() => {
if (!this.responseHandled[id] && cb) {
delete this.responseHandled[id];
return cb(new Error('No response from RPC'), null);
}
delete this.responseHandled[id];
// needed for linter to pass
return true;
}, TEN_SECONDS_IN_MILLISECONDS);
}
onNotification(handler) {
@ -34,6 +52,8 @@ class MetaRPCClient {
const isNotification = id === undefined && error === undefined;
const cb = this.requests.get(id);
this.responseHandled[id] = true;
if (method && params && !isNotification) {
// dont handle server-side to client-side requests
return;
@ -79,14 +99,13 @@ const metaRPCClientFactory = (connectionStream) => {
const cb = p[p.length - 1];
const params = p.slice(0, -1);
const id = createRandomId();
object.requests.set(id, cb);
object.connectionStream.write({
const payload = {
jsonrpc: '2.0',
method: property,
params,
id,
});
};
object.send(id, payload, cb);
};
},
});

View File

@ -131,4 +131,21 @@ describe('metaRPCClientFactory', () => {
},
});
});
it('should be able to handle no message within TIMEOUT secs', async () => {
jest.useFakeTimers();
const streamTest = createThoughStream();
const metaRPCClient = metaRPCClientFactory(streamTest);
const errorPromise = new Promise((_resolve, reject) =>
metaRPCClient.foo('bad', (error, _) => {
reject(error);
}),
);
jest.runOnlyPendingTimers();
await expect(errorPromise).rejects.toThrow('No response from RPC');
jest.useRealTimers();
});
});

View File

@ -17,6 +17,8 @@ import {
ENVIRONMENT_TYPE_POPUP,
} from '../../shared/constants/app';
import { isManifestV3 } from '../../shared/modules/mv3.utils';
import { SUPPORT_LINK } from '../../ui/helpers/constants/common';
import { getErrorHtml } from '../../ui/helpers/utils/error-utils';
import ExtensionPlatform from './platforms/extension';
import { setupMultiplex } from './lib/stream-utils';
import { getEnvironmentType } from './lib/util';
@ -25,6 +27,21 @@ import metaRPCClientFactory from './lib/metaRPCClientFactory';
start().catch(log.error);
async function start() {
async function displayCriticalError(container, err, metamaskState) {
const html = await getErrorHtml(SUPPORT_LINK, metamaskState);
container.innerHTML = html;
const button = document.getElementById('critical-error-button');
button.addEventListener('click', (_) => {
browser.runtime.reload();
});
log.error(err.stack);
throw err;
}
// create platform global
global.platform = new ExtensionPlatform();
@ -33,6 +50,7 @@ async function start() {
// setup stream to background
const extensionPort = browser.runtime.connect({ name: windowType });
const connectionStream = new PortStream(extensionPort);
const activeTab = await queryCurrentActiveTab(windowType);
@ -51,19 +69,12 @@ async function start() {
initializeUiWithTab(activeTab);
}
function displayCriticalError(container, err) {
container.innerHTML =
'<div class="critical-error">The MetaMask app failed to load: please open and close MetaMask again to restart.</div>';
container.style.height = '80px';
log.error(err.stack);
throw err;
}
function initializeUiWithTab(tab) {
const container = document.getElementById('app-content');
initializeUi(tab, container, connectionStream, (err, store) => {
if (err) {
displayCriticalError(container, err);
// if there's an error, store will be = metamaskState
displayCriticalError(container, err, store);
return;
}
@ -104,7 +115,7 @@ async function queryCurrentActiveTab(windowType) {
function initializeUi(activeTab, container, connectionStream, cb) {
connectToAccountManager(connectionStream, (err, backgroundConnection) => {
if (err) {
cb(err);
cb(err, null);
return;
}

44
ui/css/errors.scss Normal file
View File

@ -0,0 +1,44 @@
.critical-error {
padding: 16px;
max-width: 600px;
margin: 0 auto;
&__alert {
color: var(--color-text-default);
background-color: var(--color-error-muted);
border: 1px solid var(--color-error-default);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
margin-bottom: 16px;
&__message {
margin-bottom: 16px;
}
&__button {
height: 40px;
border-radius: 20px;
padding-left: 16px;
padding-right: 16px;
background-color: var(--color-primary-default);
color: var(--color-primary-inverse);
border: 1px solid var(--color-primary-default);
margin: 0 auto;
}
}
&__paragraph {
color: var(--color-text-default);
text-align: center;
&__link {
color: var(--color-primary-default);
&:hover {
color: var(--color-primary-alternative);
}
}
}
}

View File

@ -11,6 +11,8 @@
@import '../components/app/app-components';
@import '../components/ui/ui-components';
@import '../pages/pages';
@import './errors.scss';
@import './loading.scss';
/*
ITCSS

13
ui/css/loading.scss Normal file
View File

@ -0,0 +1,13 @@
.loading-logo {
width: 10rem;
height: 10rem;
align-self: center;
margin: 10rem 0 0 0;
}
.loading-spinner {
width: 2rem;
height: 2rem;
align-self: center;
margin-top: 1rem;
}

View File

@ -0,0 +1 @@
export const TEN_SECONDS_IN_MILLISECONDS = 10_000;

View File

@ -0,0 +1,55 @@
import getFirstPreferredLangCode from '../../../app/scripts/lib/get-first-preferred-lang-code';
import { setupLocale } from '../..';
import switchDirection from './switch-direction';
const getLocaleContext = (currentLocaleMessages, enLocaleMessages) => {
return (key) => {
let message = currentLocaleMessages[key]?.message;
if (!message && enLocaleMessages[key]) {
message = enLocaleMessages[key].message;
}
return message;
};
};
export async function getErrorHtml(supportLink, metamaskState) {
let response, preferredLocale;
if (metamaskState?.currentLocale) {
preferredLocale = metamaskState.currentLocale;
response = await setupLocale(metamaskState.currentLocale);
} else {
preferredLocale = await getFirstPreferredLangCode();
response = await setupLocale(preferredLocale);
}
const textDirection = ['ar', 'dv', 'fa', 'he', 'ku'].includes(preferredLocale)
? 'rtl'
: 'auto';
switchDirection(textDirection);
const { currentLocaleMessages, enLocaleMessages } = response;
const t = getLocaleContext(currentLocaleMessages, enLocaleMessages);
return `
<div class="critical-error">
<div class="critical-error__alert">
<p class="critical-error__alert__message">
${t('troubleStarting')}
</p>
<button id='critical-error-button' class="critical-error__alert__button">
${t('restartMetamask')}
</button>
</div>
<p class="critical-error__paragraph">
${t('stillGettingMessage')}
<a
href=${supportLink}
class="critical-error__paragraph__link"
target="_blank"
rel="noopener noreferrer">
${t('sendBugReport')}
</a>
</p>
</div>
`;
}

View File

@ -0,0 +1,49 @@
import { SUPPORT_LINK } from '../constants/common';
import { getErrorHtml } from './error-utils';
import { fetchLocale } from './i18n-helper';
jest.mock('./i18n-helper', () => ({
fetchLocale: jest.fn(),
loadRelativeTimeFormatLocaleData: jest.fn(),
}));
describe('Error utils Tests', () => {
it('should get error html', async () => {
const mockStore = {
localeMessages: {
current: {
troubleStarting: {
message:
'MetaMask had trouble starting. This error could be intermittent, so try restarting the extension.',
},
restartMetamask: {
message: 'Restart MetaMask',
},
stillGettingMessage: {
message: 'Still getting this message?',
},
sendBugReport: {
message: 'Send us a bug report.',
},
},
},
metamask: {
currentLocale: 'en',
},
};
fetchLocale.mockReturnValue(mockStore.localeMessages.current);
const errorHtml = await getErrorHtml(SUPPORT_LINK, mockStore.metamask);
const currentLocale = mockStore.localeMessages.current;
const troubleStartingMessage = currentLocale.troubleStarting.message;
const restartMetamaskMessage = currentLocale.restartMetamask.message;
const stillGettingMessageMessage =
currentLocale.stillGettingMessage.message;
const sendBugReportMessage = currentLocale.sendBugReport.message;
expect(errorHtml).toContain(troubleStartingMessage);
expect(errorHtml).toContain(restartMetamaskMessage);
expect(errorHtml).toContain(stillGettingMessageMessage);
expect(errorHtml).toContain(sendBugReportMessage);
});
});

View File

@ -1,6 +1,6 @@
import copyToClipboard from 'copy-to-clipboard';
import log from 'loglevel';
import { clone } from 'lodash';
import { clone, memoize } from 'lodash';
import React from 'react';
import { render } from 'react-dom';
import browser from 'webextension-polyfill';
@ -36,7 +36,7 @@ export default function launchMetamaskUi(opts, cb) {
// check if we are unlocked first
backgroundConnection.getState(function (err, metamaskState) {
if (err) {
cb(err);
cb(err, metamaskState);
return;
}
startApp(metamaskState, backgroundConnection, opts).then((store) => {
@ -46,21 +46,31 @@ export default function launchMetamaskUi(opts, cb) {
});
}
const _setupLocale = async (currentLocale) => {
const currentLocaleMessages = currentLocale
? await fetchLocale(currentLocale)
: {};
const enLocaleMessages = await fetchLocale('en');
await loadRelativeTimeFormatLocaleData('en');
if (currentLocale) {
await loadRelativeTimeFormatLocaleData(currentLocale);
}
return { currentLocaleMessages, enLocaleMessages };
};
export const setupLocale = memoize(_setupLocale);
async function startApp(metamaskState, backgroundConnection, opts) {
// parse opts
if (!metamaskState.featureFlags) {
metamaskState.featureFlags = {};
}
const currentLocaleMessages = metamaskState.currentLocale
? await fetchLocale(metamaskState.currentLocale)
: {};
const enLocaleMessages = await fetchLocale('en');
await loadRelativeTimeFormatLocaleData('en');
if (metamaskState.currentLocale) {
await loadRelativeTimeFormatLocaleData(metamaskState.currentLocale);
}
const { currentLocaleMessages, enLocaleMessages } = await setupLocale(
metamaskState.currentLocale,
);
if (metamaskState.textDirection === 'rtl') {
await switchDirection('rtl');

70
ui/index.test.js Normal file
View File

@ -0,0 +1,70 @@
import { setupLocale } from '.';
const enMessages = {
troubleStarting: {
message:
'MetaMask had trouble starting. This error could be intermittent, so try restarting the extension.',
},
restartMetamask: {
message: 'Restart MetaMask',
},
stillGettingMessage: {
message: 'Still getting this message?',
},
sendBugReport: {
message: 'Send us a bug report.',
},
};
const esMessages = {
troubleStarting: {
message:
'MetaMask tuvo problemas para iniciarse. Este error podría ser intermitente, así que intente reiniciar la extensión.',
},
restartMetamask: {
message: 'Reiniciar metamáscara',
},
sendBugReport: {
message: 'Envíenos un informe de errores.',
},
};
jest.mock('./helpers/utils/i18n-helper', () => ({
fetchLocale: jest.fn((locale) => (locale === 'en' ? enMessages : esMessages)),
loadRelativeTimeFormatLocaleData: jest.fn(),
}));
describe('Index Tests', () => {
it('should get locale messages by calling setupLocale', async () => {
let result = await setupLocale('en');
const { currentLocaleMessages: clm, enLocaleMessages: elm } = result;
expect(clm).toBeDefined();
expect(elm).toBeDefined();
expect(clm.troubleStarting).toStrictEqual(enMessages.troubleStarting);
expect(clm.restartMetamask).toStrictEqual(enMessages.restartMetamask);
expect(clm.stillGettingMessage).toStrictEqual(
enMessages.stillGettingMessage,
);
expect(clm.sendBugReport).toStrictEqual(enMessages.sendBugReport);
result = await setupLocale('es');
const { currentLocaleMessages: clm2, enLocaleMessages: elm2 } = result;
expect(clm2).toBeDefined();
expect(elm2).toBeDefined();
expect(clm2.troubleStarting).toStrictEqual(esMessages.troubleStarting);
expect(clm2.restartMetamask).toStrictEqual(esMessages.restartMetamask);
expect(clm2.stillGettingMessage).toBeUndefined();
expect(elm2.stillGettingMessage).toStrictEqual(
enMessages.stillGettingMessage,
);
expect(clm2.sendBugReport).toStrictEqual(esMessages.sendBugReport);
});
});