mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 09:57:02 +01:00
20e16d41be
* Improve Sentry state pre-initialization Previously the masked state snapshot sent to Sentry would be blank for errors that occured during initialization. Instead we'll now include some basic information in all cases, and a masked copy of the persisted state if it happens after the first time the persisted state is read. * Add test * Fix crash when persisted state not yet fetched * Add descriptions for initial state hooks * Update comments to reflect recent changes * Re-order imports to follow conventions * Move initial state hooks back to module-level The initial state hooks are now setup at the top-level of their module. This ensures that they're setup prior to later imports. Calling a function to setup these hooks in the entrypoint module wouldn't accomplish this even if it was run "before" the imports because ES6 imports always get hoisted to the top of the file. The `localStore` instance wasn't available statically, so a new state hook was introduced for retrieving the most recent retrieved persisted state. * Fix error e2e tests
448 lines
15 KiB
JavaScript
448 lines
15 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';
|
|
import LocalStore from './lib/local-store';
|
|
import ReadOnlyNetworkStore from './lib/network-store';
|
|
|
|
// Setup global hook for improved Sentry state snapshots during initialization
|
|
const inTest = process.env.IN_TEST;
|
|
const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore();
|
|
global.stateHooks.getMostRecentPersistedState = () =>
|
|
localStore.mostRecentRetrievedState;
|
|
|
|
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);
|
|
}
|