1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-25 19:58:11 +01:00
metamask-extension/app/scripts/ui.js
Mark Stacey 507c2cb475
Capture Sentry errors prior to initialization (#20265)
* Capture Sentry errors prior to initialization

Sentry errors captured before/during the wallet initialization are
currently not captured because we don't have the controller state yet
to determine whether the user has consented.

The Sentry setup has been updated to check the persisted state for
whether the user has consented, as a fallback in case the controller
state hasn't been initialized yet. This ensures that we capture errors
during initialization if the user has opted in.

* Always await async check for whether the user has opted in

* Remove unused import

* Update JSDoc return type

* Remove unused driver method

* Fix metametrics controller unit tests

* Fix e2e tests

* Fix e2e test on Firefox

* Start session upon install rather than toggle
2023-07-31 18:49:32 -02:30

438 lines
14 KiB
JavaScript

// dev only, "react-devtools" import is skipped in prod builds
import 'react-devtools';
// This import sets up a global function required for Sentry to function.
// It must be run first in case an error is thrown later during initialization.
import './lib/setup-persisted-state-hook';
import PortStream from 'extension-port-stream';
import browser from 'webextension-polyfill';
import Eth from 'ethjs';
import EthQuery from 'eth-query';
import StreamProvider from 'web3-stream-provider';
import log from 'loglevel';
import launchMetaMaskUi, { updateBackgroundConnection } from '../../ui';
import {
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_POPUP,
PLATFORM_FIREFOX,
} from '../../shared/constants/app';
import { isManifestV3 } from '../../shared/modules/mv3.utils';
import { checkForLastErrorAndLog } from '../../shared/modules/browser-runtime.utils';
import { SUPPORT_LINK } from '../../shared/lib/ui-utils';
import {
getErrorHtml,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
registerDesktopErrorActions,
///: END:ONLY_INCLUDE_IN
} from '../../shared/lib/error-utils';
import ExtensionPlatform from './platforms/extension';
import { setupMultiplex } from './lib/stream-utils';
import { getEnvironmentType, getPlatform } from './lib/util';
import metaRPCClientFactory from './lib/metaRPCClientFactory';
const container = document.getElementById('app-content');
const ONE_SECOND_IN_MILLISECONDS = 1_000;
// Service Worker Keep Alive Message Constants
const WORKER_KEEP_ALIVE_INTERVAL = ONE_SECOND_IN_MILLISECONDS;
const WORKER_KEEP_ALIVE_MESSAGE = 'WORKER_KEEP_ALIVE_MESSAGE';
const ACK_KEEP_ALIVE_WAIT_TIME = 60_000; // 1 minute
const ACK_KEEP_ALIVE_MESSAGE = 'ACK_KEEP_ALIVE_MESSAGE';
// Timeout for initializing phishing warning page.
const PHISHING_WARNING_PAGE_TIMEOUT = ONE_SECOND_IN_MILLISECONDS;
const PHISHING_WARNING_SW_STORAGE_KEY = 'phishing-warning-sw-registered';
let lastMessageReceivedTimestamp = Date.now();
let extensionPort;
let ackTimeoutToDisplayError;
/*
* As long as UI is open it will keep sending messages to service worker
* In service worker as this message is received
* if service worker is inactive it is reactivated and script re-loaded
* Time has been kept to 1000ms but can be reduced for even faster re-activation of service worker
*/
if (isManifestV3) {
// Checking for SW aliveness (or stuckness) flow
// 1. Check if we have an extensionPort, if yes
// 2a. Send a keep alive message to the background via extensionPort
// 2b. Add a listener to it (if not already added)
// 3a. Set a timeout to check if we have received an ACK from background
// 3b. If we have not received an ACK within ACK_KEEP_ALIVE_WAIT_TIME,
// we know the background is stuck or dead
// 4. If we recieve an ACK_KEEP_ALIVE_MESSAGE from the service worker, we know it is alive
const ackKeepAliveListener = (message) => {
if (message.name === ACK_KEEP_ALIVE_MESSAGE) {
lastMessageReceivedTimestamp = Date.now();
clearTimeout(ackTimeoutToDisplayError);
}
};
const keepAliveInterval = setInterval(() => {
browser.runtime.sendMessage({ name: WORKER_KEEP_ALIVE_MESSAGE });
if (extensionPort !== null && extensionPort !== undefined) {
extensionPort.postMessage({ name: WORKER_KEEP_ALIVE_MESSAGE });
if (extensionPort.onMessage.hasListener(ackKeepAliveListener) === false) {
extensionPort.onMessage.addListener(ackKeepAliveListener);
}
}
ackTimeoutToDisplayError = setTimeout(() => {
if (
Date.now() - lastMessageReceivedTimestamp >
ACK_KEEP_ALIVE_WAIT_TIME
) {
clearInterval(keepAliveInterval);
displayCriticalError(
'somethingIsWrong',
new Error("Something's gone wrong. Try reloading the page."),
);
}
}, ACK_KEEP_ALIVE_WAIT_TIME);
}, WORKER_KEEP_ALIVE_INTERVAL);
}
start().catch(log.error);
async function start() {
// create platform global
global.platform = new ExtensionPlatform();
// identify window type (popup, notification)
const windowType = getEnvironmentType();
let isUIInitialised = false;
// setup stream to background
extensionPort = browser.runtime.connect({ name: windowType });
let connectionStream = new PortStream(extensionPort);
const activeTab = await queryCurrentActiveTab(windowType);
let loadPhishingWarningPage;
if (isManifestV3) {
/*
* In case of MV3 the issue of blank screen was very frequent, it is caused by UI initialising before background is ready to send state.
* Code below ensures that UI is rendered only after "CONNECTION_READY" or "startUISync"
* messages are received thus the background is ready, and ensures that streams and
* phishing warning page load only after the "startUISync" message is received.
* In case the UI is already rendered, only update the streams.
*/
const messageListener = async (message) => {
if (message?.data?.method === 'startUISync') {
if (isUIInitialised) {
// Currently when service worker is revived we create new streams
// in later version we might try to improve it by reviving same streams.
updateUiStreams();
} else {
initializeUiWithTab(activeTab);
}
await loadPhishingWarningPage();
}
};
/**
* An error thrown if the phishing warning page takes too long to load.
*/
class PhishingWarningPageTimeoutError extends Error {
constructor() {
super('Timeout failed');
}
}
/**
* Load the phishing warning page temporarily to ensure the service
* worker has been registered, so that the warning page works offline.
*/
loadPhishingWarningPage = async function () {
// Check session storage for whether we've already initialized the phishing warning
// service worker in this browser session and do not attempt to re-initialize if so.
const phishingSWMemoryFetch = await browser.storage.session.get(
PHISHING_WARNING_SW_STORAGE_KEY,
);
if (phishingSWMemoryFetch[PHISHING_WARNING_SW_STORAGE_KEY]) {
return;
}
const currentPlatform = getPlatform();
let iframe;
try {
const extensionStartupPhishingPageUrl = new URL(
process.env.PHISHING_WARNING_PAGE_URL,
);
// The `extensionStartup` hash signals to the phishing warning page that it should not bother
// setting up streams for user interaction. Otherwise this page load would cause a console
// error.
extensionStartupPhishingPageUrl.hash = '#extensionStartup';
iframe = window.document.createElement('iframe');
iframe.setAttribute('src', extensionStartupPhishingPageUrl.href);
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
// Create "deferred Promise" to allow passing resolve/reject to event handlers
let deferredResolve;
let deferredReject;
const loadComplete = new Promise((resolve, reject) => {
deferredResolve = resolve;
deferredReject = reject;
});
// The load event is emitted once loading has completed, even if the loading failed.
// If loading failed we can't do anything about it, so we don't need to check.
iframe.addEventListener('load', deferredResolve);
// This step initiates the page loading.
window.document.body.appendChild(iframe);
// This timeout ensures that this iframe gets cleaned up in a reasonable
// timeframe, and ensures that the "initialization complete" message
// doesn't get delayed too long.
setTimeout(
() => deferredReject(new PhishingWarningPageTimeoutError()),
PHISHING_WARNING_PAGE_TIMEOUT,
);
await loadComplete;
// store a flag in sessions storage that we've already loaded the service worker
// and don't need to try again
if (currentPlatform === PLATFORM_FIREFOX) {
// Firefox does not yet support the storage.session API introduced in MV3
// Tracked here: https://bugzilla.mozilla.org/show_bug.cgi?id=1687778
console.error(
'Firefox does not support required MV3 APIs: Phishing warning page iframe and service worker will reload each page refresh',
);
} else {
browser.storage.session.set({
[PHISHING_WARNING_SW_STORAGE_KEY]: true,
});
}
} catch (error) {
if (error instanceof PhishingWarningPageTimeoutError) {
console.warn(
'Phishing warning page timeout; page not guaranteed to work offline.',
);
} else {
console.error('Failed to initialize phishing warning page', error);
}
} finally {
if (iframe) {
iframe.remove();
}
}
};
// resetExtensionStreamAndListeners takes care to remove listeners from closed streams
// it also creates new streams and attaches event listeners to them
const resetExtensionStreamAndListeners = () => {
extensionPort.onMessage.removeListener(messageListener);
extensionPort.onDisconnect.removeListener(
resetExtensionStreamAndListeners,
);
// message below will try to activate service worker
// in MV3 is likely that reason of stream closing is service worker going in-active
browser.runtime.sendMessage({ name: WORKER_KEEP_ALIVE_MESSAGE });
extensionPort = browser.runtime.connect({ name: windowType });
connectionStream = new PortStream(extensionPort);
extensionPort.onMessage.addListener(messageListener);
extensionPort.onDisconnect.addListener(resetExtensionStreamAndListeners);
};
extensionPort.onMessage.addListener(messageListener);
extensionPort.onDisconnect.addListener(resetExtensionStreamAndListeners);
} else {
initializeUiWithTab(activeTab);
}
function initializeUiWithTab(tab) {
initializeUi(
tab,
connectionStream,
(
err,
store,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
backgroundConnection,
///: END:ONLY_INCLUDE_IN
) => {
if (err) {
// if there's an error, store will be = metamaskState
displayCriticalError(
'troubleStarting',
err,
store,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
backgroundConnection,
///: END:ONLY_INCLUDE_IN
);
return;
}
isUIInitialised = true;
const state = store.getState();
const { metamask: { completedOnboarding } = {} } = state;
if (
!completedOnboarding &&
windowType !== ENVIRONMENT_TYPE_FULLSCREEN
) {
global.platform.openExtensionInBrowser();
}
},
);
}
// Function to update new backgroundConnection in the UI
function updateUiStreams() {
connectToAccountManager(connectionStream, (err, backgroundConnection) => {
if (err) {
displayCriticalError(
'troubleStarting',
err,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
undefined,
backgroundConnection,
///: END:ONLY_INCLUDE_IN
);
return;
}
updateBackgroundConnection(backgroundConnection);
});
}
}
async function queryCurrentActiveTab(windowType) {
// At the time of writing we only have the `activeTab` permission which means
// that this query will only succeed in the popup context (i.e. after a "browserAction")
if (windowType !== ENVIRONMENT_TYPE_POPUP) {
return {};
}
const tabs = await browser.tabs
.query({ active: true, currentWindow: true })
.catch((e) => {
checkForLastErrorAndLog() || log.error(e);
});
const [activeTab] = tabs;
const { id, title, url } = activeTab;
const { origin, protocol } = url ? new URL(url) : {};
if (!origin || origin === 'null') {
return {};
}
return { id, title, origin, protocol, url };
}
function initializeUi(activeTab, connectionStream, cb) {
connectToAccountManager(connectionStream, (err, backgroundConnection) => {
if (err) {
cb(
err,
null,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
backgroundConnection,
///: END:ONLY_INCLUDE_IN
);
return;
}
launchMetaMaskUi(
{
activeTab,
container,
backgroundConnection,
},
cb,
);
});
}
async function displayCriticalError(
errorKey,
err,
metamaskState,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
backgroundConnection,
///: END:ONLY_INCLUDE_IN
) {
const html = await getErrorHtml(
errorKey,
SUPPORT_LINK,
metamaskState,
///: BEGIN:ONLY_INCLUDE_IN(desktop)
err,
///: END:ONLY_INCLUDE_IN
);
container.innerHTML = html;
///: BEGIN:ONLY_INCLUDE_IN(desktop)
registerDesktopErrorActions(backgroundConnection, browser);
///: END:ONLY_INCLUDE_IN
const button = document.getElementById('critical-error-button');
button?.addEventListener('click', (_) => {
browser.runtime.reload();
});
log.error(err.stack);
throw err;
}
/**
* Establishes a connection to the background and a Web3 provider
*
* @param {PortDuplexStream} connectionStream - PortStream instance establishing a background connection
* @param {Function} cb - Called when controller connection is established
*/
function connectToAccountManager(connectionStream, cb) {
const mx = setupMultiplex(connectionStream);
const controllerConnectionStream = mx.createStream('controller');
setupControllerConnection(controllerConnectionStream, cb);
setupWeb3Connection(mx.createStream('provider'));
}
/**
* Establishes a streamed connection to a Web3 provider
*
* @param {PortDuplexStream} connectionStream - PortStream instance establishing a background connection
*/
function setupWeb3Connection(connectionStream) {
const providerStream = new StreamProvider();
providerStream.pipe(connectionStream).pipe(providerStream);
connectionStream.on('error', console.error.bind(console));
providerStream.on('error', console.error.bind(console));
global.ethereumProvider = providerStream;
global.ethQuery = new EthQuery(providerStream);
global.eth = new Eth(providerStream);
}
/**
* Establishes a streamed connection to the background account manager
*
* @param {PortDuplexStream} controllerConnectionStream - PortStream instance establishing a background connection
* @param {Function} cb - Called when the remote account manager connection is established
*/
function setupControllerConnection(controllerConnectionStream, cb) {
const backgroundRPC = metaRPCClientFactory(controllerConnectionStream);
cb(null, backgroundRPC);
}