1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-25 03:20:23 +01:00
metamask-extension/app/scripts/ui.js
Mark Stacey 9cfa9ba6b0
Fix pre-initialization UI error state capture (#20529)
In the case where an error is thrown in the UI before initialization
has finished, we aren't capturing the application state correctly for
Sentry errors. We had a test case for this, but the test case was
broken due to a mistake in how the `network-store` was setup (it was
not matching the behavior of the real `local-tstore` module).

The pre-initialization state capture logic was updated to rely solely
upon the `localStore` instance used by Sentry to determine whether the
user had opted-in to metrics or not. This simplifies the logic a great
deal, removing the need for the `getMostRecentPersistedState` state
hook. It also ensures that state is captured corretly pre-
initialization in both the background and UI.
2023-08-18 16:32:28 -02:30

440 lines
14 KiB
JavaScript

// Disabled to allow setting up initial state hooks first
// This import sets up global functions required for Sentry to function.
// It must be run first in case an error is thrown later during initialization.
import './lib/setup-initial-state-hooks';
// dev only, "react-devtools" import is skipped in prod builds
import 'react-devtools';
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);
}