diff --git a/app/scripts/background.js b/app/scripts/background.js index 6a1ca37d8..ec2aff120 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -15,6 +15,7 @@ import { ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_FULLSCREEN, + EXTENSION_MESSAGES, PLATFORM_FIREFOX, } from '../../shared/constants/app'; import { SECOND } from '../../shared/constants/time'; @@ -25,6 +26,7 @@ import { EVENT_NAMES, TRAITS, } from '../../shared/constants/metametrics'; +import { checkForLastErrorAndLog } from '../../shared/modules/browser-runtime.utils'; import { isManifestV3 } from '../../shared/modules/mv3.utils'; import { maskObject } from '../../shared/modules/object.utils'; import migrations from './migrations'; @@ -94,16 +96,67 @@ const WORKER_KEEP_ALIVE_MESSAGE = 'WORKER_KEEP_ALIVE_MESSAGE'; /** * In case of MV3 we attach a "onConnect" event listener as soon as the application is initialised. * Reason is that in case of MV3 a delay in doing this was resulting in missing first connect event after service worker is re-activated. + * + * @param remotePort */ - const initApp = async (remotePort) => { browser.runtime.onConnect.removeListener(initApp); await initialize(remotePort); log.info('MetaMask initialization complete.'); }; +/** + * Sends a message to the dapp(s) content script to signal it can connect to MetaMask background as + * the backend is not active. It is required to re-connect dapps after service worker re-activates. + * For non-dapp pages, the message will be sent and ignored. + */ +const sendReadyMessageToTabs = async () => { + const tabs = await browser.tabs + .query({ + /** + * Only query tabs that our extension can run in. To do this, we query for all URLs that our + * extension can inject scripts in, which is by using the "" value and __without__ + * the "tabs" manifest permission. If we included the "tabs" permission, this would also fetch + * URLs that we'd not be able to inject in, e.g. chrome://pages, chrome://extension, which + * is not what we'd want. + * + * You might be wondering, how does the "url" param work without the "tabs" permission? + * + * @see {@link https://bugs.chromium.org/p/chromium/issues/detail?id=661311#c1} + * "If the extension has access to inject scripts into Tab, then we can return the url + * of Tab (because the extension could just inject a script to message the location.href)." + */ + url: '', + windowType: 'normal', + }) + .then((result) => { + checkForLastErrorAndLog(); + return result; + }) + .catch(() => { + checkForLastErrorAndLog(); + }); + + /** @todo we should only sendMessage to dapp tabs, not all tabs. */ + for (const tab of tabs) { + browser.tabs + .sendMessage(tab.id, { + name: EXTENSION_MESSAGES.READY, + }) + .then(() => { + checkForLastErrorAndLog(); + }) + .catch(() => { + // An error may happen if the contentscript is blocked from loading, + // and thus there is no runtime.onMessage handler to listen to the message. + checkForLastErrorAndLog(); + }); + } +}; + if (isManifestV3) { browser.runtime.onConnect.addListener(initApp); + sendReadyMessageToTabs(); } else { // initialization flow initialize().catch(log.error); diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index dcaa6c59b..c2bcbad5e 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -5,6 +5,11 @@ import browser from 'webextension-polyfill'; import PortStream from 'extension-port-stream'; import { obj as createThoughStream } from 'through2'; +import { EXTENSION_MESSAGES, MESSAGE_TYPE } from '../../shared/constants/app'; +import { + checkForLastError, + checkForLastErrorAndWarn, +} from '../../shared/modules/browser-runtime.utils'; import { isManifestV3 } from '../../shared/modules/mv3.utils'; import shouldInjectProvider from '../../shared/modules/provider-injection'; @@ -44,9 +49,6 @@ let legacyExtMux, legacyPagePublicConfigChannel, notificationTransformStream; -const WORKER_KEEP_ALIVE_INTERVAL = 1000; -const WORKER_KEEP_ALIVE_MESSAGE = 'WORKER_KEEP_ALIVE_MESSAGE'; - const phishingPageUrl = new URL(process.env.PHISHING_WARNING_PAGE_URL); let phishingExtChannel, @@ -82,6 +84,51 @@ function injectScript(content) { } } +/** + * SERVICE WORKER LOGIC + */ + +const WORKER_KEEP_ALIVE_INTERVAL = 1000; +const WORKER_KEEP_ALIVE_MESSAGE = 'WORKER_KEEP_ALIVE_MESSAGE'; +const TIME_45_MIN_IN_MS = 45 * 60 * 1000; + +/** + * Don't run the keep-worker-alive logic for JSON-RPC methods called on initial load. + * This is to prevent the service worker from being kept alive when accounts are not + * connected to the dapp or when the user is not interacting with the extension. + * The keep-alive logic should not work for non-dapp pages. + */ +const IGNORE_INIT_METHODS_FOR_KEEP_ALIVE = [ + MESSAGE_TYPE.GET_PROVIDER_STATE, + MESSAGE_TYPE.SEND_METADATA, +]; + +let keepAliveInterval; +let keepAliveTimer; + +/** + * Running this method will ensure the service worker is kept alive for 45 minutes. + * The first message is sent immediately and subsequent messages are sent at an + * interval of WORKER_KEEP_ALIVE_INTERVAL. + */ +const runWorkerKeepAliveInterval = () => { + clearTimeout(keepAliveTimer); + + keepAliveTimer = setTimeout(() => { + clearInterval(keepAliveInterval); + }, TIME_45_MIN_IN_MS); + + clearInterval(keepAliveInterval); + + browser.runtime.sendMessage({ name: WORKER_KEEP_ALIVE_MESSAGE }); + + keepAliveInterval = setInterval(() => { + if (browser.runtime.id) { + browser.runtime.sendMessage({ name: WORKER_KEEP_ALIVE_MESSAGE }); + } + }, WORKER_KEEP_ALIVE_INTERVAL); +}; + /** * PHISHING STREAM LOGIC */ @@ -93,6 +140,14 @@ function setupPhishingPageStreams() { target: PHISHING_WARNING_PAGE, }); + if (isManifestV3) { + phishingPageStream.on('data', ({ data: { method } }) => { + if (!IGNORE_INIT_METHODS_FOR_KEEP_ALIVE.includes(method)) { + runWorkerKeepAliveInterval(); + } + }); + } + // create and connect channel muxers // so we can handle the channels individually phishingPageMux = new ObjectMultiplex(); @@ -142,6 +197,9 @@ const setupPhishingExtStreams = () => { error, ), ); + + // eslint-disable-next-line no-use-before-define + phishingExtPort.onDisconnect.addListener(onDisconnectDestroyPhishingStreams); }; /** Destroys all of the phishing extension streams */ @@ -153,19 +211,42 @@ const destroyPhishingExtStreams = () => { phishingExtChannel.removeAllListeners(); phishingExtChannel.destroy(); + + phishingExtStream = null; }; /** - * Resets the extension stream with new streams to channel with the phishing page streams, - * and creates a new event listener to the reestablished extension port. + * This listener destroys the phishing extension streams when the extension port is disconnected, + * so that streams may be re-established later the phishing extension port is reconnected. */ -const resetPhishingStreamAndListeners = () => { - phishingExtPort.onDisconnect.removeListener(resetPhishingStreamAndListeners); +const onDisconnectDestroyPhishingStreams = () => { + checkForLastErrorAndWarn(); + + phishingExtPort.onDisconnect.removeListener( + onDisconnectDestroyPhishingStreams, + ); destroyPhishingExtStreams(); - setupPhishingExtStreams(); +}; - phishingExtPort.onDisconnect.addListener(resetPhishingStreamAndListeners); +/** + * When the extension background is loaded it sends the EXTENSION_MESSAGES.READY message to the browser tabs. + * This listener/callback receives the message to set up the streams after service worker in-activity. + * + * @param {object} msg + * @param {string} msg.name - custom property and name to identify the message received + * @returns {Promise|undefined} + */ +const onMessageSetUpPhishingStreams = (msg) => { + if (msg.name === EXTENSION_MESSAGES.READY) { + if (!phishingExtStream) { + setupPhishingExtStreams(); + } + return Promise.resolve( + `MetaMask: handled "${EXTENSION_MESSAGES.READY}" for phishing streams`, + ); + } + return undefined; }; /** @@ -177,7 +258,7 @@ const initPhishingStreams = () => { setupPhishingPageStreams(); setupPhishingExtStreams(); - phishingExtPort.onDisconnect.addListener(resetPhishingStreamAndListeners); + browser.runtime.onMessage.addListener(onMessageSetUpPhishingStreams); }; /** @@ -191,6 +272,14 @@ const setupPageStreams = () => { target: INPAGE, }); + if (isManifestV3) { + pageStream.on('data', ({ data: { method } }) => { + if (!IGNORE_INIT_METHODS_FOR_KEEP_ALIVE.includes(method)) { + runWorkerKeepAliveInterval(); + } + }); + } + // create and connect channel muxers // so we can handle the channels individually pageMux = new ObjectMultiplex(); @@ -231,7 +320,8 @@ const setupExtensionStreams = () => { extensionPhishingStream = extensionMux.createStream('phishing'); extensionPhishingStream.once('data', redirectToPhishingWarning); - notifyInpageOfExtensionStreamConnect(); + // eslint-disable-next-line no-use-before-define + extensionPort.onDisconnect.addListener(onDisconnectDestroyStreams); }; /** Destroys all of the extension streams */ @@ -243,10 +333,13 @@ const destroyExtensionStreams = () => { extensionChannel.removeAllListeners(); extensionChannel.destroy(); + + extensionStream = null; }; /** * LEGACY STREAM LOGIC + * TODO:LegacyProvider: Delete */ // TODO:LegacyProvider: Delete @@ -256,6 +349,14 @@ const setupLegacyPageStreams = () => { target: LEGACY_INPAGE, }); + if (isManifestV3) { + legacyPageStream.on('data', ({ data: { method } }) => { + if (!IGNORE_INIT_METHODS_FOR_KEEP_ALIVE.includes(method)) { + runWorkerKeepAliveInterval(); + } + }); + } + legacyPageMux = new ObjectMultiplex(); legacyPageMux.setMaxListeners(25); @@ -331,19 +432,47 @@ const destroyLegacyExtensionStreams = () => { }; /** - * Resets the extension stream with new streams to channel with the in page streams, - * and creates a new event listener to the reestablished extension port. + * When the extension background is loaded it sends the EXTENSION_MESSAGES.READY message to the browser tabs. + * This listener/callback receives the message to set up the streams after service worker in-activity. + * + * @param {object} msg + * @param {string} msg.name - custom property and name to identify the message received + * @returns {Promise|undefined} */ -const resetStreamAndListeners = () => { - extensionPort.onDisconnect.removeListener(resetStreamAndListeners); +const onMessageSetUpExtensionStreams = (msg) => { + if (msg.name === EXTENSION_MESSAGES.READY) { + if (!extensionStream) { + setupExtensionStreams(); + setupLegacyExtensionStreams(); + } + return Promise.resolve(`MetaMask: handled ${EXTENSION_MESSAGES.READY}`); + } + return undefined; +}; - destroyExtensionStreams(); - setupExtensionStreams(); +/** + * This listener destroys the extension streams when the extension port is disconnected, + * so that streams may be re-established later when the extension port is reconnected. + */ +const onDisconnectDestroyStreams = () => { + const err = checkForLastError(); + + extensionPort.onDisconnect.removeListener(onDisconnectDestroyStreams); + destroyExtensionStreams(); destroyLegacyExtensionStreams(); - setupLegacyExtensionStreams(); - extensionPort.onDisconnect.addListener(resetStreamAndListeners); + /** + * If an error is found, reset the streams. When running two or more dapps, resetting the service + * worker may cause the error, "Error: Could not establish connection. Receiving end does not + * exist.", due to a race-condition. The disconnect event may be called by runtime.connect which + * may cause issues. We suspect that this is a chromium bug as this event should only be called + * once the port and connections are ready. Delay time is arbitrary. + */ + if (err) { + console.warn(`${err} Resetting the streams.`); + setTimeout(setupExtensionStreams, 1000); + } }; /** @@ -353,13 +482,12 @@ const resetStreamAndListeners = () => { */ const initStreams = () => { setupPageStreams(); - setupExtensionStreams(); - - // TODO:LegacyProvider: Delete setupLegacyPageStreams(); + + setupExtensionStreams(); setupLegacyExtensionStreams(); - extensionPort.onDisconnect.addListener(resetStreamAndListeners); + browser.runtime.onMessage.addListener(onMessageSetUpExtensionStreams); }; // TODO:LegacyProvider: Delete @@ -389,26 +517,6 @@ function logStreamDisconnectWarning(remoteLabel, error) { ); } -/** - * The function send message to inpage to notify it of extension stream connection - */ -function notifyInpageOfExtensionStreamConnect() { - window.postMessage( - { - target: INPAGE, // the post-message-stream "target" - data: { - // this object gets passed to obj-multiplex - name: PROVIDER, // the obj-multiplex channel name - data: { - jsonrpc: '2.0', - method: 'METAMASK_EXTENSION_STREAM_CONNECT', - }, - }, - }, - window.location.origin, - ); -} - /** * This function must ONLY be called in pump destruction/close callbacks. * Notifies the inpage context that streams have failed, via window.postMessage. @@ -446,12 +554,6 @@ function redirectToPhishingWarning(data = {}) { window.location.href = `${baseUrl}#${querystring}`; } -const initKeepWorkerAlive = () => { - setInterval(() => { - browser.runtime.sendMessage({ name: WORKER_KEEP_ALIVE_MESSAGE }); - }, WORKER_KEEP_ALIVE_INTERVAL); -}; - const start = () => { const isDetectedPhishingSite = window.location.origin === phishingPageUrl.origin && @@ -463,9 +565,7 @@ const start = () => { } if (shouldInjectProvider()) { - if (isManifestV3) { - initKeepWorkerAlive(); - } else { + if (!isManifestV3) { injectScript(inpageBundle); } initStreams(); diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index c3f761a6c..58c0b3c89 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -96,6 +96,7 @@ function BnMultiplyByFraction(targetBN, numerator, denominator) { * Returns an Error if extension.runtime.lastError is present * this is a workaround for the non-standard error object that's used * + * @deprecated use checkForLastError in shared/modules/browser-runtime.utils.js * @returns {Error|undefined} */ function checkForError() { diff --git a/shared/constants/app.ts b/shared/constants/app.ts index 496d48434..8ebf53cba 100644 --- a/shared/constants/app.ts +++ b/shared/constants/app.ts @@ -57,6 +57,13 @@ export const MESSAGE_TYPE = { ///: END:ONLY_INCLUDE_IN } as const; +/** + * Custom messages to send and be received by the extension + */ +export const EXTENSION_MESSAGES = { + READY: 'METAMASK_EXTENSION_READY', +} as const; + /** * The different kinds of subjects that MetaMask may interact with, including * third parties and itself (e.g. when the background communicated with the UI). diff --git a/shared/modules/browser-runtime.utils.js b/shared/modules/browser-runtime.utils.js new file mode 100644 index 000000000..8f7b2afe7 --- /dev/null +++ b/shared/modules/browser-runtime.utils.js @@ -0,0 +1,55 @@ +/** + * Utility Functions to support browser.runtime JavaScript API + */ + +import browser from 'webextension-polyfill'; +import log from 'loglevel'; + +/** + * Returns an Error if extension.runtime.lastError is present + * this is a workaround for the non-standard error object that's used + * + * According to the docs, we are expected to check lastError in runtime API callbacks: + * " + * If you call an asynchronous function that may set lastError, you are expected to + * check for the error when you handle the result of the function. If lastError has been + * set and you don't check it within the callback function, then an error will be raised. + * " + * + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/lastError} + * @returns {Error|undefined} + */ +export function checkForLastError() { + const { lastError } = browser.runtime; + if (!lastError) { + return undefined; + } + // if it quacks like an Error, its an Error + if (lastError.stack && lastError.message) { + return lastError; + } + // repair incomplete error object (eg chromium v77) + return new Error(lastError.message); +} + +/** @returns {Error|undefined} */ +export function checkForLastErrorAndLog() { + const error = checkForLastError(); + + if (error) { + log.error(error); + } + + return error; +} + +/** @returns {Error|undefined} */ +export function checkForLastErrorAndWarn() { + const error = checkForLastError(); + + if (error) { + console.warn(error); + } + + return error; +}