1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 09:23:21 +01:00

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
This commit is contained in:
João Tavares 2023-02-23 16:39:48 +00:00 committed by GitHub
parent 505f1f5445
commit bde74756d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2737 additions and 90 deletions

View File

@ -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"
},

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M64 0C28.7 0 0 28.7 0 64V352c0 35.3 28.7 64 64 64H240l-10.7 32H160c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H346.7L336 416H512c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zM512 64V352H64V64H512z"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -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<string, Function>} EthAccountsOptions
* @property {Function} getAccounts - Gets the accounts for the requesting
* origin.
*/
/**
*
* @param {import('json-rpc-engine').JsonRpcRequest<unknown>} _req - The JSON-RPC request object.
* @param {import('json-rpc-engine').JsonRpcResponse<true>} 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();
}

View File

@ -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;

View File

@ -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();
});

View File

@ -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',
};

View File

@ -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;
}
});
}

View File

@ -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);
});
});
});

View File

@ -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) {
</div>
`;
}
///: 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

View File

@ -1439,7 +1439,8 @@
],
"origin": "tmashuang.github.io"
}
]
],
"desktopEnabled": false
},
"send": {
"amountMode": "INPUT",

View File

@ -33,10 +33,10 @@ I18nProvider.defaultProps = {
children: undefined,
};
export function renderWithProvider(component, store) {
export function renderWithProvider(component, store, initialEntries) {
const Wrapper = ({ children }) => {
const WithoutStore = () => (
<MemoryRouter initialEntries={['/']} initialIndex={0}>
<MemoryRouter initialEntries={initialEntries || ['/']} initialIndex={0}>
<I18nProvider currentLocale="en" current={en} en={en}>
<LegacyI18nProvider>{children}</LegacyI18nProvider>
</I18nProvider>

View File

@ -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 && (
<div data-testid="app-header-desktop-dev-logo">
<MetaFoxLogo
unsetIconHeight
src="./images/logo/desktop.svg"
/>
</div>
)
///: END:ONLY_INCLUDE_IN
}
<div className="app-header__account-menu-container">
{!hideNetworkIndicator && (
<div className="app-header__network-component-wrapper">

View File

@ -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
};
};

View File

@ -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(
<AppHeader />,
desktopEnabledStore,
);
const desktopDevLogo = queryByTestId('app-header-desktop-dev-logo');
expect(desktopDevLogo).not.toBeNull();
});
});
});

View File

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Desktop Enable Button should match snapshot 1`] = `
<div>
<button
class="button btn--rounded btn-primary btn--large"
role="button"
tabindex="0"
>
Enable Desktop App
</button>
</div>
`;

View File

@ -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 (
<Button
type="primary"
large
onClick={(event) => {
event.preventDefault();
onClick();
}}
>
{desktopEnabled ? t('desktopDisableButton') : t('desktopEnableButton')}
</Button>
);
}

View File

@ -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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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(<DesktopEnableButton />, 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();
});
});
});

View File

@ -0,0 +1 @@
export { default } from './desktop-enable-button.component';

View File

@ -0,0 +1,83 @@
import React from 'react';
import PropTypes from 'prop-types';
const IconDesktopPairing = ({
size = 64,
color = 'currentColor',
ariaLabel,
className,
onClick,
}) => (
<svg
width={size}
height={size}
fill={color}
className={className}
aria-label={ariaLabel}
onClick={onClick}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 39"
>
<path
d="M2.05588 38.7539L2.05764 38.7541H40.952H40.9539C40.9557 38.7541 40.9573 38.7536 40.9591 38.7536C42.0608 38.7497 43.0116 37.8498 43.0116 36.6965C43.0112 35.5025 43.0127 41.0218 43.0084 25.0192H41.7318V32.2147H1.27784V8.06471H41.7318V13.7349H43.0084C43.0088 12.6803 43.0073 17.5546 43.0116 3.42458C43.0116 2.33886 42.1096 1.3715 40.9592 1.36749C40.9574 1.36749 40.9557 1.36694 40.9539 1.36694H40.952H2.05826L2.05592 1.3671C0.938953 1.36848 0 2.2928 0 3.42458C0.00118187 40.1604 0.00115779 35.5443 0.00151939 36.7115C0.00442983 37.7954 0.916013 38.7525 2.05588 38.7539ZM40.952 2.64354C40.9545 2.64354 40.9568 2.64459 40.9593 2.64463C41.3592 2.6475 41.7318 2.97087 41.7318 3.42396V6.78811H1.27784V3.42396C1.27784 3.4215 1.27776 3.42143 1.27773 3.41905C1.28066 3.03143 1.60496 2.64481 2.05596 2.64389L2.05826 2.64354H40.952ZM41.7318 33.4913V36.6971C41.7318 37.1484 41.3421 37.4739 40.959 37.4765C40.9566 37.4765 40.9545 37.4775 40.952 37.4775H2.05826L2.05588 37.4772C1.67874 37.4762 1.2822 37.1511 1.278 36.7035C1.278 36.7013 1.27784 36.6993 1.27784 36.6971V33.4913H41.7318Z"
fill="black"
/>
<path d="M8.2473 4.07751H3.19141V5.35411H8.2473V4.07751Z" fill="black" />
<path d="M23.5665 34.8462H18.5106V36.1228H23.5665V34.8462Z" fill="black" />
<path
d="M36.5719 5.16847C36.8185 4.92251 36.8186 4.50823 36.5719 4.26214C36.3294 4.0259 35.9018 4.03244 35.6656 4.26214C35.4197 4.52205 35.4195 4.90836 35.6656 5.16847C35.9317 5.42024 36.3318 5.40917 36.5719 5.16847Z"
fill="black"
/>
<path
d="M58.7234 1.98595V5.13942H44.305V6.41602H58.7234V29.2623H44.305V30.5389H58.7234V33.544C58.7234 33.9351 58.408 34.2533 58.0203 34.2533H44.305V35.5299H58.0203C59.1117 35.5299 60 34.6392 60 33.544C60 26.776 60 9.50087 60 1.98595V1.97972C60 0.888256 59.1117 0 58.0203 0H42.8152V1.2766H58.0203C58.408 1.2766 58.7234 1.59481 58.7234 1.98595Z"
fill="black"
/>
<path d="M51.0488 2.56812H46.2522V3.84471H51.0488V2.56812Z" fill="black" />
<path d="M51.0488 31.7573H46.2522V33.0339H51.0488V31.7573Z" fill="black" />
<path
d="M53.8826 3.65558C54.1329 3.4194 54.1259 2.98539 53.8826 2.7558C53.6463 2.51955 53.2125 2.51955 52.9762 2.7558C52.7244 3.02196 52.7345 3.41386 52.9762 3.65558C53.236 3.91538 53.6465 3.90578 53.8826 3.65558Z"
fill="black"
/>
<path
d="M37.2794 13.3285L34.9387 15.6694L37.2794 18.0101L38.1819 17.1075L37.3822 16.3077H48.899V15.0311H37.382L38.1819 14.2311L37.2794 13.3285Z"
fill="black"
/>
<path
d="M46.5582 24.5231L47.4608 25.4257L49.8015 23.0848L47.4608 20.7441L46.5582 21.6467L47.358 22.4465H35.8412V23.7231H47.3581L46.5582 24.5231Z"
fill="black"
/>
<path d="M33.8298 12.7659H8.29785V14.0425H33.8298V12.7659Z" fill="black" />
<path d="M33.8298 15.9574H8.29785V17.234H33.8298V15.9574Z" fill="black" />
<path d="M33.8298 19.7874H8.29785V21.0639H33.8298V19.7874Z" fill="black" />
<path d="M33.8298 22.9788H8.29785V24.2554H33.8298V22.9788Z" fill="black" />
<path d="M57.0254 12.6194H51.0127V13.896H57.0254V12.6194Z" fill="black" />
<path d="M57.0254 16.0691H51.0127V17.3457H57.0254V16.0691Z" fill="black" />
<path d="M57.0254 19.5188H51.0127V20.7954H57.0254V19.5188Z" fill="black" />
<path d="M57.0254 22.9688H51.0127V24.2453H57.0254V22.9688Z" fill="black" />
</svg>
);
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;

View File

@ -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`.
<svg
width={size}
height={size}
fill={color}
className={className}
aria-label={ariaLabel}
onClick={onClick}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 352 512"
>
<path d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z" />
</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;

View File

@ -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 = () => (
<MetaFoxHorizontalLogo
className={classnames({
'app-header__metafox-logo--horizontal': !isOnboarding,
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
})}
/>
);
let imageSrc = './images/logo/metamask-fox.svg';
///: BEGIN:ONLY_INCLUDE_IN(desktop)
if (src) {
renderHorizontalLogo = () => (
<img
{...iconProps}
src={src}
className={classnames({
'app-header__metafox-logo--horizontal': !isOnboarding,
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
})}
alt=""
/>
);
imageSrc = src;
}
///: END:ONLY_INCLUDE_IN
return (
<div
onClick={onClick}
@ -28,15 +67,11 @@ export default class MetaFoxLogo extends PureComponent {
})}
data-testid="app-header-logo"
>
<MetaFoxHorizontalLogo
className={classnames({
'app-header__metafox-logo--horizontal': !isOnboarding,
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
})}
/>
{renderHorizontalLogo()}
<img
{...iconProps}
src="./images/logo/metamask-fox.svg"
src={imageSrc}
className={classnames({
'app-header__metafox-logo--icon': !isOnboarding,
'onboarding-app-header__metafox-logo--icon': isOnboarding,

View File

@ -166,6 +166,15 @@ export default function reduceMetamask(state = initialState, action) {
};
}
///: BEGIN:ONLY_INCLUDE_IN(desktop)
case actionConstants.FORCE_DISABLE_DESKTOP: {
return {
...metamaskState,
desktopEnabled: false,
};
}
///: END:ONLY_INCLUDE_IN
default:
return metamaskState;
}

View File

@ -222,6 +222,17 @@ describe('MetaMask Reducers', () => {
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', () => {

View File

@ -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
};

14
ui/hooks/desktopHooks.js Normal file
View File

@ -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 };

View File

@ -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}`,
);
});
});
});

View File

@ -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
);
});
});
}

View File

@ -0,0 +1,373 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Desktop Error page should render connection lost page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MetaMask Desktop connection was lost
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
Please make sure you have the desktop app up and running or disable MetaMask Desktop.
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-open-or-download-mmd"
role="button"
tabindex="0"
>
Open MetaMask Desktop
</button>
</div>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-disable-mmd"
role="button"
tabindex="0"
>
Disable MetaMask Desktop
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render critical error page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MetaMask had trouble starting
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
This error could be intermittent, so try restarting the extension or disable MetaMask Desktop.
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-restart-mm"
role="button"
tabindex="0"
>
Restart MetaMask
</button>
</div>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-disable-mmd"
role="button"
tabindex="0"
>
Disable MetaMask Desktop
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render default error page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
Something went wrong...
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
Check your MetaMask Desktop to restore connection
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-return-mm-home"
role="button"
tabindex="0"
>
Return MetaMask Home
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render desktop app outdated page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MetaMask Desktop is outdated
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
Your MetaMask desktop app needs to be upgraded.
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-update-mmd"
role="button"
tabindex="0"
>
Update MetaMask Desktop
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render extension outdated page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MetaMask Extension is outdated
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
Your MetaMask extension needs to be upgraded.
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-update-extension"
role="button"
tabindex="0"
>
Update MetaMask Extension
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render not found page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MetaMask Desktop was not found
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
Please make sure you have the desktop app up and running.
</p>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
If you have no desktop app installed, please download it on the MetaMask website.
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-download-mmd"
role="button"
tabindex="0"
>
Download MetaMask Desktop
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render pairing key not match page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
MM Desktop is already paired
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
If you want to start a new pairing, please remove the current connection.
</p>
<a
class="button btn-link desktop-pairing-warning__link"
role="button"
tabindex="0"
>
Go to Settings in MetaMask Desktop
</a>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-navigate-settings"
role="button"
tabindex="0"
>
Return to Settings Page
</button>
</div>
</div>
</div>
`;
exports[`Desktop Error page should render route not found page 1`] = `
<div>
<div
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="var(--color-error-default"
height="64"
viewBox="0 0 352 512"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
/>
</svg>
<h4
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
>
desktopRouteNotFoundErrorTitle
</h4>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
>
desktopRouteNotFoundErrorDescription
</p>
<div
class="box box--margin-top-6 box--flex-direction-row"
>
<button
class="button btn--rounded btn-primary"
id="desktop-error-button-navigate-settings"
role="button"
tabindex="0"
>
Return to Settings Page
</button>
</div>
</div>
</div>
`;

View File

@ -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,
});
}

View File

@ -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);

View File

@ -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(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
store,
[`/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`],
);
expect(container).toMatchSnapshot();
});
it('should render connection lost page', async () => {
const store = configureStore(mockState);
const { container } = renderWithProvider(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
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(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
store,
[`/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`],
);
expect(container).toMatchSnapshot();
});
it('should render extension outdated page', async () => {
const store = configureStore(mockState);
const { container } = renderWithProvider(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
store,
[`/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`],
);
expect(container).toMatchSnapshot();
});
it('should render critical error page', async () => {
const store = configureStore(mockState);
const { container } = renderWithProvider(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
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(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
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(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
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(
<Route path="/:errorType">
<DesktopErrorPage />
</Route>,
store,
[`/unknown-error-type`],
);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1 @@
export { default } from './desktop-error.container';

View File

@ -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 (
<Typography
variant={TypographyVariant.H4}
fontWeight={FONT_WEIGHT.BOLD}
marginTop={6}
marginBottom={6}
>
{text}
</Typography>
);
};
const renderDescription = (text) => {
return (
<Typography variant={TypographyVariant.Paragraph}>{text}</Typography>
);
};
const renderCTA = (id, text, onClick) => {
return (
<Box marginTop={6}>
<Button type="primary" onClick={onClick ?? noop} id={id}>
{text}
</Button>
</Box>
);
};
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'))}
<Button
type="link"
onClick={() => {
openSettingsOrDownloadMMD();
}}
className="desktop-pairing-warning__link"
>
{t('desktopPairedWarningDeepLink')}
</Button>
{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 = (
<Box
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
textAlign={TEXT_ALIGN.CENTER}
flexDirection={FLEX_DIRECTION.COLUMN}
marginLeft={6}
marginRight={6}
marginTop={isHtmlError ? 8 : 6}
>
<IconTimes size={64} color="var(--color-error-default" />
{content}
</Box>
);
if (isHtmlError) {
return ReactDOMServer.renderToStaticMarkup(errorContent);
}
return errorContent;
}

View File

@ -0,0 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Desktop Pairing page should render otp component 1`] = `
<div>
<div
class="page-container__content"
>
<div
class="desktop-pairing"
>
<div>
<div
class="box box--margin-top-12 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<svg
fill="currentColor"
height="64"
viewBox="0 0 64 39"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.05588 38.7539L2.05764 38.7541H40.952H40.9539C40.9557 38.7541 40.9573 38.7536 40.9591 38.7536C42.0608 38.7497 43.0116 37.8498 43.0116 36.6965C43.0112 35.5025 43.0127 41.0218 43.0084 25.0192H41.7318V32.2147H1.27784V8.06471H41.7318V13.7349H43.0084C43.0088 12.6803 43.0073 17.5546 43.0116 3.42458C43.0116 2.33886 42.1096 1.3715 40.9592 1.36749C40.9574 1.36749 40.9557 1.36694 40.9539 1.36694H40.952H2.05826L2.05592 1.3671C0.938953 1.36848 0 2.2928 0 3.42458C0.00118187 40.1604 0.00115779 35.5443 0.00151939 36.7115C0.00442983 37.7954 0.916013 38.7525 2.05588 38.7539ZM40.952 2.64354C40.9545 2.64354 40.9568 2.64459 40.9593 2.64463C41.3592 2.6475 41.7318 2.97087 41.7318 3.42396V6.78811H1.27784V3.42396C1.27784 3.4215 1.27776 3.42143 1.27773 3.41905C1.28066 3.03143 1.60496 2.64481 2.05596 2.64389L2.05826 2.64354H40.952ZM41.7318 33.4913V36.6971C41.7318 37.1484 41.3421 37.4739 40.959 37.4765C40.9566 37.4765 40.9545 37.4775 40.952 37.4775H2.05826L2.05588 37.4772C1.67874 37.4762 1.2822 37.1511 1.278 36.7035C1.278 36.7013 1.27784 36.6993 1.27784 36.6971V33.4913H41.7318Z"
fill="black"
/>
<path
d="M8.2473 4.07751H3.19141V5.35411H8.2473V4.07751Z"
fill="black"
/>
<path
d="M23.5665 34.8462H18.5106V36.1228H23.5665V34.8462Z"
fill="black"
/>
<path
d="M36.5719 5.16847C36.8185 4.92251 36.8186 4.50823 36.5719 4.26214C36.3294 4.0259 35.9018 4.03244 35.6656 4.26214C35.4197 4.52205 35.4195 4.90836 35.6656 5.16847C35.9317 5.42024 36.3318 5.40917 36.5719 5.16847Z"
fill="black"
/>
<path
d="M58.7234 1.98595V5.13942H44.305V6.41602H58.7234V29.2623H44.305V30.5389H58.7234V33.544C58.7234 33.9351 58.408 34.2533 58.0203 34.2533H44.305V35.5299H58.0203C59.1117 35.5299 60 34.6392 60 33.544C60 26.776 60 9.50087 60 1.98595V1.97972C60 0.888256 59.1117 0 58.0203 0H42.8152V1.2766H58.0203C58.408 1.2766 58.7234 1.59481 58.7234 1.98595Z"
fill="black"
/>
<path
d="M51.0488 2.56812H46.2522V3.84471H51.0488V2.56812Z"
fill="black"
/>
<path
d="M51.0488 31.7573H46.2522V33.0339H51.0488V31.7573Z"
fill="black"
/>
<path
d="M53.8826 3.65558C54.1329 3.4194 54.1259 2.98539 53.8826 2.7558C53.6463 2.51955 53.2125 2.51955 52.9762 2.7558C52.7244 3.02196 52.7345 3.41386 52.9762 3.65558C53.236 3.91538 53.6465 3.90578 53.8826 3.65558Z"
fill="black"
/>
<path
d="M37.2794 13.3285L34.9387 15.6694L37.2794 18.0101L38.1819 17.1075L37.3822 16.3077H48.899V15.0311H37.382L38.1819 14.2311L37.2794 13.3285Z"
fill="black"
/>
<path
d="M46.5582 24.5231L47.4608 25.4257L49.8015 23.0848L47.4608 20.7441L46.5582 21.6467L47.358 22.4465H35.8412V23.7231H47.3581L46.5582 24.5231Z"
fill="black"
/>
<path
d="M33.8298 12.7659H8.29785V14.0425H33.8298V12.7659Z"
fill="black"
/>
<path
d="M33.8298 15.9574H8.29785V17.234H33.8298V15.9574Z"
fill="black"
/>
<path
d="M33.8298 19.7874H8.29785V21.0639H33.8298V19.7874Z"
fill="black"
/>
<path
d="M33.8298 22.9788H8.29785V24.2554H33.8298V22.9788Z"
fill="black"
/>
<path
d="M57.0254 12.6194H51.0127V13.896H57.0254V12.6194Z"
fill="black"
/>
<path
d="M57.0254 16.0691H51.0127V17.3457H57.0254V16.0691Z"
fill="black"
/>
<path
d="M57.0254 19.5188H51.0127V20.7954H57.0254V19.5188Z"
fill="black"
/>
<path
d="M57.0254 22.9688H51.0127V24.2453H57.0254V22.9688Z"
fill="black"
/>
</svg>
</div>
</div>
<div
class="desktop-pairing__title"
>
Pair with Desktop
</div>
<div
class="desktop-pairing__subtitle"
>
Open your MetaMask Desktop and type this code
</div>
</div>
<div
class="desktop-pairing"
>
<div
class="desktop-pairing__clickable"
data-testid="desktop-pairing-otp-content"
>
<div
class="box box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
>
<div
class="desktop-pairing__tooltip-wrapper"
>
<div
aria-describedby="tippy-tooltip-1"
class=""
data-original-title="Copy to clipboard"
data-tooltipped=""
style="display: inline;"
tabindex="0"
>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography desktop-pairing__otp typography--p typography--weight-normal typography--style-normal typography--align-center typography--color-text-default"
>
123456
</p>
</div>
</div>
</div>
<p
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography desktop-pairing__countdown-timer typography--p typography--weight-normal typography--style-normal typography--align-center typography--color-text-default"
>
<span>
Code expires in
<span
class="desktop-pairing__countdown-timer-seconds"
>
30
</span>
seconds
</span>
</p>
<div
class="desktop-pairing__description"
>
If the pairing is successful, extension will restart and you'll have to re-enter your password.
</div>
</div>
</div>
<div
class="desktop-pairing__buttons"
>
<button
class="button btn--rounded btn-primary"
role="button"
tabindex="0"
>
Done
</button>
</div>
</div>
</div>
`;

View File

@ -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 (
<div>
<Box
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
textAlign={TEXT_ALIGN.CENTER}
flexDirection={FLEX_DIRECTION.COLUMN}
marginLeft={6}
marginRight={6}
marginTop={12}
>
<IconDesktopPairing size={64} />
</Box>
</div>
);
};
const goBack = () => {
history?.push(mostRecentOverviewPage);
};
const renderContent = () => {
if (!otp) {
showLoadingIndication();
return null;
}
hideLoadingIndication();
return (
<div
className="desktop-pairing__clickable"
onClick={() => {
handleCopy(otp);
}}
data-testid="desktop-pairing-otp-content"
>
<Box
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
textAlign={TEXT_ALIGN.CENTER}
flexDirection={FLEX_DIRECTION.COLUMN}
marginLeft={6}
marginRight={6}
>
<Tooltip
wrapperClassName="desktop-pairing__tooltip-wrapper"
position="top"
title={copied ? t('copiedExclamation') : t('copyToClipboard')}
>
<Typography
align={TEXT_ALIGN.CENTER}
className="desktop-pairing__otp"
>
{otp}
</Typography>
</Tooltip>
</Box>
<Typography
variant={TypographyVariant.paragraph}
align={TEXT_ALIGN.CENTER}
className="desktop-pairing__countdown-timer"
>
{t('desktopPairingExpireMessage', [
<span className="desktop-pairing__countdown-timer-seconds" key={1}>
{getExpireDuration()}
</span>,
])}
</Typography>
<div className="desktop-pairing__description">
{t('desktopPageDescription')}
</div>
</div>
);
};
const renderFooter = () => {
return (
<div className="desktop-pairing__buttons">
<Button
type="primary"
rounded
onClick={() => {
goBack();
}}
>
{t('done')}
</Button>
</div>
);
};
return (
<div className="page-container__content">
<div className="desktop-pairing">
{renderIcon()}
<div className="desktop-pairing__title">{t('desktopPageTitle')}</div>
<div className="desktop-pairing__subtitle">
{t('desktopPageSubTitle')}
</div>
</div>
<div className="desktop-pairing">{renderContent()}</div>
{renderFooter()}
</div>
);
}
DesktopPairingPage.propTypes = {
mostRecentOverviewPage: PropTypes.string,
showLoadingIndication: PropTypes.func,
hideLoadingIndication: PropTypes.func,
generateOtp: PropTypes.func,
};

View File

@ -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);

View File

@ -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(<DesktopPairingPage />, 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(<DesktopPairingPage />, 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();
});
});

View File

@ -0,0 +1 @@
export { default } from './desktop-pairing.container';

View File

@ -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;
}
}
}

View File

@ -25,3 +25,4 @@
@import 'unlock-page/index';
@import 'onboarding-flow/index';
@import 'notifications/index';
@import 'desktop-pairing/index';

View File

@ -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 {
<Switch>
<Route path={ONBOARDING_ROUTE} component={OnboardingFlow} />
<Route path={LOCK_ROUTE} component={Lock} exact />
{
///: BEGIN:ONLY_INCLUDE_IN(desktop)
<Route
path={`${DESKTOP_ERROR_ROUTE}/:errorType`}
component={DesktopErrorPage}
exact
/>
///: END:ONLY_INCLUDE_IN
}
<Initialized path={UNLOCK_ROUTE} component={UnlockPage} exact />
<RestoreVaultComponent
path={RESTORE_VAULT_ROUTE}
@ -239,6 +280,15 @@ export default class Routes extends Component {
/>
<Authenticated path={`${ASSET_ROUTE}/:asset/:id`} component={Asset} />
<Authenticated path={`${ASSET_ROUTE}/:asset/`} component={Asset} />
{
///: BEGIN:ONLY_INCLUDE_IN(desktop)
<Authenticated
path={DESKTOP_PAIRING_ROUTE}
component={DesktopPairingPage}
exact
/>
///: END:ONLY_INCLUDE_IN
}
<Authenticated path={DEFAULT_ROUTE} component={Home} />
</Switch>
);
@ -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,

View File

@ -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 (
<div ref={this.settingsRefs[9]} className="settings-page__content-row">
<div
ref={this.settingsRefs[9]}
className="settings-page__content-row"
data-testId="ledger-live-control"
>
<div className="settings-page__content-item">
<span>{t('preferredLedgerConnectionType')}</span>
<div className="settings-page__content-description">

View File

@ -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(
<AdvancedTab />,
mockStoreWithDesktopEnabled,
);
expect(queryByTestId('ledger-live-control')).not.toBeInTheDocument();
});
});

View File

@ -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
};
};

View File

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExperimentalTab with desktop enabled renders ExperimentalTab component without error 1`] = `
<div>
<div
class="settings-page__body"
>
<div
class="settings-page__content-row"
data-testid="advanced-setting-desktop-pairing"
>
<div
class="settings-page__content-item"
>
<span>
Click to run all background processes in the desktop app.
</span>
</div>
<div
class="settings-page__content-item"
>
<div
class="settings-page__content-item-col"
>
<button
class="button btn--rounded btn-primary btn--large"
role="button"
tabindex="0"
>
Disable Desktop App
</button>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -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 (
<div
ref={this.settingsRefs[6]}
className="settings-page__content-row"
data-testid="advanced-setting-desktop-pairing"
>
<div className="settings-page__content-item">
<span>{t('desktopEnableButtonDescription')}</span>
</div>
<div className="settings-page__content-item">
<div className="settings-page__content-item-col">
<DesktopEnableButton />
</div>
</div>
</div>
);
}
///: END:ONLY_INCLUDE_IN
render() {
return (
<div className="settings-page__body">
{process.env.TRANSACTION_SECURITY_PROVIDER &&
this.renderTransactionSecurityCheckToggle()}
{process.env.NFTS_V1 && this.renderOpenSeaEnabledToggle()}
{
///: BEGIN:ONLY_INCLUDE_IN(desktop)
this.renderDesktopEnableButton()
///: END:ONLY_INCLUDE_IN
}
</div>
);
}

View File

@ -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(<ExperimentalTab />, 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();
});
});
});

View File

@ -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: <i className="fa fa-flask" />,
@ -365,7 +373,7 @@ class SettingsPage extends PureComponent {
render={() => <AddNetwork />}
/>
<Route exact path={SECURITY_ROUTE} component={SecurityTab} />
{this.shouldRenderExperimentalTab ? (
{this.shouldRenderExperimentalTab() ? (
<Route exact path={EXPERIMENTAL_ROUTE} component={ExperimentalTab} />
) : null}
<Route exact path={CONTACT_LIST_ROUTE} component={ContactListTab} />

View File

@ -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

View File

@ -294,4 +294,9 @@ describe('Selectors', () => {
selectors.getTotalUnapprovedSignatureRequestCount(mockState);
expect(totalUnapprovedSignatureRequestCount).toStrictEqual(0);
});
it('#getIsDesktopEnabled', () => {
const isDesktopEnabled = selectors.getIsDesktopEnabled(mockState);
expect(isDesktopEnabled).toBeFalsy();
});
});

View File

@ -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

View File

@ -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();
});
});
});
});

View File

@ -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