From bde74756d370a6e574c680f01cd1f902529e0c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Tavares?= Date: Thu, 23 Feb 2023 16:39:48 +0000 Subject: [PATCH] Add extension desktop UI (#17748) * feat: add desktop enable button component This component will be added to the experimental page. Users will then be able to initialize a desktop connection * feat: add desktop pairing page * feat: add desktop deep-linking shared lib * test: add initial entries to render helper Allow specifying initialEntries for MemoryRouter. This change will allow testing pages that use the useParam hook. * feat: add desktop error page Error page for any desktop pairing related issue * feat: add desktop routes to route component * feat: add enable desktop button to experimental tab * feat: add desktop icon when paired in dev mode * feat: disable ledger live control when desktop enabled * feat: register desktop error actions on ui init * fix: add missing code fencing * chore: remove enable desktop rpc middleware Now that we are adding the UI there's no need for this rpc middleware (as it was used to test desktop background code) * fix: display experimental tab for desktop --- app/_locales/en/messages.json | 99 +++++ app/images/logo/desktop.svg | 1 + .../handlers/desktop/enable-desktop.js | 43 -- .../rpc-method-middleware/handlers/index.js | 7 - app/scripts/ui.js | 94 ++++- shared/constants/desktop.ts | 9 + shared/lib/deep-linking.js | 20 + shared/lib/deep-linking.test.js | 88 +++++ shared/lib/error-utils.js | 90 ++++- test/data/mock-state.json | 3 +- test/jest/rendering.js | 4 +- .../app/app-header/app-header.component.js | 18 + .../app/app-header/app-header.container.js | 12 +- .../app/app-header/app-header.test.js | 34 ++ ...sktop-enable-button.component.test.js.snap | 13 + .../desktop-enable-button.component.js | 96 +++++ .../desktop-enable-button.component.test.js | 232 +++++++++++ .../app/desktop-enable-button/index.js | 1 + ui/components/ui/icon/icon-desktop-pairing.js | 83 ++++ ui/components/ui/icon/icon-times.js | 49 +++ .../ui/metafox-logo/metafox-logo.component.js | 51 ++- ui/ducks/metamask/metamask.js | 9 + ui/ducks/metamask/metamask.test.js | 11 + ui/helpers/constants/routes.ts | 8 + ui/hooks/desktopHooks.js | 14 + ui/hooks/desktopHooks.test.js | 27 ++ ui/index.js | 31 +- .../__snapshots__/desktop-error.test.js.snap | 373 ++++++++++++++++++ .../desktop-error/desktop-error.component.js | 29 ++ .../desktop-error/desktop-error.container.js | 20 + ui/pages/desktop-error/desktop-error.test.js | 127 ++++++ ui/pages/desktop-error/index.js | 1 + .../desktop-error/render-desktop-error.js | 237 +++++++++++ .../desktop-pairing.test.js.snap | 172 ++++++++ .../desktop-pairing.component.js | 190 +++++++++ .../desktop-pairing.container.js | 24 ++ .../desktop-pairing/desktop-pairing.test.js | 110 ++++++ ui/pages/desktop-pairing/index.js | 1 + ui/pages/desktop-pairing/index.scss | 128 ++++++ ui/pages/pages.scss | 1 + ui/pages/routes/routes.component.js | 63 +++ .../advanced-tab/advanced-tab.component.js | 18 +- .../advanced-tab.component.test.js | 17 + .../advanced-tab/advanced-tab.container.js | 6 + .../experimental-tab.test.js.snap | 37 ++ .../experimental-tab.component.js | 31 ++ .../experimental-tab/experimental-tab.test.js | 10 +- ui/pages/settings/settings.component.js | 16 +- ui/selectors/selectors.js | 12 + ui/selectors/selectors.test.js | 5 + ui/store/actionConstants.ts | 4 + ui/store/actions.test.js | 24 ++ ui/store/actions.ts | 24 ++ 53 files changed, 2737 insertions(+), 90 deletions(-) create mode 100644 app/images/logo/desktop.svg delete mode 100644 app/scripts/lib/rpc-method-middleware/handlers/desktop/enable-desktop.js create mode 100644 shared/constants/desktop.ts create mode 100644 shared/lib/deep-linking.js create mode 100644 shared/lib/deep-linking.test.js create mode 100644 ui/components/app/desktop-enable-button/__snapshots__/desktop-enable-button.component.test.js.snap create mode 100644 ui/components/app/desktop-enable-button/desktop-enable-button.component.js create mode 100644 ui/components/app/desktop-enable-button/desktop-enable-button.component.test.js create mode 100644 ui/components/app/desktop-enable-button/index.js create mode 100644 ui/components/ui/icon/icon-desktop-pairing.js create mode 100644 ui/components/ui/icon/icon-times.js create mode 100644 ui/hooks/desktopHooks.js create mode 100644 ui/hooks/desktopHooks.test.js create mode 100644 ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap create mode 100644 ui/pages/desktop-error/desktop-error.component.js create mode 100644 ui/pages/desktop-error/desktop-error.container.js create mode 100644 ui/pages/desktop-error/desktop-error.test.js create mode 100644 ui/pages/desktop-error/index.js create mode 100644 ui/pages/desktop-error/render-desktop-error.js create mode 100644 ui/pages/desktop-pairing/__snapshots__/desktop-pairing.test.js.snap create mode 100644 ui/pages/desktop-pairing/desktop-pairing.component.js create mode 100644 ui/pages/desktop-pairing/desktop-pairing.container.js create mode 100644 ui/pages/desktop-pairing/desktop-pairing.test.js create mode 100644 ui/pages/desktop-pairing/index.js create mode 100644 ui/pages/desktop-pairing/index.scss create mode 100644 ui/pages/settings/experimental-tab/__snapshots__/experimental-tab.test.js.snap diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 3fe91b173..2fa054abf 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1002,6 +1002,105 @@ "description": { "message": "Description" }, + "desktopConnectionCriticalErrorDescription": { + "message": "This error could be intermittent, so try restarting the extension or disable MetaMask Desktop." + }, + "desktopConnectionCriticalErrorTitle": { + "message": "MetaMask had trouble starting" + }, + "desktopConnectionLostErrorDescription": { + "message": "Please make sure you have the desktop app up and running or disable MetaMask Desktop." + }, + "desktopConnectionLostErrorTitle": { + "message": "MetaMask Desktop connection was lost" + }, + "desktopDisableButton": { + "message": "Disable Desktop App" + }, + "desktopDisableErrorCTA": { + "message": "Disable MetaMask Desktop" + }, + "desktopEnableButton": { + "message": "Enable Desktop App" + }, + "desktopEnableButtonDescription": { + "message": "Click to run all background processes in the desktop app." + }, + "desktopErrorNavigateSettingsCTA": { + "message": "Return to Settings Page" + }, + "desktopErrorRestartMMCTA": { + "message": "Restart MetaMask" + }, + "desktopNotFoundErrorCTA": { + "message": "Download MetaMask Desktop" + }, + "desktopNotFoundErrorDescription1": { + "message": "Please make sure you have the desktop app up and running." + }, + "desktopNotFoundErrorDescription2": { + "message": "If you have no desktop app installed, please download it on the MetaMask website." + }, + "desktopNotFoundErrorTitle": { + "message": "MetaMask Desktop was not found" + }, + "desktopOpenOrDownloadCTA": { + "message": "Open MetaMask Desktop" + }, + "desktopOutdatedErrorCTA": { + "message": "Update MetaMask Desktop" + }, + "desktopOutdatedErrorDescription": { + "message": "Your MetaMask desktop app needs to be upgraded." + }, + "desktopOutdatedErrorTitle": { + "message": "MetaMask Desktop is outdated" + }, + "desktopOutdatedExtensionErrorCTA": { + "message": "Update MetaMask Extension" + }, + "desktopOutdatedExtensionErrorDescription": { + "message": "Your MetaMask extension needs to be upgraded." + }, + "desktopOutdatedExtensionErrorTitle": { + "message": "MetaMask Extension is outdated" + }, + "desktopPageDescription": { + "message": "If the pairing is successful, extension will restart and you'll have to re-enter your password." + }, + "desktopPageSubTitle": { + "message": "Open your MetaMask Desktop and type this code" + }, + "desktopPageTitle": { + "message": "Pair with Desktop" + }, + "desktopPairedWarningDeepLink": { + "message": "Go to Settings in MetaMask Desktop" + }, + "desktopPairedWarningDescription": { + "message": "If you want to start a new pairing, please remove the current connection." + }, + "desktopPairedWarningTitle": { + "message": "MM Desktop is already paired" + }, + "desktopPairingExpireMessage": { + "message": "Code expires in $1 seconds" + }, + "desktopRouteNotFoundErrorDescription": { + "message": "desktopRouteNotFoundErrorDescription" + }, + "desktopRouteNotFoundErrorTitle": { + "message": "desktopRouteNotFoundErrorTitle" + }, + "desktopUnexpectedErrorCTA": { + "message": "Return MetaMask Home" + }, + "desktopUnexpectedErrorDescription": { + "message": "Check your MetaMask Desktop to restore connection" + }, + "desktopUnexpectedErrorTitle": { + "message": "Something went wrong..." + }, "details": { "message": "Details" }, diff --git a/app/images/logo/desktop.svg b/app/images/logo/desktop.svg new file mode 100644 index 000000000..62e101002 --- /dev/null +++ b/app/images/logo/desktop.svg @@ -0,0 +1 @@ + diff --git a/app/scripts/lib/rpc-method-middleware/handlers/desktop/enable-desktop.js b/app/scripts/lib/rpc-method-middleware/handlers/desktop/enable-desktop.js deleted file mode 100644 index 37bd152e9..000000000 --- a/app/scripts/lib/rpc-method-middleware/handlers/desktop/enable-desktop.js +++ /dev/null @@ -1,43 +0,0 @@ -import { MESSAGE_TYPE } from '../../../../../../shared/constants/app'; - -/** - * A wrapper for `eth_accounts` that returns an empty array when permission is denied. - */ - -const requestEthereumAccounts = { - methodNames: [MESSAGE_TYPE.ENABLE_DESKTOP], - implementation: enableDesktop, - hookNames: { - testDesktopConnection: true, - generateOtp: true, - }, -}; -export default requestEthereumAccounts; - -/** - * @typedef {Record} EthAccountsOptions - * @property {Function} getAccounts - Gets the accounts for the requesting - * origin. - */ - -/** - * - * @param {import('json-rpc-engine').JsonRpcRequest} _req - The JSON-RPC request object. - * @param {import('json-rpc-engine').JsonRpcResponse} res - The JSON-RPC response object. - * @param {Function} _next - The json-rpc-engine 'next' callback. - * @param {Function} end - The json-rpc-engine 'end' callback. - * @param {EthAccountsOptions} options - The RPC method hooks. - */ -async function enableDesktop( - _req, - res, - _next, - end, - { testDesktopConnection, generateOtp }, -) { - const testResult = await testDesktopConnection(); - const otp = testResult.isConnected ? await generateOtp() : undefined; - - res.result = { ...testResult, otp }; - return end(); -} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/index.js b/app/scripts/lib/rpc-method-middleware/handlers/index.js index 5f502fde5..9b0fc02fe 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/index.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/index.js @@ -7,10 +7,6 @@ import sendMetadata from './send-metadata'; import switchEthereumChain from './switch-ethereum-chain'; import watchAsset from './watch-asset'; -///: BEGIN:ONLY_INCLUDE_IN(desktop) -import enableDesktop from './desktop/enable-desktop'; -///: END:ONLY_INCLUDE_IN - const handlers = [ addEthereumChain, ethAccounts, @@ -20,8 +16,5 @@ const handlers = [ sendMetadata, switchEthereumChain, watchAsset, - ///: BEGIN:ONLY_INCLUDE_IN(desktop) - enableDesktop, - ///: END:ONLY_INCLUDE_IN ]; export default handlers; diff --git a/app/scripts/ui.js b/app/scripts/ui.js index b6753d567..148ce0397 100644 --- a/app/scripts/ui.js +++ b/app/scripts/ui.js @@ -20,7 +20,12 @@ import { 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 } from '../../shared/lib/error-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'; @@ -253,28 +258,55 @@ async function start() { } function initializeUiWithTab(tab) { - initializeUi(tab, connectionStream, (err, store) => { - if (err) { - // if there's an error, store will be = metamaskState - displayCriticalError('troubleStarting', err, store); - return; - } - isUIInitialised = true; + 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; + const state = store.getState(); + const { metamask: { completedOnboarding } = {} } = state; - if (!completedOnboarding && windowType !== ENVIRONMENT_TYPE_FULLSCREEN) { - global.platform.openExtensionInBrowser(); - } - }); + 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); + displayCriticalError( + 'troubleStarting', + err, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + undefined, + backgroundConnection, + ///: END:ONLY_INCLUDE_IN + ); return; } @@ -310,7 +342,13 @@ async function queryCurrentActiveTab(windowType) { function initializeUi(activeTab, connectionStream, cb) { connectToAccountManager(connectionStream, (err, backgroundConnection) => { if (err) { - cb(err, null); + cb( + err, + null, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + backgroundConnection, + ///: END:ONLY_INCLUDE_IN + ); return; } @@ -325,14 +363,32 @@ function initializeUi(activeTab, connectionStream, cb) { }); } -async function displayCriticalError(errorKey, err, metamaskState) { - const html = await getErrorHtml(errorKey, SUPPORT_LINK, metamaskState); +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', (_) => { + button?.addEventListener('click', (_) => { browser.runtime.reload(); }); diff --git a/shared/constants/desktop.ts b/shared/constants/desktop.ts new file mode 100644 index 000000000..9eca08b32 --- /dev/null +++ b/shared/constants/desktop.ts @@ -0,0 +1,9 @@ +export const EXTENSION_ERROR_PAGE_TYPES = { + NOT_FOUND: 'not-found', + CONNECTION_LOST: 'connection-lost', + DESKTOP_OUTDATED: 'desktop-outdated', + EXTENSION_OUTDATED: 'extension-outdated', + CRITICAL_ERROR: 'critical-error', + ROUTE_NOT_FOUND: 'route-not-found', + PAIRING_KEY_NOT_MATCH: 'pairing-key-not-match', +}; diff --git a/shared/lib/deep-linking.js b/shared/lib/deep-linking.js new file mode 100644 index 000000000..2afee5e63 --- /dev/null +++ b/shared/lib/deep-linking.js @@ -0,0 +1,20 @@ +export function openCustomProtocol(protocolLink) { + return new Promise((resolve, reject) => { + // msLaunchUri is windows specific. It will open and app or service + // that handles a given protocol + if (window?.navigator?.msLaunchUri) { + window.navigator.msLaunchUri(protocolLink, resolve, () => { + reject(new Error('Failed to open custom protocol link')); + }); + } else { + const timeoutId = window.setTimeout(function () { + reject(new Error('Timeout opening custom protocol link')); + }, 500); + window.addEventListener('blur', function () { + window.clearTimeout(timeoutId); + resolve(); + }); + window.location = protocolLink; + } + }); +} diff --git a/shared/lib/deep-linking.test.js b/shared/lib/deep-linking.test.js new file mode 100644 index 000000000..e8ffe1d37 --- /dev/null +++ b/shared/lib/deep-linking.test.js @@ -0,0 +1,88 @@ +import { openCustomProtocol } from './deep-linking'; + +describe('#openCustomProtocol', () => { + describe('with msLaunchUri available', () => { + const mockMsLaunchUri = jest.fn(); + let windowSpy; + + beforeEach(() => { + windowSpy = jest + .spyOn(global, 'window', 'get') + .mockImplementation(() => ({ + navigator: { + msLaunchUri: mockMsLaunchUri, + }, + })); + }); + + afterEach(() => { + windowSpy.mockRestore(); + mockMsLaunchUri.mockRestore(); + }); + + it('successfully open when protocol found', async () => { + mockMsLaunchUri.mockImplementation((_protocol, cb) => cb()); + + await openCustomProtocol('TEST PROTOCOL'); + + expect(mockMsLaunchUri).toHaveBeenCalledTimes(1); + }); + + it('throws when protocol not found', async () => { + mockMsLaunchUri.mockImplementation((_protocol, _cb, errorCb) => + errorCb(), + ); + + await expect(openCustomProtocol('TEST PROTOCOL')).rejects.toThrow( + 'Failed to open custom protocol link', + ); + expect(mockMsLaunchUri).toHaveBeenCalledTimes(1); + }); + }); + + describe('without msLaunchUri available', () => { + it('successfully open when protocol found', async () => { + // eslint-disable-next-line consistent-return + const mockAddEventListener = jest.fn().mockImplementation((event, cb) => { + if (event === 'blur') { + return cb(); + } + }); + const clearTimeoutMock = jest.fn(); + + const windowSpy = jest + .spyOn(global, 'window', 'get') + .mockImplementation(() => ({ + addEventListener: mockAddEventListener, + setTimeout: jest.fn(), + clearTimeout: clearTimeoutMock, + })); + + await openCustomProtocol('TEST PROTOCOL'); + + expect(mockAddEventListener).toHaveBeenCalledTimes(1); + expect(clearTimeoutMock).toHaveBeenCalledTimes(1); + + windowSpy.mockRestore(); + mockAddEventListener.mockRestore(); + clearTimeoutMock.mockRestore(); + }); + + it('throws when protocol not found', async () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + const openCustomProtocolPromise = openCustomProtocol('TEST PROTOCOL'); + + jest.advanceTimersByTime(500); + + await expect(openCustomProtocolPromise).rejects.toThrow( + 'Timeout opening custom protocol link', + ); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/shared/lib/error-utils.js b/shared/lib/error-utils.js index 0dc11e91c..3eb55be3c 100644 --- a/shared/lib/error-utils.js +++ b/shared/lib/error-utils.js @@ -1,9 +1,17 @@ +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import browser from 'webextension-polyfill'; +///: END:ONLY_INCLUDE_IN import { memoize } from 'lodash'; import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code'; import { fetchLocale, loadRelativeTimeFormatLocaleData, } from '../../ui/helpers/utils/i18n-helper'; +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop'; +import { openCustomProtocol } from './deep-linking'; +///: END:ONLY_INCLUDE_IN import switchDirection from './switch-direction'; const _setupLocale = async (currentLocale) => { @@ -32,7 +40,14 @@ const getLocaleContext = (currentLocaleMessages, enLocaleMessages) => { }; }; -export async function getErrorHtml(errorKey, supportLink, metamaskState) { +export async function getErrorHtml( + errorKey, + supportLink, + metamaskState, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + err, + ///: END:ONLY_INCLUDE_IN +) { let response, preferredLocale; if (metamaskState?.currentLocale) { preferredLocale = metamaskState.currentLocale; @@ -50,6 +65,23 @@ export async function getErrorHtml(errorKey, supportLink, metamaskState) { const { currentLocaleMessages, enLocaleMessages } = response; const t = getLocaleContext(currentLocaleMessages, enLocaleMessages); + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + const isDesktopEnabled = metamaskState?.desktopEnabled === true; + + if (isDesktopEnabled) { + let errorType = EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR; + + if (err?.message.includes('No response from RPC')) { + errorType = EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST; + } + + return renderDesktopError({ + type: errorType, + t, + isHtmlError: true, + }); + } + ///: END:ONLY_INCLUDE_IN /** * The pattern ${errorKey === 'troubleStarting' ? t('troubleStarting') : ''} * is neccessary because we we need linter to see the string @@ -87,3 +119,59 @@ export async function getErrorHtml(errorKey, supportLink, metamaskState) { `; } + +///: BEGIN:ONLY_INCLUDE_IN(desktop) +function disableDesktop(backgroundConnection) { + backgroundConnection.disableDesktopError(); +} + +export function downloadDesktopApp() { + global.platform.openTab({ url: 'https://metamask.io/' }); +} + +export function downloadExtension() { + global.platform.openTab({ url: 'https://metamask.io/' }); +} + +export function restartExtension() { + browser.runtime.reload(); +} + +export function openOrDownloadMMD() { + openCustomProtocol('metamask-desktop://pair').catch(() => { + window.open('https://metamask.io/download.html', '_blank').focus(); + }); +} + +export function registerDesktopErrorActions(backgroundConnection) { + const disableDesktopButton = document.getElementById( + 'desktop-error-button-disable-mmd', + ); + const restartMMButton = document.getElementById( + 'desktop-error-button-restart-mm', + ); + const downloadMMDButton = document.getElementById( + 'desktop-error-button-download-mmd', + ); + + const openOrDownloadMMDButton = document.getElementById( + 'desktop-error-button-open-or-download-mmd', + ); + + disableDesktopButton?.addEventListener('click', (_) => { + disableDesktop(backgroundConnection); + }); + + restartMMButton?.addEventListener('click', (_) => { + restartExtension(); + }); + + downloadMMDButton?.addEventListener('click', (_) => { + downloadDesktopApp(); + }); + + openOrDownloadMMDButton?.addEventListener('click', (_) => { + openOrDownloadMMD(); + }); +} +///: END:ONLY_INCLUDE_IN diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 183ba6ba2..fb8abf000 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -1439,7 +1439,8 @@ ], "origin": "tmashuang.github.io" } - ] + ], + "desktopEnabled": false }, "send": { "amountMode": "INPUT", diff --git a/test/jest/rendering.js b/test/jest/rendering.js index f84ecfe0b..2c46c306a 100644 --- a/test/jest/rendering.js +++ b/test/jest/rendering.js @@ -33,10 +33,10 @@ I18nProvider.defaultProps = { children: undefined, }; -export function renderWithProvider(component, store) { +export function renderWithProvider(component, store, initialEntries) { const Wrapper = ({ children }) => { const WithoutStore = () => ( - + {children} diff --git a/ui/components/app/app-header/app-header.component.js b/ui/components/app/app-header/app-header.component.js index f5e7d57c5..0bb414e1b 100644 --- a/ui/components/app/app-header/app-header.component.js +++ b/ui/components/app/app-header/app-header.component.js @@ -31,6 +31,9 @@ export default class AppHeader extends PureComponent { showBetaHeader: PropTypes.bool, ///: END:ONLY_INCLUDE_IN onClick: PropTypes.func, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled: PropTypes.bool, + ///: END:ONLY_INCLUDE_IN }; static contextTypes = { @@ -122,6 +125,9 @@ export default class AppHeader extends PureComponent { ///: BEGIN:ONLY_INCLUDE_IN(beta) showBetaHeader, ///: END:ONLY_INCLUDE_IN(beta) + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN } = this.props; return ( @@ -143,6 +149,18 @@ export default class AppHeader extends PureComponent { history.push(DEFAULT_ROUTE); }} /> + { + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled && process.env.METAMASK_DEBUG && ( +
+ +
+ ) + ///: END:ONLY_INCLUDE_IN + }
{!hideNetworkIndicator && (
diff --git a/ui/components/app/app-header/app-header.container.js b/ui/components/app/app-header/app-header.container.js index 04b9c47c4..7bf1bc9a8 100644 --- a/ui/components/app/app-header/app-header.container.js +++ b/ui/components/app/app-header/app-header.container.js @@ -16,7 +16,14 @@ import AppHeader from './app-header.component'; const mapStateToProps = (state) => { const { appState, metamask } = state; const { networkDropdownOpen } = appState; - const { selectedAddress, isUnlocked, isAccountMenuOpen } = metamask; + const { + selectedAddress, + isUnlocked, + isAccountMenuOpen, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN + } = metamask; ///: BEGIN:ONLY_INCLUDE_IN(flask) const unreadNotificationsCount = getUnreadNotificationsCount(state); @@ -37,6 +44,9 @@ const mapStateToProps = (state) => { ///: BEGIN:ONLY_INCLUDE_IN(beta) showBetaHeader, ///: END:ONLY_INCLUDE_IN + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN }; }; diff --git a/ui/components/app/app-header/app-header.test.js b/ui/components/app/app-header/app-header.test.js index c7619aea8..03eacc6b3 100644 --- a/ui/components/app/app-header/app-header.test.js +++ b/ui/components/app/app-header/app-header.test.js @@ -145,4 +145,38 @@ describe('App Header', () => { expect(mockToggleAccountMenu).not.toHaveBeenCalled(); }); }); + + describe('App Header Desktop dev mode Logo', () => { + const tempDebug = process.env.METAMASK_DEBUG; + + beforeEach(() => { + process.env.METAMASK_DEBUG = true; + }); + + afterEach(() => { + process.env.METAMASK_DEBUG = tempDebug; + }); + + it('displays desktop icon when in dev mode', () => { + const desktopEnabledState = { + ...mockState, + metamask: { + ...mockState.metamask, + desktopEnabled: true, + }, + }; + + const desktopEnabledStore = configureMockStore([thunk])( + desktopEnabledState, + ); + const { queryByTestId } = renderWithProvider( + , + desktopEnabledStore, + ); + + const desktopDevLogo = queryByTestId('app-header-desktop-dev-logo'); + + expect(desktopDevLogo).not.toBeNull(); + }); + }); }); diff --git a/ui/components/app/desktop-enable-button/__snapshots__/desktop-enable-button.component.test.js.snap b/ui/components/app/desktop-enable-button/__snapshots__/desktop-enable-button.component.test.js.snap new file mode 100644 index 000000000..de0fd70b8 --- /dev/null +++ b/ui/components/app/desktop-enable-button/__snapshots__/desktop-enable-button.component.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Desktop Enable Button should match snapshot 1`] = ` +
+ +
+`; diff --git a/ui/components/app/desktop-enable-button/desktop-enable-button.component.js b/ui/components/app/desktop-enable-button/desktop-enable-button.component.js new file mode 100644 index 000000000..b4c0b7ef3 --- /dev/null +++ b/ui/components/app/desktop-enable-button/desktop-enable-button.component.js @@ -0,0 +1,96 @@ +import React, { useContext } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import browser from 'webextension-polyfill'; +import { PairingKeyStatus } from '@metamask/desktop/dist/types'; +import { I18nContext } from '../../../contexts/i18n'; +import Button from '../../ui/button'; +import { + DESKTOP_ERROR_ROUTE, + DESKTOP_PAIRING_ROUTE, +} from '../../../helpers/constants/routes'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../../../shared/constants/desktop'; +import { getIsDesktopEnabled } from '../../../selectors'; +import { + hideLoadingIndication, + showLoadingIndication, + setDesktopEnabled as setDesktopEnabledAction, + testDesktopConnection, + disableDesktop, +} from '../../../store/actions'; +import { SECOND } from '../../../../shared/constants/time'; + +const DESKTOP_ERROR_DESKTOP_OUTDATED_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`; +const DESKTOP_ERROR_EXTENSION_OUTDATED_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`; +const DESKTOP_ERROR_NOT_FOUND_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`; +const DESKTOP_ERROR_PAIRING_KEY_NOT_MATCH_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`; +const SKIP_PAIRING_RESTART_DELAY = 2 * SECOND; + +export default function DesktopEnableButton() { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const history = useHistory(); + const showLoader = () => dispatch(showLoadingIndication()); + const hideLoader = () => dispatch(hideLoadingIndication()); + const desktopEnabled = useSelector(getIsDesktopEnabled); + const setDesktopEnabled = (val) => dispatch(setDesktopEnabledAction(val)); + const restart = () => dispatch(browser.runtime.reload()); + + const onClick = async () => { + if (desktopEnabled) { + await disableDesktop(); + setDesktopEnabled(false); + return; + } + + showLoader(); + const testResult = await testDesktopConnection(); + hideLoader(); + + if ([PairingKeyStatus.NO_MATCH].includes(testResult.pairingKeyCheck)) { + history.push(DESKTOP_ERROR_PAIRING_KEY_NOT_MATCH_ROUTE); + return; + } + + if (!testResult.isConnected) { + history.push(DESKTOP_ERROR_NOT_FOUND_ROUTE); + return; + } + + if (!testResult.versionCheck?.isExtensionVersionValid) { + history.push(DESKTOP_ERROR_EXTENSION_OUTDATED_ROUTE); + return; + } + + if (!testResult.versionCheck?.isDesktopVersionValid) { + history.push(DESKTOP_ERROR_DESKTOP_OUTDATED_ROUTE); + return; + } + + if (process.env.SKIP_OTP_PAIRING_FLOW) { + showLoader(); + setDesktopEnabled(true); + + // Wait for new state to persist before restarting + setTimeout(() => { + restart(); + }, SKIP_PAIRING_RESTART_DELAY); + return; + } + + history.push(DESKTOP_PAIRING_ROUTE); + }; + + return ( + + ); +} diff --git a/ui/components/app/desktop-enable-button/desktop-enable-button.component.test.js b/ui/components/app/desktop-enable-button/desktop-enable-button.component.test.js new file mode 100644 index 000000000..a62f76555 --- /dev/null +++ b/ui/components/app/desktop-enable-button/desktop-enable-button.component.test.js @@ -0,0 +1,232 @@ +import React from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import reactRouterDom from 'react-router-dom'; +import thunk from 'redux-thunk'; +import { PairingKeyStatus } from '@metamask/desktop/dist/types'; +import mockState from '../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import { + DESKTOP_ERROR_ROUTE, + DESKTOP_PAIRING_ROUTE, +} from '../../../helpers/constants/routes'; +import actions from '../../../store/actions'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../../../shared/constants/desktop'; +import DesktopEnableButton from './desktop-enable-button.component'; + +const mockHideLoadingIndication = jest.fn(); +const mockShowLoadingIndication = jest.fn(); +const mockSetDesktopEnabled = jest.fn(); + +jest.mock('../../../store/actions', () => { + return { + hideLoadingIndication: () => mockHideLoadingIndication, + showLoadingIndication: () => mockShowLoadingIndication, + setDesktopEnabled: () => mockSetDesktopEnabled, + testDesktopConnection: jest.fn(), + disableDesktop: jest.fn(), + }; +}); + +const mockedActions = actions; + +describe('Desktop Enable Button', () => { + const mockHistoryPush = jest.fn(); + + beforeEach(() => { + jest + .spyOn(reactRouterDom, 'useHistory') + .mockImplementation() + .mockReturnValue({ push: mockHistoryPush }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const store = configureMockStore([thunk])(mockState); + + it('should match snapshot', () => { + const { container } = renderWithProvider(, store); + expect(container).toMatchSnapshot(); + }); + + describe('Click enable button', () => { + it('succefully routes to otp pairing page', async () => { + mockedActions.testDesktopConnection.mockResolvedValue({ + isConnected: true, + versionCheck: { + extensionVersion: 'dummyVersion', + desktopVersion: 'dummyVersion', + isExtensionVersionValid: true, + isDesktopVersionValid: true, + }, + }); + + act(() => { + renderWithProvider(, store); + }); + + const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App'); + + act(() => { + fireEvent.click(enableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1); + }); + + expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith(`${DESKTOP_PAIRING_ROUTE}`); + }); + + it('routes to pairing key error page when pairing does not match', async () => { + mockedActions.testDesktopConnection.mockResolvedValue({ + isConnected: false, + pairingKeyCheck: PairingKeyStatus.NO_MATCH, + }); + + act(() => { + renderWithProvider(, store); + }); + + const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App'); + + act(() => { + fireEvent.click(enableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1); + }); + + expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`, + ); + }); + + it('routes to pairing key error page when fails to connect', async () => { + mockedActions.testDesktopConnection.mockResolvedValue({ + isConnected: false, + }); + + act(() => { + renderWithProvider(, store); + }); + + const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App'); + + act(() => { + fireEvent.click(enableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1); + }); + + expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`, + ); + }); + + it('routes to pairing key error page when extension version is not supported', async () => { + mockedActions.testDesktopConnection.mockResolvedValue({ + isConnected: true, + versionCheck: { + isExtensionVersionValid: false, + }, + }); + act(() => { + renderWithProvider(, store); + }); + + const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App'); + + act(() => { + fireEvent.click(enableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1); + }); + + expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`, + ); + }); + + it('routes to pairing key error page when desktop version is not supported', async () => { + mockedActions.testDesktopConnection.mockResolvedValue({ + isConnected: true, + versionCheck: { + isExtensionVersionValid: true, + isDesktopVersionValid: false, + }, + }); + + act(() => { + renderWithProvider(, store); + }); + + const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App'); + + act(() => { + fireEvent.click(enableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1); + }); + + expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`, + ); + }); + }); + + describe('Click disable button', () => { + it('succefully dispatches disable desktop action when desktop is enabled', async () => { + const desktopEnabledStore = configureMockStore([thunk])({ + ...mockState, + metamask: { ...mockState, desktopEnabled: true }, + }); + + act(() => { + renderWithProvider(, desktopEnabledStore); + }); + + const [disableDesktopButton] = screen.queryAllByText( + 'Disable Desktop App', + ); + + act(() => { + fireEvent.click(disableDesktopButton); + }); + + await waitFor(() => { + expect(mockedActions.disableDesktop).toHaveBeenCalledTimes(1); + }); + + expect(mockSetDesktopEnabled).toHaveBeenCalledTimes(1); + expect(mockShowLoadingIndication).not.toHaveBeenCalled(); + expect(mockedActions.testDesktopConnection).not.toHaveBeenCalled(); + expect(mockHideLoadingIndication).not.toHaveBeenCalled(); + expect(mockHistoryPush).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/components/app/desktop-enable-button/index.js b/ui/components/app/desktop-enable-button/index.js new file mode 100644 index 000000000..0c99bd3ff --- /dev/null +++ b/ui/components/app/desktop-enable-button/index.js @@ -0,0 +1 @@ +export { default } from './desktop-enable-button.component'; diff --git a/ui/components/ui/icon/icon-desktop-pairing.js b/ui/components/ui/icon/icon-desktop-pairing.js new file mode 100644 index 000000000..47249523e --- /dev/null +++ b/ui/components/ui/icon/icon-desktop-pairing.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const IconDesktopPairing = ({ + size = 64, + color = 'currentColor', + ariaLabel, + className, + onClick, +}) => ( + + + + + + + + + + + + + + + + + + + + +); + +IconDesktopPairing.propTypes = { + /** + * The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc + */ + size: PropTypes.number, + /** + * The color of the icon accepts design token css variables + */ + color: PropTypes.string, + /** + * An additional className to assign the Icon + */ + className: PropTypes.string, + /** + * The onClick handler + */ + onClick: PropTypes.func, + /** + * The aria-label of the icon for accessibility purposes + */ + ariaLabel: PropTypes.string, +}; + +export default IconDesktopPairing; diff --git a/ui/components/ui/icon/icon-times.js b/ui/components/ui/icon/icon-times.js new file mode 100644 index 000000000..bdb67acc9 --- /dev/null +++ b/ui/components/ui/icon/icon-times.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const IconTimes = ({ + size = 24, + color = 'currentColor', + ariaLabel, + className, + onClick, +}) => ( + // This SVG copied from `@fortawesome/fontawesome-free@5.13.0/regular/times.svg`. + + + +); + +IconTimes.propTypes = { + /** + * The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc + */ + size: PropTypes.number, + /** + * The color of the icon accepts design token css variables + */ + color: PropTypes.string, + /** + * An additional className to assign the Icon + */ + className: PropTypes.string, + /** + * The onClick handler + */ + onClick: PropTypes.func, + /** + * The aria-label of the icon for accessibility purposes + */ + ariaLabel: PropTypes.string, +}; + +export default IconTimes; diff --git a/ui/components/ui/metafox-logo/metafox-logo.component.js b/ui/components/ui/metafox-logo/metafox-logo.component.js index 0f52eb16a..081b8284a 100644 --- a/ui/components/ui/metafox-logo/metafox-logo.component.js +++ b/ui/components/ui/metafox-logo/metafox-logo.component.js @@ -8,6 +8,9 @@ export default class MetaFoxLogo extends PureComponent { onClick: PropTypes.func, unsetIconHeight: PropTypes.bool, isOnboarding: PropTypes.bool, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + src: PropTypes.string, + ///: END:ONLY_INCLUDE_IN }; static defaultProps = { @@ -15,9 +18,45 @@ export default class MetaFoxLogo extends PureComponent { }; render() { - const { onClick, unsetIconHeight, isOnboarding } = this.props; + const { + onClick, + unsetIconHeight, + isOnboarding, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + src, + ///: END:ONLY_INCLUDE_IN + } = this.props; const iconProps = unsetIconHeight ? {} : { height: 42, width: 42 }; + let renderHorizontalLogo = () => ( + + ); + + let imageSrc = './images/logo/metamask-fox.svg'; + + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + if (src) { + renderHorizontalLogo = () => ( + + ); + + imageSrc = src; + } + ///: END:ONLY_INCLUDE_IN + return (
- + {renderHorizontalLogo()} + { expect(state.pendingTokens).toStrictEqual({}); }); + it('disables desktop', () => { + const enabledMetaMaskState = { + desktopEnabled: true, + }; + const enabledDesktopMetaMask = reduceMetamask(enabledMetaMaskState, { + type: actionConstants.FORCE_DISABLE_DESKTOP, + }); + + expect(enabledDesktopMetaMask.desktopEnabled).toStrictEqual(false); + }); + describe('metamask state selectors', () => { describe('getBlockGasLimit', () => { it('should return the current block gas limit', () => { diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index 3debe35e7..e887ee395 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -85,6 +85,10 @@ const SIGNATURE_REQUEST_PATH = '/signature-request'; const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request'; const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request'; const CONFIRMATION_V_NEXT_ROUTE = '/confirmation'; +///: BEGIN:ONLY_INCLUDE_IN(desktop) +const DESKTOP_ERROR_ROUTE = '/desktop/error'; +const DESKTOP_PAIRING_ROUTE = '/desktop-pairing'; +///: END:ONLY_INCLUDE_IN // Used to pull a convenient name for analytics tracking events. The key must // be react-router ready path, and can include params such as :id for popup windows @@ -242,4 +246,8 @@ export { INITIALIZE_EXPERIMENTAL_AREA, ONBOARDING_EXPERIMENTAL_AREA, ///: END:ONLY_INCLUDE_IN + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + DESKTOP_ERROR_ROUTE, + DESKTOP_PAIRING_ROUTE, + ///: END:ONLY_INCLUDE_IN }; diff --git a/ui/hooks/desktopHooks.js b/ui/hooks/desktopHooks.js new file mode 100644 index 000000000..02ccae535 --- /dev/null +++ b/ui/hooks/desktopHooks.js @@ -0,0 +1,14 @@ +import { DESKTOP_HOOK_TYPES } from '@metamask/desktop/dist/constants'; +import { DESKTOP_ERROR_ROUTE } from '../helpers/constants/routes'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../shared/constants/desktop'; + +const registerOnDesktopDisconnect = (history) => { + return (request) => { + if (request.type === DESKTOP_HOOK_TYPES.DISCONNECT) { + const connectionLostRoute = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`; + history.push(connectionLostRoute); + } + }; +}; + +export { registerOnDesktopDisconnect }; diff --git a/ui/hooks/desktopHooks.test.js b/ui/hooks/desktopHooks.test.js new file mode 100644 index 000000000..3497661de --- /dev/null +++ b/ui/hooks/desktopHooks.test.js @@ -0,0 +1,27 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { DESKTOP_HOOK_TYPES } from '@metamask/desktop/dist/constants'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../shared/constants/desktop'; +import { DESKTOP_ERROR_ROUTE } from '../helpers/constants/routes'; +import { registerOnDesktopDisconnect } from './desktopHooks'; + +describe('desktopHooks', () => { + describe('registerOnDesktopDisconnect', () => { + it('push desktop connection lost route when request type is disconnect', () => { + const mockHistoryPush = jest.fn(); + + const { result } = renderHook(() => + registerOnDesktopDisconnect({ push: mockHistoryPush }), + ); + + expect(result).toBeInstanceOf(Object); + expect(mockHistoryPush).not.toHaveBeenCalled(); + + result.current({ type: DESKTOP_HOOK_TYPES.DISCONNECT }); + + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`, + ); + }); + }); +}); diff --git a/ui/index.js b/ui/index.js index 2422b1fb4..5b3628fb8 100644 --- a/ui/index.js +++ b/ui/index.js @@ -53,15 +53,42 @@ export const updateBackgroundConnection = (backgroundConnection) => { export default function launchMetamaskUi(opts, cb) { const { backgroundConnection } = opts; + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + let desktopEnabled = false; + + backgroundConnection.getDesktopEnabled(function (err, result) { + if (err) { + return; + } + + desktopEnabled = result; + }); + ///: END:ONLY_INCLUDE_IN + // check if we are unlocked first backgroundConnection.getState(function (err, metamaskState) { if (err) { - cb(err, metamaskState); + cb( + err, + { + ...metamaskState, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN + }, + backgroundConnection, + ); return; } startApp(metamaskState, backgroundConnection, opts).then((store) => { setupDebuggingHelpers(store); - cb(null, store); + cb( + null, + store, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + backgroundConnection, + ///: END:ONLY_INCLUDE_IN + ); }); }); } diff --git a/ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap b/ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap new file mode 100644 index 000000000..1da259ec2 --- /dev/null +++ b/ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap @@ -0,0 +1,373 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Desktop Error page should render connection lost page 1`] = ` +
+
+ + + +

+ MetaMask Desktop connection was lost +

+

+ Please make sure you have the desktop app up and running or disable MetaMask Desktop. +

+
+ +
+
+ +
+
+
+`; + +exports[`Desktop Error page should render critical error page 1`] = ` +
+
+ + + +

+ MetaMask had trouble starting +

+

+ This error could be intermittent, so try restarting the extension or disable MetaMask Desktop. +

+
+ +
+
+ +
+
+
+`; + +exports[`Desktop Error page should render default error page 1`] = ` +
+
+ + + +

+ Something went wrong... +

+

+ Check your MetaMask Desktop to restore connection +

+
+ +
+
+
+`; + +exports[`Desktop Error page should render desktop app outdated page 1`] = ` +
+
+ + + +

+ MetaMask Desktop is outdated +

+

+ Your MetaMask desktop app needs to be upgraded. +

+
+ +
+
+
+`; + +exports[`Desktop Error page should render extension outdated page 1`] = ` +
+
+ + + +

+ MetaMask Extension is outdated +

+

+ Your MetaMask extension needs to be upgraded. +

+
+ +
+
+
+`; + +exports[`Desktop Error page should render not found page 1`] = ` +
+
+ + + +

+ MetaMask Desktop was not found +

+

+ Please make sure you have the desktop app up and running. +

+

+ If you have no desktop app installed, please download it on the MetaMask website. +

+
+ +
+
+
+`; + +exports[`Desktop Error page should render pairing key not match page 1`] = ` +
+
+ + + +

+ MM Desktop is already paired +

+

+ If you want to start a new pairing, please remove the current connection. +

+ + Go to Settings in MetaMask Desktop + +
+ +
+
+
+`; + +exports[`Desktop Error page should render route not found page 1`] = ` +
+
+ + + +

+ desktopRouteNotFoundErrorTitle +

+

+ desktopRouteNotFoundErrorDescription +

+
+ +
+
+
+`; diff --git a/ui/pages/desktop-error/desktop-error.component.js b/ui/pages/desktop-error/desktop-error.component.js new file mode 100644 index 000000000..76042b178 --- /dev/null +++ b/ui/pages/desktop-error/desktop-error.component.js @@ -0,0 +1,29 @@ +import { useParams, useHistory } from 'react-router-dom'; +import { useI18nContext } from '../../hooks/useI18nContext'; +import { + downloadDesktopApp, + downloadExtension, + restartExtension, +} from '../../../shared/lib/error-utils'; +import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; +import { renderDesktopError } from './render-desktop-error'; + +export default function DesktopError({ forceDisableDesktop }) { + const t = useI18nContext(); + const { errorType } = useParams(); + const history = useHistory(); + + return renderDesktopError({ + type: errorType, + t, + isHtmlError: false, + history, + disableDesktop: () => { + forceDisableDesktop(); + history.push(DEFAULT_ROUTE); + }, + downloadDesktopApp, + downloadExtension, + restartExtension, + }); +} diff --git a/ui/pages/desktop-error/desktop-error.container.js b/ui/pages/desktop-error/desktop-error.container.js new file mode 100644 index 000000000..36f6f0fc1 --- /dev/null +++ b/ui/pages/desktop-error/desktop-error.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { withRouter } from 'react-router-dom'; +import { FORCE_DISABLE_DESKTOP } from '../../store/actionConstants'; +import DesktopError from './desktop-error.component'; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = (dispatch) => ({ + forceDisableDesktop: async () => { + dispatch({ + type: FORCE_DISABLE_DESKTOP, + }); + }, +}); + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), +)(DesktopError); diff --git a/ui/pages/desktop-error/desktop-error.test.js b/ui/pages/desktop-error/desktop-error.test.js new file mode 100644 index 000000000..8995f5cc3 --- /dev/null +++ b/ui/pages/desktop-error/desktop-error.test.js @@ -0,0 +1,127 @@ +import React from 'react'; +import reactRouterDom, { Route } from 'react-router-dom'; +import configureStore from '../../store/store'; +import { renderWithProvider } from '../../../test/jest'; +import mockState from '../../../test/data/mock-state.json'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop'; +import DesktopErrorPage from '.'; + +describe('Desktop Error page', () => { + const mockHistoryPush = jest.fn(); + + beforeEach(() => { + jest + .spyOn(reactRouterDom, 'useHistory') + .mockImplementation() + .mockReturnValue({ push: mockHistoryPush }); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('should render not found page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render connection lost page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render desktop app outdated page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render extension outdated page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render critical error page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render route not found page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.ROUTE_NOT_FOUND}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render pairing key not match page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`], + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render default error page', async () => { + const store = configureStore(mockState); + const { container } = renderWithProvider( + + + , + store, + [`/unknown-error-type`], + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/desktop-error/index.js b/ui/pages/desktop-error/index.js new file mode 100644 index 000000000..7975f86a8 --- /dev/null +++ b/ui/pages/desktop-error/index.js @@ -0,0 +1 @@ +export { default } from './desktop-error.container'; diff --git a/ui/pages/desktop-error/render-desktop-error.js b/ui/pages/desktop-error/render-desktop-error.js new file mode 100644 index 000000000..5e849ccc2 --- /dev/null +++ b/ui/pages/desktop-error/render-desktop-error.js @@ -0,0 +1,237 @@ +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; + +import IconTimes from '../../components/ui/icon/icon-times'; +import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop'; +import { + TypographyVariant, + DISPLAY, + FLEX_DIRECTION, + AlignItems, + TEXT_ALIGN, + FONT_WEIGHT, +} from '../../helpers/constants/design-system'; +import { DEFAULT_ROUTE, SETTINGS_ROUTE } from '../../helpers/constants/routes'; +import Typography from '../../components/ui/typography'; +import Button from '../../components/ui/button'; +import Box from '../../components/ui/box'; +import { openCustomProtocol } from '../../../shared/lib/deep-linking'; + +export function renderDesktopError({ + type, + t, + isHtmlError, + history, + disableDesktop, + downloadExtension, + downloadDesktopApp, + restartExtension, + openOrDownloadDesktopApp, +}) { + let content; + + const noop = () => { + // do nothing + }; + + const returnExtensionHome = () => { + history?.push(DEFAULT_ROUTE); + }; + + const navigateSettings = () => { + history?.push(SETTINGS_ROUTE); + }; + + const renderHeader = (text) => { + return ( + + {text} + + ); + }; + + const renderDescription = (text) => { + return ( + {text} + ); + }; + + const renderCTA = (id, text, onClick) => { + return ( + + + + ); + }; + + const openSettingsOrDownloadMMD = () => { + openCustomProtocol('metamask-desktop://pair').catch(() => { + window.open('https://metamask.io/download.html', '_blank').focus(); + }); + }; + + switch (type) { + case EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND: + content = ( + <> + {renderHeader(t('desktopNotFoundErrorTitle'))} + {renderDescription(t('desktopNotFoundErrorDescription1'))} + {renderDescription(t('desktopNotFoundErrorDescription2'))} + {renderCTA( + 'desktop-error-button-download-mmd', + t('desktopNotFoundErrorCTA'), + downloadDesktopApp, + )} + + ); + break; + + case EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST: + content = ( + <> + {renderHeader(t('desktopConnectionLostErrorTitle'))} + {renderDescription(t('desktopConnectionLostErrorDescription'))} + {renderCTA( + 'desktop-error-button-open-or-download-mmd', + t('desktopOpenOrDownloadCTA'), + openOrDownloadDesktopApp, + )} + {renderCTA( + 'desktop-error-button-disable-mmd', + t('desktopDisableErrorCTA'), + disableDesktop, + )} + + ); + break; + + case EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED: + content = ( + <> + {renderHeader(t('desktopOutdatedErrorTitle'))} + {renderDescription(t('desktopOutdatedErrorDescription'))} + {renderCTA( + 'desktop-error-button-update-mmd', + t('desktopOutdatedErrorCTA'), + downloadDesktopApp, + )} + + ); + break; + + case EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED: + content = ( + <> + {renderHeader(t('desktopOutdatedExtensionErrorTitle'))} + {renderDescription(t('desktopOutdatedExtensionErrorDescription'))} + {renderCTA( + 'desktop-error-button-update-extension', + t('desktopOutdatedExtensionErrorCTA'), + downloadExtension, + )} + + ); + break; + + case EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR: + content = ( + <> + {renderHeader(t('desktopConnectionCriticalErrorTitle'))} + {renderDescription(t('desktopConnectionCriticalErrorDescription'))} + {renderCTA( + 'desktop-error-button-restart-mm', + t('desktopErrorRestartMMCTA'), + restartExtension, + )} + {renderCTA( + 'desktop-error-button-disable-mmd', + t('desktopDisableErrorCTA'), + disableDesktop, + )} + + ); + break; + + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + // This route only exists on the Desktop App + case EXTENSION_ERROR_PAGE_TYPES.ROUTE_NOT_FOUND: + content = ( + <> + {renderHeader(t('desktopRouteNotFoundErrorTitle'))} + {renderDescription(t('desktopRouteNotFoundErrorDescription'))} + {renderCTA( + 'desktop-error-button-navigate-settings', + t('desktopErrorNavigateSettingsCTA'), + navigateSettings, + )} + + ); + break; + ///: END:ONLY_INCLUDE_IN + + case EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH: + content = ( + <> + {renderHeader(t('desktopPairedWarningTitle'))} + {renderDescription(t('desktopPairedWarningDescription'))} + + {renderCTA( + 'desktop-error-button-navigate-settings', + t('desktopErrorNavigateSettingsCTA'), + navigateSettings, + )} + + ); + break; + + default: + content = ( + <> + {renderHeader(t('desktopUnexpectedErrorTitle'))} + {renderDescription(t('desktopUnexpectedErrorDescription'))} + {renderCTA( + 'desktop-error-button-return-mm-home', + t('desktopUnexpectedErrorCTA'), + returnExtensionHome, + )} + + ); + break; + } + + const errorContent = ( + + + {content} + + ); + + if (isHtmlError) { + return ReactDOMServer.renderToStaticMarkup(errorContent); + } + + return errorContent; +} diff --git a/ui/pages/desktop-pairing/__snapshots__/desktop-pairing.test.js.snap b/ui/pages/desktop-pairing/__snapshots__/desktop-pairing.test.js.snap new file mode 100644 index 000000000..dcead5fb5 --- /dev/null +++ b/ui/pages/desktop-pairing/__snapshots__/desktop-pairing.test.js.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Desktop Pairing page should render otp component 1`] = ` +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ Pair with Desktop +
+
+ Open your MetaMask Desktop and type this code +
+
+
+
+
+
+
+

+ 123456 +

+
+
+
+

+ + + Code expires in + + 30 + + seconds + + +

+
+ If the pairing is successful, extension will restart and you'll have to re-enter your password. +
+
+
+
+ +
+
+
+`; diff --git a/ui/pages/desktop-pairing/desktop-pairing.component.js b/ui/pages/desktop-pairing/desktop-pairing.component.js new file mode 100644 index 000000000..6a5347552 --- /dev/null +++ b/ui/pages/desktop-pairing/desktop-pairing.component.js @@ -0,0 +1,190 @@ +import React, { useState, useEffect, useRef, useContext } from 'react'; +import { useHistory } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import Button from '../../components/ui/button'; +import { SECOND } from '../../../shared/constants/time'; +import Typography from '../../components/ui/typography'; +import { I18nContext } from '../../contexts/i18n'; +import IconDesktopPairing from '../../components/ui/icon/icon-desktop-pairing'; +import { + TEXT_ALIGN, + TypographyVariant, + DISPLAY, + AlignItems, + FLEX_DIRECTION, +} from '../../helpers/constants/design-system'; +import Box from '../../components/ui/box/box'; +import { useCopyToClipboard } from '../../hooks/useCopyToClipboard'; +import Tooltip from '../../components/ui/tooltip'; + +export default function DesktopPairingPage({ + generateOtp, + mostRecentOverviewPage, + showLoadingIndication, + hideLoadingIndication, +}) { + const t = useContext(I18nContext); + const history = useHistory(); + const OTP_DURATION = SECOND * 30; + const REFRESH_INTERVAL = SECOND; + const time = new Date().getTime(); + + const [otp, setOtp] = useState(); + const [lastOtpTime, setLastOtpTime] = useState(time); + const [currentTime, setCurrentTime] = useState(time); + const [copied, handleCopy] = useCopyToClipboard(); + const generateIntervalRef = useRef(); + const refreshIntervalRef = useRef(); + + const updateCurrentTime = () => { + setCurrentTime(new Date().getTime()); + }; + + const getExpireDuration = () => { + const timeSinceOtp = currentTime - lastOtpTime; + const expireDurationMilliseconds = OTP_DURATION - timeSinceOtp; + + const expireDurationSeconds = Math.round( + expireDurationMilliseconds / SECOND, + ); + + return expireDurationSeconds; + }; + + useEffect(() => { + const generate = async () => { + setLastOtpTime(new Date().getTime()); + const OTP = await generateOtp(); + setOtp(OTP); + }; + + generate(); + updateCurrentTime(); + + generateIntervalRef.current = setInterval(() => generate(), OTP_DURATION); + refreshIntervalRef.current = setInterval( + () => updateCurrentTime(), + REFRESH_INTERVAL, + ); + + return function cleanup() { + clearInterval(generateIntervalRef.current); + clearInterval(refreshIntervalRef.current); + }; + }, [OTP_DURATION, REFRESH_INTERVAL, generateOtp]); + + const renderIcon = () => { + return ( +
+ + + +
+ ); + }; + + const goBack = () => { + history?.push(mostRecentOverviewPage); + }; + + const renderContent = () => { + if (!otp) { + showLoadingIndication(); + return null; + } + + hideLoadingIndication(); + + return ( +
{ + handleCopy(otp); + }} + data-testid="desktop-pairing-otp-content" + > + + + + {otp} + + + + + + {t('desktopPairingExpireMessage', [ + + {getExpireDuration()} + , + ])} + +
+ {t('desktopPageDescription')} +
+
+ ); + }; + + const renderFooter = () => { + return ( +
+ +
+ ); + }; + + return ( +
+
+ {renderIcon()} +
{t('desktopPageTitle')}
+
+ {t('desktopPageSubTitle')} +
+
+
{renderContent()}
+ {renderFooter()} +
+ ); +} + +DesktopPairingPage.propTypes = { + mostRecentOverviewPage: PropTypes.string, + showLoadingIndication: PropTypes.func, + hideLoadingIndication: PropTypes.func, + generateOtp: PropTypes.func, +}; diff --git a/ui/pages/desktop-pairing/desktop-pairing.container.js b/ui/pages/desktop-pairing/desktop-pairing.container.js new file mode 100644 index 000000000..734a074b0 --- /dev/null +++ b/ui/pages/desktop-pairing/desktop-pairing.container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { + generateOtp, + hideLoadingIndication, + showLoadingIndication, +} from '../../store/actions'; +import { getMostRecentOverviewPage } from '../../ducks/history/history'; +import DesktopPairingPage from './desktop-pairing.component'; + +const mapDispatchToProps = (dispatch) => { + return { + generateOtp: () => generateOtp(), + showLoadingIndication: () => dispatch(showLoadingIndication()), + hideLoadingIndication: () => dispatch(hideLoadingIndication()), + }; +}; + +const mapStateToProps = (state) => { + return { + mostRecentOverviewPage: getMostRecentOverviewPage(state), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DesktopPairingPage); diff --git a/ui/pages/desktop-pairing/desktop-pairing.test.js b/ui/pages/desktop-pairing/desktop-pairing.test.js new file mode 100644 index 000000000..7cdf9f919 --- /dev/null +++ b/ui/pages/desktop-pairing/desktop-pairing.test.js @@ -0,0 +1,110 @@ +import React from 'react'; +import reactRouterDom from 'react-router-dom'; +import { waitFor, act, screen } from '@testing-library/react'; +import actions from '../../store/actions'; +import configureStore from '../../store/store'; +import { renderWithProvider } from '../../../test/jest'; +import mockState from '../../../test/data/mock-state.json'; +import { SECOND } from '../../../shared/constants/time'; +import DesktopPairingPage from '.'; + +const mockHideLoadingIndication = jest.fn(); +const mockShowLoadingIndication = jest.fn(); + +jest.mock('../../store/actions', () => { + return { + hideLoadingIndication: () => mockHideLoadingIndication, + showLoadingIndication: () => mockShowLoadingIndication, + generateOtp: jest.fn(), + }; +}); + +const mockedActions = actions; + +describe('Desktop Pairing page', () => { + const mockHistoryPush = jest.fn(); + + function flushPromises() { + // Wait for promises running in the non-async timer callback to complete. + // From https://github.com/facebook/jest/issues/2157#issuecomment-897935688 + return new Promise(jest.requireActual('timers').setImmediate); + } + + beforeEach(() => { + jest + .spyOn(reactRouterDom, 'useHistory') + .mockImplementation() + .mockReturnValue({ push: mockHistoryPush }); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('should render otp component', async () => { + const otp = '123456'; + mockedActions.generateOtp.mockResolvedValue(otp); + + const store = configureStore(mockState); + let container = null; + + act(() => { + container = renderWithProvider(, store).container; + }); + + await waitFor(() => { + expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined(); + }); + + expect(container).toMatchSnapshot(); + }); + + it('should re-render otp component after 30s', async () => { + jest.useFakeTimers(); + const otp = '123456'; + const newOtp = '654321'; + const neverGeneratedOTP = '111222'; + mockedActions.generateOtp + .mockResolvedValueOnce(otp) + .mockResolvedValueOnce(newOtp) + .mockResolvedValueOnce(neverGeneratedOTP); + + const store = configureStore(mockState); + + act(() => { + renderWithProvider(, store); + }); + + // First render + await waitFor(async () => { + await flushPromises(); + expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined(); + expect(screen.getByText(otp)).toBeDefined(); + expect(mockedActions.generateOtp).toHaveBeenCalledTimes(1); + }); + + // Advance timers 30s to trigger next OTP + act(() => jest.advanceTimersByTime(30 * SECOND)); + + await waitFor(async () => { + await flushPromises(); + expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined(); + expect(screen.getByText(newOtp)).toBeDefined(); + expect(mockedActions.generateOtp).toHaveBeenCalledTimes(2); + }); + + // Advance timers 10s to test that OTP is still the same + act(() => jest.advanceTimersByTime(10 * SECOND)); + + await waitFor(async () => { + await flushPromises(); + expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined(); + expect(screen.getByText(newOtp)).toBeDefined(); + expect(mockedActions.generateOtp).toHaveBeenCalledTimes(2); + }); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); +}); diff --git a/ui/pages/desktop-pairing/index.js b/ui/pages/desktop-pairing/index.js new file mode 100644 index 000000000..574a8c9cf --- /dev/null +++ b/ui/pages/desktop-pairing/index.js @@ -0,0 +1 @@ +export { default } from './desktop-pairing.container'; diff --git a/ui/pages/desktop-pairing/index.scss b/ui/pages/desktop-pairing/index.scss new file mode 100644 index 000000000..cf634e8fb --- /dev/null +++ b/ui/pages/desktop-pairing/index.scss @@ -0,0 +1,128 @@ +.desktop-pairing { + display: flex; + flex-flow: column; + align-items: center; + padding: 0 30px 0; + + &__countdown-timer { + background: #f2f3f4; + border-radius: 15.5px; + padding: 7px 0 7px 0; + margin: 0 32px 0 32px; + font-size: 12px; + } + + &__countdown-timer-seconds { + color: var(--color-primary-default); + } + + &__icon { + padding-top: 24px; + } + + &__title { + font-style: normal; + font-weight: 700; + font-size: 24px; + line-height: 140.62%; + text-align: center; + color: #24292e; + padding-top: 24px; + } + + &__subtitle, + &__description { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 140.62%; + text-align: center; + color: #000; + padding-top: 8px; + margin: 0 56px; + } + + &__description { + margin: 18px 0; + } + + &__otp { + font-style: normal; + font-weight: 500; + font-size: 48px; + letter-spacing: 10px; + color: #000; + } + + &__buttons { + display: flex; + width: 70%; + justify-content: space-between; + margin: auto; + } + + &__tooltip-wrapper { + width: 100%; + } + + &__clickable { + width: 100%; + + &:hover { + cursor: pointer; + } + } +} + +.desktop-pairing-warning { + font-style: normal; + font-weight: 400; + font-size: 14px; + + &__close-button { + z-index: 1050; + font-size: 24px; + cursor: pointer; + + &__close::after { + content: '\00D7'; + font-size: 24px; + cursor: pointer; + position: relative; + float: right; + margin-top: -8px; + } + } + + &__title { + font-weight: bold; + font-size: 16px; + } + + &__text { + font-size: 0.875rem; + } + + &__link { + color: var(--color-primary-default); + display: block; + cursor: pointer; + line-height: 100%; + font-size: 0.875rem; + padding: 0 0; + } + + &__warning-content { + border-left: 5px solid var(--color-warning-default); + border-right: 0; + border-bottom: 0; + border-top: 0; + margin: 4px; + + .icon { + position: absolute; + left: 5px; + top: 10px; + } + } +} diff --git a/ui/pages/pages.scss b/ui/pages/pages.scss index d1a091872..1d78f3af6 100644 --- a/ui/pages/pages.scss +++ b/ui/pages/pages.scss @@ -25,3 +25,4 @@ @import 'unlock-page/index'; @import 'onboarding-flow/index'; @import 'notifications/index'; +@import 'desktop-pairing/index'; diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index ea604dd1f..a9d8f0edb 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -4,6 +4,9 @@ import React, { Component } from 'react'; import { matchPath, Route, Switch } from 'react-router-dom'; import IdleTimer from 'react-idle-timer'; +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import browserAPI from 'webextension-polyfill'; +///: END:ONLY_INCLUDE_IN(desktop) import SendTransactionScreen from '../send'; import Swaps from '../swaps'; import ConfirmTransaction from '../confirm-transaction'; @@ -36,6 +39,11 @@ import TokenDetailsPage from '../token-details'; ///: BEGIN:ONLY_INCLUDE_IN(flask) import Notifications from '../notifications'; ///: END:ONLY_INCLUDE_IN +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import { registerOnDesktopDisconnect } from '../../hooks/desktopHooks'; +import DesktopErrorPage from '../desktop-error'; +import DesktopPairingPage from '../desktop-pairing'; +///: END:ONLY_INCLUDE_IN(desktop) import { IMPORT_TOKEN_ROUTE, @@ -63,8 +71,16 @@ import { ///: BEGIN:ONLY_INCLUDE_IN(flask) NOTIFICATIONS_ROUTE, ///: END:ONLY_INCLUDE_IN + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + DESKTOP_PAIRING_ROUTE, + DESKTOP_ERROR_ROUTE, + ///: END:ONLY_INCLUDE_IN } from '../../helpers/constants/routes'; +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop'; +///: END:ONLY_INCLUDE_IN + import { ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_POPUP, @@ -128,6 +144,22 @@ export default class Routes extends Component { document.documentElement.setAttribute('data-theme', osTheme); } + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + componentDidMount() { + const { history } = this.props; + browserAPI.runtime.onMessage.addListener( + registerOnDesktopDisconnect(history), + ); + } + + componentWillUnmount() { + const { history } = this.props; + browserAPI.runtime.onMessage.removeListener( + registerOnDesktopDisconnect(history), + ); + } + ///: END:ONLY_INCLUDE_IN + componentDidUpdate(prevProps) { const { theme } = this.props; @@ -173,6 +205,15 @@ export default class Routes extends Component { + { + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + + ///: END:ONLY_INCLUDE_IN + } + { + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + + ///: END:ONLY_INCLUDE_IN + } ); @@ -295,6 +345,19 @@ export default class Routes extends Component { hideAppHeader() { const { location } = this.props; + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + const isDesktopConnectionLostScreen = Boolean( + matchPath(location.pathname, { + path: `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`, + exact: true, + }), + ); + + if (isDesktopConnectionLostScreen) { + return true; + } + ///: END:ONLY_INCLUDE_IN + const isInitializing = Boolean( matchPath(location.pathname, { path: ONBOARDING_ROUTE, diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js index cf42eb77c..4ebd7fab2 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js @@ -60,6 +60,9 @@ export default class AdvancedTab extends PureComponent { disabledRpcMethodPreferences: PropTypes.shape({ eth_sign: PropTypes.bool.isRequired, }), + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled: PropTypes.bool, + ///: END:ONLY_INCLUDE_IN }; state = { @@ -466,8 +469,17 @@ export default class AdvancedTab extends PureComponent { ledgerTransportType, setLedgerTransportPreference, userHasALedgerAccount, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN } = this.props; + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + if (desktopEnabled) { + return null; + } + ///: END:ONLY_INCLUDE_IN + const LEDGER_TRANSPORT_NAMES = { LIVE: t('ledgerLive'), WEBHID: t('webhid'), @@ -497,7 +509,11 @@ export default class AdvancedTab extends PureComponent { : LEDGER_TRANSPORT_NAMES.U2F; return ( -
+
{t('preferredLedgerConnectionType')}
diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js index 6329043bb..c5f52a12d 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.component.test.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.component.test.js @@ -54,4 +54,21 @@ describe('AdvancedTab Component', () => { expect(mockSetShowTestNetworks).toHaveBeenCalled(); }); + + it('should not render ledger live control with desktop pairing enabled', () => { + const mockStoreWithDesktopEnabled = configureMockStore([thunk])({ + ...mockState, + metamask: { + ...mockState.metamask, + desktopEnabled: true, + }, + }); + + const { queryByTestId } = renderWithProvider( + , + mockStoreWithDesktopEnabled, + ); + + expect(queryByTestId('ledger-live-control')).not.toBeInTheDocument(); + }); }); diff --git a/ui/pages/settings/advanced-tab/advanced-tab.container.js b/ui/pages/settings/advanced-tab/advanced-tab.container.js index f30c954b6..faf67b703 100644 --- a/ui/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/pages/settings/advanced-tab/advanced-tab.container.js @@ -30,6 +30,9 @@ export const mapStateToProps = (state) => { useNonceField, ledgerTransportType, dismissSeedBackUpReminder, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN } = metamask; const { showFiatInTestnets, @@ -51,6 +54,9 @@ export const mapStateToProps = (state) => { dismissSeedBackUpReminder, userHasALedgerAccount, disabledRpcMethodPreferences, + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + desktopEnabled, + ///: END:ONLY_INCLUDE_IN }; }; diff --git a/ui/pages/settings/experimental-tab/__snapshots__/experimental-tab.test.js.snap b/ui/pages/settings/experimental-tab/__snapshots__/experimental-tab.test.js.snap new file mode 100644 index 000000000..738a55ac1 --- /dev/null +++ b/ui/pages/settings/experimental-tab/__snapshots__/experimental-tab.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExperimentalTab with desktop enabled renders ExperimentalTab component without error 1`] = ` +
+
+
+
+ + Click to run all background processes in the desktop app. + +
+
+
+ +
+
+
+
+
+`; diff --git a/ui/pages/settings/experimental-tab/experimental-tab.component.js b/ui/pages/settings/experimental-tab/experimental-tab.component.js index 2ab4f04bf..b8c43eebf 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.component.js +++ b/ui/pages/settings/experimental-tab/experimental-tab.component.js @@ -13,6 +13,9 @@ import { TextColor, TypographyVariant, } from '../../../helpers/constants/design-system'; +///: BEGIN:ONLY_INCLUDE_IN(desktop) +import DesktopEnableButton from '../../../components/app/desktop-enable-button'; +///: END:ONLY_INCLUDE_IN export default class ExperimentalTab extends PureComponent { static contextTypes = { @@ -223,12 +226,40 @@ export default class ExperimentalTab extends PureComponent { ); } + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + renderDesktopEnableButton() { + const { t } = this.context; + + return ( +
+
+ {t('desktopEnableButtonDescription')} +
+
+
+ +
+
+
+ ); + } + ///: END:ONLY_INCLUDE_IN + render() { return (
{process.env.TRANSACTION_SECURITY_PROVIDER && this.renderTransactionSecurityCheckToggle()} {process.env.NFTS_V1 && this.renderOpenSeaEnabledToggle()} + { + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + this.renderDesktopEnableButton() + ///: END:ONLY_INCLUDE_IN + }
); } diff --git a/ui/pages/settings/experimental-tab/experimental-tab.test.js b/ui/pages/settings/experimental-tab/experimental-tab.test.js index aec08bb81..c9a2aabb2 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.test.js +++ b/ui/pages/settings/experimental-tab/experimental-tab.test.js @@ -4,10 +4,11 @@ import configureStore from '../../../store/store'; import mockState from '../../../../test/data/mock-state.json'; import ExperimentalTab from './experimental-tab.component'; -const render = () => { +const render = (overrideMetaMaskState) => { const store = configureStore({ metamask: { ...mockState.metamask, + ...overrideMetaMaskState, }, }); return renderWithProvider(, store); @@ -19,4 +20,11 @@ describe('ExperimentalTab', () => { render(); }).not.toThrow(); }); + + describe('with desktop enabled', () => { + it('renders ExperimentalTab component without error', () => { + const { container } = render({ desktopEnabled: true }); + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/ui/pages/settings/settings.component.js b/ui/pages/settings/settings.component.js index c7989d6e3..5da1c45cb 100644 --- a/ui/pages/settings/settings.component.js +++ b/ui/pages/settings/settings.component.js @@ -78,8 +78,16 @@ class SettingsPage extends PureComponent { searchText: '', }; - shouldRenderExperimentalTab = - process.env.TRANSACTION_SECURITY_PROVIDER || process.env.NFTS_V1; + shouldRenderExperimentalTab() { + ///: BEGIN:ONLY_INCLUDE_IN(desktop) + const desktopAvailable = true; + if (desktopAvailable) { + return true; + } + ///: END:ONLY_INCLUDE_IN + + return process.env.TRANSACTION_SECURITY_PROVIDER || process.env.NFTS_V1; + } componentDidMount() { this.handleConversionDate(); @@ -305,7 +313,7 @@ class SettingsPage extends PureComponent { }, ]; - if (this.shouldRenderExperimentalTab) { + if (this.shouldRenderExperimentalTab()) { tabs.push({ content: t('experimental'), icon: , @@ -365,7 +373,7 @@ class SettingsPage extends PureComponent { render={() => } /> - {this.shouldRenderExperimentalTab ? ( + {this.shouldRenderExperimentalTab() ? ( ) : null} diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 624e3eea6..2cb504f78 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -1410,3 +1410,15 @@ export function getCustomTokenAmount(state) { export function getUseCurrencyRateCheck(state) { return Boolean(state.metamask.useCurrencyRateCheck); } + +///: BEGIN:ONLY_INCLUDE_IN(desktop) +/** + * To get the `desktopEnabled` value which determines whether we use the desktop app + * + * @param {*} state + * @returns Boolean + */ +export function getIsDesktopEnabled(state) { + return state.metamask.desktopEnabled; +} +///: END:ONLY_INCLUDE_IN diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index c507c4f2d..cc1623a30 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -294,4 +294,9 @@ describe('Selectors', () => { selectors.getTotalUnapprovedSignatureRequestCount(mockState); expect(totalUnapprovedSignatureRequestCount).toStrictEqual(0); }); + + it('#getIsDesktopEnabled', () => { + const isDesktopEnabled = selectors.getIsDesktopEnabled(mockState); + expect(isDesktopEnabled).toBeFalsy(); + }); }); diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 5c04a24a3..f83b1fd6a 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -104,3 +104,7 @@ export const SET_NEW_TOKENS_IMPORTED = 'SET_NEW_TOKENS_IMPORTED'; // Token allowance export const SET_CUSTOM_TOKEN_AMOUNT = 'SET_CUSTOM_TOKEN_AMOUNT'; + +///: BEGIN:ONLY_INCLUDE_IN(desktop) +export const FORCE_DISABLE_DESKTOP = 'FORCE_DISABLE_DESKTOP'; +///: END:ONLY_INCLUDE_IN diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 44e279df0..16aba64ae 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1832,4 +1832,28 @@ describe('Actions', () => { expect(expectedActions[1].value.id).toStrictEqual(msgsList[1].id); }); }); + + describe('Desktop', () => { + describe('#setDesktopEnabled', () => { + it('calls background setDesktopEnabled method', async () => { + const store = mockStore(); + const setDesktopEnabled = sinon.stub().callsFake((_, cb) => cb()); + + background.getApi.returns({ + setDesktopEnabled, + getState: sinon.stub().callsFake((cb) => + cb(null, { + desktopEnabled: true, + }), + ), + }); + + _setBackgroundConnection(background.getApi()); + + await store.dispatch(actions.setDesktopEnabled(true)); + + expect(setDesktopEnabled.calledOnceWith(true)).toBeTruthy(); + }); + }); + }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 14347d234..f1fcf44e4 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4584,3 +4584,27 @@ export function requestAddNetworkApproval( } }; } + +///: BEGIN:ONLY_INCLUDE_IN(desktop) +export function setDesktopEnabled(desktopEnabled: boolean) { + return async () => { + try { + await submitRequestToBackground('setDesktopEnabled', [desktopEnabled]); + } catch (error) { + log.error(error); + } + }; +} + +export async function generateOtp() { + return await submitRequestToBackground('generateOtp'); +} + +export async function testDesktopConnection() { + return await submitRequestToBackground('testDesktopConnection'); +} + +export async function disableDesktop() { + return await submitRequestToBackground('disableDesktop'); +} +///: END:ONLY_INCLUDE_IN