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