diff --git a/.prettierignore b/.prettierignore index 523aeda201..eec1fd43c0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,6 +5,7 @@ !source/ !features/ !storybook/ +!hardware-wallet-tests/ # Now we ignore all files *.* diff --git a/hardware-wallet-tests/cardano-app-already-connected.ts b/hardware-wallet-tests/cardano-app-already-connected.ts new file mode 100644 index 0000000000..75ff4d5249 --- /dev/null +++ b/hardware-wallet-tests/cardano-app-already-connected.ts @@ -0,0 +1,78 @@ +/* eslint-disable jest/no-standalone-expect */ +import expect from 'expect'; + +import { + createAndRegisterHardwareWalletChannels, + createHardwareWalletConnectionChannel, + initLedgerChannel, + createSequentialPromptMessages, + createCardanoAppChannel, + createGetPublicKeyChannel, + ipcRenderer, +} from './utils'; + +export const run = () => { + expect.assertions(3); + + createSequentialPromptMessages([ + 'Plug Ledger Nano S to your computer', + 'Start Cardano APP on Nano S', + 'Run the test again with Cardano App opened', + 'Export the public key', + ]); + + createAndRegisterHardwareWalletChannels(); + + const cardanoAppChannel = createCardanoAppChannel(); + const publicKeyChannel = createGetPublicKeyChannel(); + const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); + + return new Promise((resolve) => { + hardwareWalletConnectionChannel.onReceive( + async (params: { path: string }) => { + expect(params).toEqual({ + disconnected: expect.any(Boolean), + deviceType: expect.any(String), + deviceId: null, + deviceModel: expect.any(String), + deviceName: expect.any(String), + path: expect.any(String), + }); + + const cardanoAppChannelReply = await cardanoAppChannel.request( + { path: params.path }, + ipcRenderer, + ipcRenderer + ); + + expect(cardanoAppChannelReply).toEqual({ + minor: expect.any(Number), + major: expect.any(Number), + patch: expect.any(Number), + deviceId: expect.any(String), + }); + + const extendedPublicKey = await publicKeyChannel.request( + { + path: "1852'/1815'/0'", + // Shelley 1852 ADA 1815 indicator for account '0' + isTrezor: false, + devicePath: params.path, + }, + ipcRenderer, + ipcRenderer + ); + + expect(extendedPublicKey).toEqual({ + chainCodeHex: expect.any(String), + publicKeyHex: expect.any(String), + deviceId: expect.any(String), + }); + + resolve(null); + } + ); + + initLedgerChannel(); + }); +}; diff --git a/hardware-wallet-tests/cardano-app-not-started.ts b/hardware-wallet-tests/cardano-app-not-started.ts new file mode 100644 index 0000000000..fb71d6919d --- /dev/null +++ b/hardware-wallet-tests/cardano-app-not-started.ts @@ -0,0 +1,78 @@ +/* eslint-disable jest/no-standalone-expect */ +import expect from 'expect'; + +import { + createAndRegisterHardwareWalletChannels, + createHardwareWalletConnectionChannel, + initLedgerChannel, + createSequentialPromptMessages, + createGetPublicKeyChannel, + ipcRenderer, + pollCardarnoApp, +} from './utils'; + +export const run = () => { + expect.assertions(3); + + createSequentialPromptMessages([ + 'Start test runner', + 'Plug Ledger Nano S to your computer', + 'Start Cardano APP on Nano S', + 'Nano S will prompt to export the public key', + 'Export the public key', + ]); + + createAndRegisterHardwareWalletChannels(); + + const publicKeyChannel = createGetPublicKeyChannel(); + const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); + + return new Promise((resolve) => { + hardwareWalletConnectionChannel.onReceive( + async (params: { path: string }) => { + expect(params).toEqual({ + disconnected: expect.any(Boolean), + deviceType: expect.any(String), + deviceId: null, + deviceModel: expect.any(String), + deviceName: expect.any(String), + path: expect.any(String), + }); + + try { + const cardanoAppChannelReply = await pollCardarnoApp(params.path); + + expect(cardanoAppChannelReply).toEqual({ + minor: expect.any(Number), + major: expect.any(Number), + patch: expect.any(Number), + deviceId: expect.any(String), + }); + + const extendedPublicKey = await publicKeyChannel.request( + { + path: "1852'/1815'/0'", + // Shelley 1852 ADA 1815 indicator for account '0' + isTrezor: false, + devicePath: params.path, + }, + ipcRenderer, + ipcRenderer + ); + + expect(extendedPublicKey).toEqual({ + chainCodeHex: expect.any(String), + publicKeyHex: expect.any(String), + deviceId: expect.any(String), + }); + + resolve(null); + } catch (err) { + return null; + } + } + ); + + initLedgerChannel(); + }); +}; diff --git a/hardware-wallet-tests/index.ts b/hardware-wallet-tests/index.ts new file mode 100644 index 0000000000..256512e0a1 --- /dev/null +++ b/hardware-wallet-tests/index.ts @@ -0,0 +1,69 @@ +import prompts from 'prompts'; + +import { run as runRemoveMultipleHardwareWallets } from './remove-multiple-hardware-wallets'; +import { run as runCardanoAppAlreadyConnected } from './cardano-app-already-connected'; +import { run as runCardanoAppNotStarted } from './cardano-app-not-started'; +import { run as runRemoveSingleHardwareWallet } from './remove-single-hardware-wallet'; +import { run as runMultipleHardwareWallets } from './multiple-hardware-wallets'; + +const CARDANO_APP_ALREADY_LAUNCHED = 'CARDANO_APP_ALREADY_LAUNCHED'; +const CARDANO_APP_NOT_STARTED = 'CARDANO_APP_NOT_STARTED'; +const SINGLE_LEDGER_REMOVED = 'SINGLE_LEDGER_REMOVED'; +const MULTIPLE_HARDWARE_WALLETS = 'MULTIPLE_HARDWARE_WALLETS'; +const MULTIPLE_HARDWARE_WALLETS_REMOVED = 'MULTIPLE_HARDWARE_WALLETS_REMOVED'; + +(async () => { + const { testType } = await prompts({ + type: 'select', + name: 'testType', + message: 'Ledger Hardware Wallets Channel', + choices: [ + { + title: 'export public key when Cardano APP is already connected', + value: CARDANO_APP_ALREADY_LAUNCHED, + }, + { + title: + 'export public key when Cardano APP is launched after test runner already has started', + value: CARDANO_APP_NOT_STARTED, + }, + { title: 'detect when ledger is removed', value: SINGLE_LEDGER_REMOVED }, + { + title: 'connect both Nano S and Nano X', + value: MULTIPLE_HARDWARE_WALLETS, + }, + { + title: 'detect when multiple hardware wallets are removed', + value: MULTIPLE_HARDWARE_WALLETS_REMOVED, + }, + { title: 'exit', value: 'exit' }, + ], + }); + + switch (testType) { + case CARDANO_APP_ALREADY_LAUNCHED: + await runCardanoAppAlreadyConnected(); + break; + + case CARDANO_APP_NOT_STARTED: + await runCardanoAppNotStarted(); + break; + + case SINGLE_LEDGER_REMOVED: + await runRemoveSingleHardwareWallet(); + break; + + case MULTIPLE_HARDWARE_WALLETS: + await runMultipleHardwareWallets(); + break; + + case MULTIPLE_HARDWARE_WALLETS_REMOVED: + await runRemoveMultipleHardwareWallets(); + break; + + default: + break; + } + + process.exit(0); +})(); diff --git a/hardware-wallet-tests/multiple-hardware-wallets.ts b/hardware-wallet-tests/multiple-hardware-wallets.ts new file mode 100644 index 0000000000..88b20edbba --- /dev/null +++ b/hardware-wallet-tests/multiple-hardware-wallets.ts @@ -0,0 +1,52 @@ +/* eslint-disable jest/no-standalone-expect */ +import expect from 'expect'; + +import { + createAndRegisterHardwareWalletChannels, + createHardwareWalletConnectionChannel, + createSequentialResult, + initLedgerChannel, + createSequentialPromptMessages, +} from './utils'; + +export const run = () => { + expect.assertions(2); + + createAndRegisterHardwareWalletChannels(); + + const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); + + const promptMessages = createSequentialPromptMessages([ + 'Start test runner', + 'Plug Ledger Nano S to your computer', + 'Plug Ledger Nano X to your computer', + ]); + + promptMessages(); + + const expectedSequence = createSequentialResult([ + { + disconnected: false, + deviceModel: 'nanoS', + }, + { + disconnected: false, + deviceModel: 'nanoX', + }, + ]); + + return new Promise((resolve) => { + hardwareWalletConnectionChannel.onReceive( + async (params: { path: string; deviceModel: string }) => { + const [expectedValue, isOver] = expectedSequence(); + expect(params).toEqual(expectedValue); + + if (isOver) { + resolve(null); + } + } + ); + + initLedgerChannel(); + }); +}; diff --git a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts new file mode 100644 index 0000000000..44e3dd3460 --- /dev/null +++ b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts @@ -0,0 +1,64 @@ +/* eslint-disable jest/no-standalone-expect */ +import expect from 'expect'; + +import { + createAndRegisterHardwareWalletChannels, + createHardwareWalletConnectionChannel, + createSequentialResult, + initLedgerChannel, + createSequentialPromptMessages, +} from './utils'; + +const expectedSequence = createSequentialResult([ + { + disconnected: false, + deviceModel: 'nanoS', + }, + { + disconnected: false, + deviceModel: 'nanoX', + }, + { + disconnected: true, + deviceModel: 'nanoS', + }, + { + disconnected: true, + deviceModel: 'nanoX', + }, +]); + +export const run = () => { + expect.assertions(4); + + createAndRegisterHardwareWalletChannels(); + + const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); + + const promptMessages = createSequentialPromptMessages([ + 'Connect Nano S', + 'Connect Nano X', + 'Disconnect Nano S', + 'Disconnect Nano X', + ]); + + promptMessages(); + + return new Promise((resolve) => { + hardwareWalletConnectionChannel.onReceive( + async (params: { path: string; deviceModel: string }) => { + const [expectedValue, isOver] = expectedSequence(); + + expect(params).toEqual(expectedValue); + + if (isOver) { + return resolve(null); + } + + promptMessages(); + } + ); + + initLedgerChannel(); + }); +}; diff --git a/hardware-wallet-tests/remove-single-hardware-wallet.ts b/hardware-wallet-tests/remove-single-hardware-wallet.ts new file mode 100644 index 0000000000..58ebadf95d --- /dev/null +++ b/hardware-wallet-tests/remove-single-hardware-wallet.ts @@ -0,0 +1,48 @@ +/* eslint-disable jest/no-standalone-expect */ +import expect from 'expect'; + +import { + createAndRegisterHardwareWalletChannels, + createHardwareWalletConnectionChannel, + initLedgerChannel, + createSequentialPromptMessages, + createSequentialResult, +} from './utils'; + +export const run = () => { + expect.assertions(3); + + const promptMessages = createSequentialPromptMessages([ + 'Plug Ledger Nano S to your computer', + 'Disconnect Nano S', + ]); + + createAndRegisterHardwareWalletChannels(); + + const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); + + const expectedSequence = createSequentialResult([ + { + disconnected: false, + }, + { + disconnected: true, + }, + ]); + + promptMessages(); + + return new Promise((resolve) => { + hardwareWalletConnectionChannel.onReceive( + async (params: { path: string }) => { + const [expectedValue, isOver] = expectedSequence(); + expect(params).toEqual(expectedValue); + + if (isOver) return resolve(null); + promptMessages(); + } + ); + + initLedgerChannel(); + }); +}; diff --git a/hardware-wallet-tests/utils.ts b/hardware-wallet-tests/utils.ts new file mode 100644 index 0000000000..0327d3c285 --- /dev/null +++ b/hardware-wallet-tests/utils.ts @@ -0,0 +1,119 @@ +import expect from 'expect'; +import chalk from 'chalk'; +import createIPCMock from 'electron-mock-ipc'; +import { + IpcChannel, + IpcReceiver, + IpcSender, +} from '../source/common/ipc/lib/IpcChannel'; + +import { createChannels } from '../source/main/ipc/createHardwareWalletIPCChannels'; +import { handleHardwareWalletRequests } from '../source/main/ipc/getHardwareWalletChannel'; + +const mocked = createIPCMock(); +const { ipcMain } = mocked; +const { ipcRenderer } = mocked; + +export { ipcMain, ipcRenderer }; + +export class MockIpcChannel extends IpcChannel< + Incoming, + Outgoing +> { + async send( + message: Outgoing, + sender: IpcSender = ipcRenderer, + receiver: IpcReceiver = ipcRenderer + ): Promise { + return super.send(message, sender, receiver); + } + + async request( + message: Outgoing, + sender: IpcSender = ipcMain, + receiver: IpcReceiver = ipcMain + ): Promise { + return super.request(message, sender, receiver); + } + + onReceive( + handler: (message: Incoming) => Promise, + receiver: IpcReceiver = ipcMain + ): void { + super.onReceive(handler, receiver); + } + + onRequest( + handler: (arg0: Incoming) => Promise, + receiver: IpcReceiver = ipcMain + ): void { + super.onRequest(handler, receiver); + } +} + +export const createAndRegisterHardwareWalletChannels = () => + // @ts-ignore fix-me later + handleHardwareWalletRequests(ipcRenderer, createChannels(MockIpcChannel)); + +export const initLedgerChannel = () => { + const initLedgerConnectChannel = new MockIpcChannel( + 'GET_INIT_LEDGER_CONNECT_CHANNEL' + ); + initLedgerConnectChannel.request({}, ipcRenderer, ipcMain); +}; + +export const createCardanoAppChannel = () => + new MockIpcChannel('GET_CARDANO_ADA_APP_CHANNEL'); + +export const createGetPublicKeyChannel = () => + new MockIpcChannel('GET_EXTENDED_PUBLIC_KEY_CHANNEL'); + +export const createHardwareWalletConnectionChannel = () => + new MockIpcChannel('GET_HARDWARE_WALLET_CONNECTION_CHANNEL'); + +export const pollCardarnoApp = (deviceId: string) => + new Promise((resolve, reject) => { + const cardanoAppChannel = createCardanoAppChannel(); + + const interval = setInterval(async () => { + try { + const cardanoAppChannelReply = await cardanoAppChannel.request( + { path: deviceId }, + ipcRenderer, + ipcRenderer + ); + clearInterval(interval); + return resolve(cardanoAppChannelReply); + } catch (err) { + if (err.code === 'DEVICE_NOT_CONNECTED') { + clearInterval(interval); + return reject(err); + } + return null; + } + }, 2000); + }); + +export const createSequentialResult = (sequence) => { + const common = { + disconnected: expect.any(Boolean), + deviceType: expect.any(String), + deviceId: null, + deviceModel: expect.any(String), + deviceName: expect.any(String), + path: expect.any(String), + }; + + const result = sequence.map((s) => ({ ...common, ...s })); + + return () => [result.shift(), result.length === 0]; +}; + +export const log = (message: string) => + console.log(chalk.whiteBright.bgBlackBright.bold(message)); // eslint-disable-line no-console + +export const createSequentialPromptMessages = (messages: string[]) => { + messages.forEach((m, i) => log(`${i + 1} - ${m}`)); + + return () => log(messages.shift()); +}; diff --git a/package.json b/package.json index 30d27dd123..4c8a8cef3f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "IS_WATCH_MODE=true gulp dev", "dev:windows": "cross-env DAEDALUS_INSTALL_DIRECTORY=\"C:\\Program Files\\Daedalus Testnet\" LAUNCHER_CONFIG=\"C:\\Program Files\\Daedalus Testnet\\launcher-config.yaml\" gulp dev", "test": "NODE_ENV=test yarn build && yarn test:unit && yarn test:e2e:fail-fast", - "test:jest": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:jest": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", "test:generate:report": "ts-node tests/reporter.ts", "test:unit": "yarn cucumber:run --require 'tests/**/unit/**/*.ts' --tags '@unit and not @skip and not @wip'", "test:unit:rerun": "yarn cucumber:rerun --require 'tests/**/unit/**/*.ts' --tags '@unit and not @skip and not @wip'", @@ -25,6 +25,7 @@ "test:e2e:rerun:fail-fast": "yarn cucumber:rerun --require 'tests/setup-e2e.ts' --require 'tests/**/e2e/**/*.ts' --tags '@e2e and not @skip and not @wip'", "test:e2e:watch": "gulp test:e2e:watch", "test:e2e:watch:once": "KEEP_APP_AFTER_TESTS=true yarn test:e2e --tags '@e2e and @watch'", + "test:hardware-wallets": "ts-node hardware-wallet-tests/index.ts", "cucumber": "cross-env NODE_ENV=test cucumber-js --require 'tests/setup-common.ts' --require-module @babel/register -f json:tests-report/report-data.json -f summary:tests-report/summary.log -f node_modules/cucumber-pretty:tests-report/results.log --format-options '{\"snippetInterface\": \"async-await\"}' -f node_modules/cucumber-pretty --format-options '{\"snippetInterface\": \"async-await\"}' -f rerun:tests/@rerun.txt", "cucumber:run": "yarn cucumber tests", "cucumber:fail-fast": "yarn cucumber tests --fail-fast", @@ -125,6 +126,7 @@ "electron-chromedriver": "16.0.0", "electron-connect": "0.6.3", "electron-devtools-installer": "3.2.0", + "electron-mock-ipc": "0.3.12", "electron-packager": "15.4.0", "electron-rebuild": "2.0.1", "eslint": "7.10.0", @@ -164,6 +166,7 @@ "prettier": "2.1.2", "pretty-quick": "3.0.2", "prettysize": "2.0.0", + "prompts": "2.4.2", "raw-loader": "1.0.0", "react-intl-translations-manager": "5.0.3", "react-syntax-highlighter": "13.5.3", diff --git a/source/common/ipc/lib/IpcChannel.ts b/source/common/ipc/lib/IpcChannel.ts index 4d1e3e4643..63d0da8548 100644 --- a/source/common/ipc/lib/IpcChannel.ts +++ b/source/common/ipc/lib/IpcChannel.ts @@ -17,13 +17,38 @@ export type IpcReceiver = { ) => void; }; +export interface Channel { + send( + message: Outgoing, + sender: IpcSender, + receiver?: IpcReceiver + ): Promise; + + request( + message: Outgoing, + sender: IpcSender, + receiver: IpcReceiver + ): Promise; + + onReceive( + handler: (message: Incoming) => Promise, + receiver: IpcReceiver + ): void; + + onRequest( + handler: (arg0: Incoming) => Promise, + receiver?: IpcReceiver + ): void; +} + /** * Provides a coherent, typed api for working with electron * ipc messages over named channels. Where possible it uses * promises to reduce the necessary boilerplate for request * and response cycles. */ -export class IpcChannel { +export class IpcChannel + implements Channel { /** * Each ipc channel should be a singleton (based on the channelName) * Here we track the created instances. @@ -74,7 +99,7 @@ export class IpcChannel { async send( message: Outgoing, sender: IpcSender, - receiver: IpcReceiver + receiver?: IpcReceiver ): Promise { return new Promise((resolve, reject) => { sender.send(this._broadcastChannel, message); @@ -144,7 +169,7 @@ export class IpcChannel { */ onRequest( handler: (arg0: Incoming) => Promise, - receiver: IpcReceiver + receiver?: IpcReceiver ): void { receiver.on( this._requestChannel, diff --git a/source/main/ipc/createHardwareWalletIPCChannels.ts b/source/main/ipc/createHardwareWalletIPCChannels.ts new file mode 100644 index 0000000000..6abed87ac6 --- /dev/null +++ b/source/main/ipc/createHardwareWalletIPCChannels.ts @@ -0,0 +1,126 @@ +import { IpcChannel } from '../../common/ipc/lib/IpcChannel'; +import { + deriveAddressMainResponse, + deriveAddressRendererRequest, + deriveXpubMainResponse, + deriveXpubRendererRequest, + getCardanoAdaAppMainResponse, + getCardanoAdaAppRendererRequest, + getExtendedPublicKeyMainResponse, + getExtendedPublicKeyRendererRequest, + getHardwareWalletConnectiontMainRequest, + getHardwareWalletConnectiontRendererResponse, + getHardwareWalletTransportMainResponse, + getHardwareWalletTransportRendererRequest, + handleInitLedgerConnectMainResponse, + handleInitLedgerConnectRendererRequest, + handleInitTrezorConnectMainResponse, + handleInitTrezorConnectRendererRequest, + resetTrezorActionMainResponse, + resetTrezorActionRendererRequest, + showAddressMainResponse, + showAddressRendererRequest, + signTransactionLedgerMainResponse, + signTransactionLedgerRendererRequest, + signTransactionTrezorMainResponse, + signTransactionTrezorRendererRequest, + DERIVE_ADDRESS_CHANNEL, + DERIVE_XPUB_CHANNEL, + GET_CARDANO_ADA_APP_CHANNEL, + GET_EXTENDED_PUBLIC_KEY_CHANNEL, + GET_HARDWARE_WALLET_CONNECTION_CHANNEL, + GET_HARDWARE_WALLET_TRANSPORT_CHANNEL, + GET_INIT_LEDGER_CONNECT_CHANNEL, + GET_INIT_TREZOR_CONNECT_CHANNEL, + RESET_ACTION_TREZOR_CHANNEL, + SHOW_ADDRESS_CHANNEL, + SIGN_TRANSACTION_LEDGER_CHANNEL, + SIGN_TRANSACTION_TREZOR_CHANNEL, +} from '../../common/ipc/api'; + +export interface HardwareWalletChannels { + getHardwareWalletTransportChannel: IpcChannel< + getHardwareWalletTransportRendererRequest, + getHardwareWalletTransportMainResponse + >; + getExtendedPublicKeyChannel: IpcChannel< + getExtendedPublicKeyRendererRequest, + getExtendedPublicKeyMainResponse + >; + getCardanoAdaAppChannel: IpcChannel< + getCardanoAdaAppRendererRequest, + getCardanoAdaAppMainResponse + >; + + getHardwareWalletConnectionChannel: IpcChannel< + getHardwareWalletConnectiontMainRequest, + getHardwareWalletConnectiontRendererResponse + >; + + signTransactionLedgerChannel: IpcChannel< + signTransactionLedgerRendererRequest, + signTransactionLedgerMainResponse + >; + + signTransactionTrezorChannel: IpcChannel< + signTransactionTrezorRendererRequest, + signTransactionTrezorMainResponse + >; + + resetTrezorActionChannel: IpcChannel< + resetTrezorActionRendererRequest, + resetTrezorActionMainResponse + >; + + handleInitTrezorConnectChannel: IpcChannel< + handleInitTrezorConnectRendererRequest, + handleInitTrezorConnectMainResponse + >; + + handleInitLedgerConnectChannel: IpcChannel< + handleInitLedgerConnectRendererRequest, + handleInitLedgerConnectMainResponse + >; + + deriveXpubChannel: IpcChannel< + deriveXpubRendererRequest, + deriveXpubMainResponse + >; + + deriveAddressChannel: IpcChannel< + deriveAddressRendererRequest, + deriveAddressMainResponse + >; + + showAddressChannel: IpcChannel< + showAddressRendererRequest, + showAddressMainResponse + >; +} + +export const createChannels = ( + Channel: typeof IpcChannel +): HardwareWalletChannels => { + return { + getHardwareWalletTransportChannel: new Channel( + GET_HARDWARE_WALLET_TRANSPORT_CHANNEL + ), + getExtendedPublicKeyChannel: new Channel(GET_EXTENDED_PUBLIC_KEY_CHANNEL), + getCardanoAdaAppChannel: new Channel(GET_CARDANO_ADA_APP_CHANNEL), + getHardwareWalletConnectionChannel: new Channel( + GET_HARDWARE_WALLET_CONNECTION_CHANNEL + ), + signTransactionLedgerChannel: new Channel(SIGN_TRANSACTION_LEDGER_CHANNEL), + signTransactionTrezorChannel: new Channel(SIGN_TRANSACTION_TREZOR_CHANNEL), + resetTrezorActionChannel: new Channel(RESET_ACTION_TREZOR_CHANNEL), + handleInitTrezorConnectChannel: new Channel( + GET_INIT_TREZOR_CONNECT_CHANNEL + ), + handleInitLedgerConnectChannel: new Channel( + GET_INIT_LEDGER_CONNECT_CHANNEL + ), + deriveXpubChannel: new Channel(DERIVE_XPUB_CHANNEL), + deriveAddressChannel: new Channel(DERIVE_ADDRESS_CHANNEL), + showAddressChannel: new Channel(SHOW_ADDRESS_CHANNEL), + }; +}; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 01a7ca2c6e..6cae1f95aa 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -1,7 +1,9 @@ +import { BrowserWindow } from 'electron'; +import moment from 'moment'; import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'; +import { identifyUSBProductId } from '@ledgerhq/devices'; import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; import AppAda, { utils } from '@cardano-foundation/ledgerjs-hw-app-cardano'; -import { BrowserWindow } from 'electron'; import TrezorConnect, { DEVICE, DEVICE_EVENT, @@ -12,49 +14,12 @@ import TrezorConnect, { } from 'trezor-connect'; import { find, get, includes, last, omit } from 'lodash'; import { derivePublic as deriveChildXpub } from 'cardano-crypto.js'; -import { MainIpcChannel } from './lib/MainIpcChannel'; -import type { - deriveAddressMainResponse, - deriveAddressRendererRequest, - deriveXpubMainResponse, - deriveXpubRendererRequest, - getCardanoAdaAppMainResponse, - getCardanoAdaAppRendererRequest, - getExtendedPublicKeyMainResponse, - getExtendedPublicKeyRendererRequest, - getHardwareWalletConnectionMainRequest, - getHardwareWalletConnectionRendererResponse, - getHardwareWalletTransportMainResponse, - getHardwareWalletTransportRendererRequest, - handleInitLedgerConnectMainResponse, - handleInitLedgerConnectRendererRequest, - handleInitTrezorConnectMainResponse, - handleInitTrezorConnectRendererRequest, - resetTrezorActionMainResponse, - resetTrezorActionRendererRequest, - showAddressMainResponse, - showAddressRendererRequest, - signTransactionLedgerMainResponse, - signTransactionLedgerRendererRequest, - signTransactionTrezorMainResponse, - signTransactionTrezorRendererRequest, -} from '../../common/ipc/api'; -import { - DERIVE_ADDRESS_CHANNEL, - DERIVE_XPUB_CHANNEL, - GET_CARDANO_ADA_APP_CHANNEL, - GET_EXTENDED_PUBLIC_KEY_CHANNEL, - GET_HARDWARE_WALLET_CONNECTION_CHANNEL, - GET_HARDWARE_WALLET_TRANSPORT_CHANNEL, - GET_INIT_LEDGER_CONNECT_CHANNEL, - GET_INIT_TREZOR_CONNECT_CHANNEL, - RESET_ACTION_TREZOR_CHANNEL, - SHOW_ADDRESS_CHANNEL, - SIGN_TRANSACTION_LEDGER_CHANNEL, - SIGN_TRANSACTION_TREZOR_CHANNEL, -} from '../../common/ipc/api'; +import { listenDevices } from './listenDevices'; +import { IpcSender } from '../../common/ipc/lib/IpcChannel'; import { logger } from '../utils/logging'; -import type { HardwareWalletTransportDeviceRequest } from '../../common/types/hardware-wallets.types'; +import { HardwareWalletTransportDeviceRequest } from '../../common/types/hardware-wallets.types'; + +import { HardwareWalletChannels } from './createHardwareWalletIPCChannels'; type ListenerType = { unsubscribe: (...args: Array) => any; @@ -67,67 +32,27 @@ export const ledgerStatus: ledgerStatusType = { listening: false, Listener: null, }; -const getHardwareWalletTransportChannel: MainIpcChannel< - getHardwareWalletTransportRendererRequest, - getHardwareWalletTransportMainResponse -> = new MainIpcChannel(GET_HARDWARE_WALLET_TRANSPORT_CHANNEL); -const getExtendedPublicKeyChannel: MainIpcChannel< - getExtendedPublicKeyRendererRequest, - getExtendedPublicKeyMainResponse -> = new MainIpcChannel(GET_EXTENDED_PUBLIC_KEY_CHANNEL); -const getCardanoAdaAppChannel: MainIpcChannel< - getCardanoAdaAppRendererRequest, - getCardanoAdaAppMainResponse -> = new MainIpcChannel(GET_CARDANO_ADA_APP_CHANNEL); -const getHardwareWalletConnectionChannel: MainIpcChannel< - getHardwareWalletConnectionMainRequest, - getHardwareWalletConnectionRendererResponse -> = new MainIpcChannel(GET_HARDWARE_WALLET_CONNECTION_CHANNEL); -const signTransactionLedgerChannel: MainIpcChannel< - signTransactionLedgerRendererRequest, - signTransactionLedgerMainResponse -> = new MainIpcChannel(SIGN_TRANSACTION_LEDGER_CHANNEL); -const signTransactionTrezorChannel: MainIpcChannel< - signTransactionTrezorRendererRequest, - signTransactionTrezorMainResponse -> = new MainIpcChannel(SIGN_TRANSACTION_TREZOR_CHANNEL); -const resetTrezorActionChannel: MainIpcChannel< - resetTrezorActionRendererRequest, - resetTrezorActionMainResponse -> = new MainIpcChannel(RESET_ACTION_TREZOR_CHANNEL); -const handleInitTrezorConnectChannel: MainIpcChannel< - handleInitTrezorConnectRendererRequest, - handleInitTrezorConnectMainResponse -> = new MainIpcChannel(GET_INIT_TREZOR_CONNECT_CHANNEL); -const handleInitLedgerConnectChannel: MainIpcChannel< - handleInitLedgerConnectRendererRequest, - handleInitLedgerConnectMainResponse -> = new MainIpcChannel(GET_INIT_LEDGER_CONNECT_CHANNEL); -const deriveXpubChannel: MainIpcChannel< - deriveXpubRendererRequest, - deriveXpubMainResponse -> = new MainIpcChannel(DERIVE_XPUB_CHANNEL); -const deriveAddressChannel: MainIpcChannel< - deriveAddressRendererRequest, - deriveAddressMainResponse -> = new MainIpcChannel(DERIVE_ADDRESS_CHANNEL); -const showAddressChannel: MainIpcChannel< - showAddressRendererRequest, - showAddressMainResponse -> = new MainIpcChannel(SHOW_ADDRESS_CHANNEL); let devicesMemo = {}; class EventObserver { - constructor(props) { - // @ts-ignore - this.mainWindow = props; + mainWindow: IpcSender; + getHardwareWalletConnectionChannel: HardwareWalletChannels['getHardwareWalletConnectionChannel']; + + constructor({ + mainWindow, + getHardwareWalletConnectionChannel, + }: { + mainWindow: IpcSender; + getHardwareWalletConnectionChannel: HardwareWalletChannels['getHardwareWalletConnectionChannel']; + }) { + this.mainWindow = mainWindow; + this.getHardwareWalletConnectionChannel = getHardwareWalletConnectionChannel; } next = async (event) => { try { const transportList = await TransportNodeHid.list(); const connectionChanged = event.type === 'add' || event.type === 'remove'; - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info( `[HW-DEBUG] Ledger NEXT: , ${JSON.stringify({ event, @@ -137,18 +62,15 @@ class EventObserver { ); if (connectionChanged) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] Ledger NEXT - connection changed'); const device = get(event, 'device', {}); const deviceModel = get(event, 'deviceModel', {}); if (event.type === 'add') { if (!devicesMemo[device.path]) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] CONSTRUCTOR ADD'); try { - // @ts-ignore const transport = await TransportNodeHid.open(device.path); const AdaConnection = new AppAda(transport); devicesMemo[device.path] = { @@ -156,7 +78,7 @@ class EventObserver { transport, AdaConnection, }; - getHardwareWalletConnectionChannel.send( + this.getHardwareWalletConnectionChannel.send( { disconnected: false, deviceType: 'ledger', @@ -167,19 +89,17 @@ class EventObserver { deviceName: deviceModel.productName, // e.g. Test Name path: device.path, - }, // @ts-ignore + }, this.mainWindow ); } catch (e) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] CONSTRUCTOR error'); } } } else { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] CONSTRUCTOR REMOVE'); devicesMemo = omit(devicesMemo, [device.path]); - getHardwareWalletConnectionChannel.send( + this.getHardwareWalletConnectionChannel.send( { disconnected: true, deviceType: 'ledger', @@ -190,37 +110,46 @@ class EventObserver { deviceName: deviceModel.productName, // e.g. Test Name path: device.path, - }, // @ts-ignore + }, this.mainWindow ); } - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] CONSTRUCTOR Memo'); } else { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] Ledger NEXT - connection NOT changed'); } } catch (error) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.error(`[HW-DEBUG] Error on NEXT ${JSON.stringify(error)}`); } }; error(e) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] Ledger NEXT error'); throw e; } complete() { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] Ledger NEXT complete'); } } export const handleHardwareWalletRequests = async ( - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + { + getHardwareWalletTransportChannel, + getExtendedPublicKeyChannel, + getCardanoAdaAppChannel, + getHardwareWalletConnectionChannel, + signTransactionLedgerChannel, + signTransactionTrezorChannel, + resetTrezorActionChannel, + handleInitTrezorConnectChannel, + handleInitLedgerConnectChannel, + deriveXpubChannel, + deriveAddressChannel, + showAddressChannel, + }: HardwareWalletChannels ) => { let deviceConnection = null; let observer; @@ -469,21 +398,27 @@ export const handleHardwareWalletRequests = async ( }); }); handleInitLedgerConnectChannel.onRequest(async () => { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] INIT LEDGER'); - observer = new EventObserver(mainWindow); + observer = new EventObserver({ + mainWindow: (mainWindow as unknown) as IpcSender, + getHardwareWalletConnectionChannel, + }); try { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] OBSERVER INIT'); - TransportNodeHid.setListenDevicesDebounce(1000); // Defaults to 500ms - ledgerStatus.Listener = TransportNodeHid.listen(observer); - ledgerStatus.listening = true; - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. + const onAdd = (payload) => { + observer.next(payload); + }; + + const onRemove = (payload) => { + observer.next(payload); + }; + + listenDevices(onAdd, onRemove); + logger.info('[HW-DEBUG] OBSERVER INIT - listener started'); } catch (e) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] OBSERVER INIT FAILED'); ledgerStatus.listening = false; } diff --git a/source/main/ipc/index.ts b/source/main/ipc/index.ts index 70eb072ff9..522d5675d7 100644 --- a/source/main/ipc/index.ts +++ b/source/main/ipc/index.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow } from 'electron'; +import { BrowserWindow } from 'electron'; import compressLogsApi from './compress-logs'; import downloadLogsApi from './download-logs'; import { handleElectronStoreChannel } from './electronStoreConversation'; @@ -21,6 +21,8 @@ import { handleAddressIntrospectionRequests } from './introspect-address'; import { handleManageAppUpdateRequests } from './manageAppUpdateChannel'; import { openExternalUrlChannel } from './open-external-url'; import { openLocalDirectoryChannel } from './open-local-directory'; +import { MainIpcChannel } from './lib/MainIpcChannel'; +import { createChannels } from './createHardwareWalletIPCChannels'; export default (window: BrowserWindow) => { compressLogsApi(); @@ -46,5 +48,5 @@ export default (window: BrowserWindow) => { downloadManagerChannel(window); getRecoveryWalletIdChannel(); handleElectronStoreChannel(); - handleHardwareWalletRequests(window); + handleHardwareWalletRequests(window, createChannels(MainIpcChannel)); }; diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts new file mode 100644 index 0000000000..aa19dfda42 --- /dev/null +++ b/source/main/ipc/listenDevices.ts @@ -0,0 +1,116 @@ +import { identifyUSBProductId } from '@ledgerhq/devices'; +import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; + +import { log } from '@ledgerhq/logs'; +import debounce from 'lodash/debounce'; + +export type Device = { + vendorId: number; + productId: number; + path: string; + deviceName: string; + manufacturer: string; + serialNumber: string; + deviceAddress: number; + product: string; + release: number; + interface: number; + usagePage: number; + usage: number; +}; + +let usbDebounce = 100; +export const setUsbDebounce = (n: number) => { + usbDebounce = n; +}; + +let listDevices = getDevices(); + +const flatDevice = (d: Device) => d.path; + +const getFlatDevices = () => [ + ...new Set(getDevices().map((d: Device) => flatDevice(d))), +]; + +const getDeviceByPaths = (paths) => + listDevices.find((d: Device) => paths.includes(flatDevice(d))); + +const lastDevices = new Map(); + +const addLastDevice = (newDevices: Device[]) => + newDevices.forEach((d) => lastDevices.set(d.path, d)); +addLastDevice(listDevices); + +const getPayloadData = (type: 'add' | 'remove', device: Device) => { + const descriptor: string = device.path; + const deviceModel = identifyUSBProductId(device.productId); + return { type, device, deviceModel, descriptor }; +}; + +// No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 +process.on('exit', () => { + if (timer) { + clearInterval(timer); + } +}); + +let timer; + +type Payload = { + type: 'add' | 'remove'; + device: Device; + deviceModel: string; + descriptor: string; +}; + +export const listenDevices = ( + onAdd: (arg0: Payload) => void, + onRemove: (arg0: Payload) => void +) => { + Promise.resolve(getDevices()).then((devices) => { + // this needs to run asynchronously so the subscription is defined during this phase + for (const device of devices) { + onAdd(getPayloadData('add', device)); + } + }); + + const poll = () => { + log('hid-listen', 'Polling for added or removed devices'); + + const currentDevices = getFlatDevices(); + const newDevices = currentDevices.filter((d) => !lastDevices.has(d)); + + if (newDevices.length > 0) { + log('hid-listen', 'New device found:', newDevices); + + listDevices = getDevices(); + onAdd(getPayloadData('add', getDeviceByPaths(newDevices))); + addLastDevice(listDevices); + } else { + log('hid-listen', 'No new device found'); + } + + const removeDevices = Array.from(lastDevices.keys()) + .filter((d) => !currentDevices.includes(d)) + .map((d) => d); + + if (removeDevices.length > 0) { + const key = removeDevices[0]; + const removedDevice = lastDevices.get(key); + log('hid-listen', 'Removed device found:', { + removeDevices, + devices: removedDevice, + }); + + onRemove(getPayloadData('remove', removedDevice)); + + lastDevices.delete(key); + } else { + log('hid-listen', 'No removed device found'); + } + }; + + const debouncedPoll = debounce(poll, usbDebounce); + + timer = setInterval(debouncedPoll, 1000); +}; diff --git a/utils/lockfile-checker/index.ts b/utils/lockfile-checker/index.ts index eaa1f68ebe..445a359783 100644 --- a/utils/lockfile-checker/index.ts +++ b/utils/lockfile-checker/index.ts @@ -28,7 +28,7 @@ const dependencyNamesToRemove = [ 'blake2b@https://github.com/BitGo/blake2b', '@types/aria-query', '@types/istanbul-lib-report', - '@types/react-syntax-highlighter', + '@types/react-syntax-highlighter' ]; const dependenciesToRemove = Object.keys(json.object).filter((key) => dependencyNamesToRemove.find((name) => key.includes(name)) diff --git a/yarn.lock b/yarn.lock index 07ec68460f..812b73a40d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2235,7 +2235,7 @@ "@ledgerhq/hw-transport-node-hid-noevents@^5.51.1": version "5.51.1" - resolved "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-5.51.1.tgz#71f37f812e448178ad0bcc2258982150d211c1ab" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-5.51.1.tgz#71f37f812e448178ad0bcc2258982150d211c1ab" dependencies: "@ledgerhq/devices" "^5.51.1" "@ledgerhq/errors" "^5.50.0" @@ -2245,7 +2245,7 @@ "@ledgerhq/hw-transport-node-hid@5.51.1": version "5.51.1" - resolved "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-5.51.1.tgz#fe8eb81e18929663540698c80905952cdbe542d5" + resolved "https://registry.yarnpkg.com/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-5.51.1.tgz#fe8eb81e18929663540698c80905952cdbe542d5" dependencies: "@ledgerhq/devices" "^5.51.1" "@ledgerhq/errors" "^5.50.0" @@ -3155,6 +3155,10 @@ version "1.0.0" resolved "https://registry.yarnpkg.com/@types/is-function/-/is-function-1.0.0.tgz#1b0b819b1636c7baf0d6785d030d12edf70c3e83" +"@types/istanbul-lib-coverage@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" + "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -7290,6 +7294,10 @@ electron-log-daedalus@2.2.21: version "2.2.21" resolved "https://registry.yarnpkg.com/electron-log-daedalus/-/electron-log-daedalus-2.2.21.tgz#7afc009036306d3466d68decd971d7e1a2f2a5b1" +electron-mock-ipc@0.3.12: + version "0.3.12" + resolved "https://registry.yarnpkg.com/electron-mock-ipc/-/electron-mock-ipc-0.3.12.tgz#f9a7dca9a23a95dbe5a62f27cca12768d4cb88c0" + electron-notarize@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-1.1.1.tgz#3ed274b36158c1beb1dbef14e7faf5927e028629" @@ -13722,6 +13730,13 @@ promise@^7.1.1: dependencies: asap "~2.0.3" +prompts@2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"