From d6b13ee94bd54fc847339501899580b5d38ddb3f Mon Sep 17 00:00:00 2001 From: Daniel Main Date: Wed, 26 Jan 2022 10:40:23 +0100 Subject: [PATCH 01/51] [DDW-895] bump cardano-launcher --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 57802061c4..3f481aa10d 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,7 @@ "bs58": "4.0.1", "cardano-crypto.js": "5.3.6-rc.6", "cardano-js": "0.4.8", - "cardano-launcher": "0.20211105.1", + "cardano-launcher": "0.20220119.0", "cbor": "5.0.2", "check-disk-space": "3.0.1", "chroma-js": "2.1.0", diff --git a/yarn.lock b/yarn.lock index b82fb6c08e..5bff54392a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5281,9 +5281,9 @@ cardano-js@0.4.8: js-chain-libs-node "^0.3.0" ts-custom-error "^3.1.1" -cardano-launcher@0.20211105.1: - version "0.20211105.1" - resolved "https://registry.yarnpkg.com/cardano-launcher/-/cardano-launcher-0.20211105.1.tgz#60ea1bd223d3d583b4b68f7bc5613d4c1555f956" +cardano-launcher@0.20220119.0: + version "0.20220119.0" + resolved "https://registry.yarnpkg.com/cardano-launcher/-/cardano-launcher-0.20220119.0.tgz#678accc7e72936882be4d39742f444d2dd0dca85" dependencies: chalk "4.1.2" get-port "5.1.1" From e4a6e843bd4708b22b7ae302917ce0db83a4d7f9 Mon Sep 17 00:00:00 2001 From: Daniel Main Date: Wed, 26 Jan 2022 10:48:23 +0100 Subject: [PATCH 02/51] [DDW-895] updated CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8403b0ac78..092484acfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Chores +- Updated cardano-launcher to 0.20220119.0 ([PR 2839](https://github.com/input-output-hk/daedalus/pull/2839)) - Updated the list of team members ([PR 2805](https://github.com/input-output-hk/daedalus/pull/2805)) ## 4.8.0-FC1 From 804ef8b5d79a12925298041dea89e4e91d0f6ad6 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Fri, 25 Feb 2022 16:40:43 -0300 Subject: [PATCH 03/51] [DDW-722] Change usb detection strategy --- .prettierignore | 1 + .../cardano-app-already-connected.ts | 78 ++++++++ .../cardano-app-not-started.ts | 78 ++++++++ hardware-wallet-tests/index.ts | 69 +++++++ .../multiple-hardware-wallets.ts | 52 ++++++ .../remove-multiple-hardware-wallets.ts | 64 +++++++ .../remove-single-hardware-wallet.ts | 48 +++++ hardware-wallet-tests/utils.ts | 119 ++++++++++++ package.json | 5 +- source/common/ipc/lib/IpcChannel.ts | 31 +++- .../ipc/createHardwareWalletIPCChannels.ts | 126 +++++++++++++ source/main/ipc/getHardwareWalletChannel.ts | 171 ++++++------------ source/main/ipc/index.ts | 6 +- source/main/ipc/listenDevices.ts | 116 ++++++++++++ utils/lockfile-checker/index.ts | 2 +- yarn.lock | 19 +- 16 files changed, 858 insertions(+), 127 deletions(-) create mode 100644 hardware-wallet-tests/cardano-app-already-connected.ts create mode 100644 hardware-wallet-tests/cardano-app-not-started.ts create mode 100644 hardware-wallet-tests/index.ts create mode 100644 hardware-wallet-tests/multiple-hardware-wallets.ts create mode 100644 hardware-wallet-tests/remove-multiple-hardware-wallets.ts create mode 100644 hardware-wallet-tests/remove-single-hardware-wallet.ts create mode 100644 hardware-wallet-tests/utils.ts create mode 100644 source/main/ipc/createHardwareWalletIPCChannels.ts create mode 100644 source/main/ipc/listenDevices.ts 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" From b4a1480555d0c85af9e0971ec3a5a6126bc25c69 Mon Sep 17 00:00:00 2001 From: Michal Rus Date: Thu, 10 Mar 2022 19:10:04 +0100 Subject: [PATCH 04/51] [DDW-1010] Add the usb-detection native module to all 3 installers --- installers/common/MacInstaller.hs | 10 ++++++++-- nix/windows-usb-libs.zip | Bin 2329368 -> 2593694 bytes package.json | 1 + shell.nix | 3 +++ yarn.lock | 31 ++++++++++++++++++++++++++++++ yarn2nix.nix | 12 ++++++++++-- 6 files changed, 53 insertions(+), 4 deletions(-) diff --git a/installers/common/MacInstaller.hs b/installers/common/MacInstaller.hs index 8f7e5aa356..dc225d4e1e 100644 --- a/installers/common/MacInstaller.hs +++ b/installers/common/MacInstaller.hs @@ -149,6 +149,7 @@ sign_cmd "$ABS_PATH/Contents/Frameworks/Electron Framework.framework/Versions/A/ # Sign native electron bindings and supplementary binaries sign_cmd "$ABS_PATH/Contents/Resources/app/build/usb_bindings.node" sign_cmd "$ABS_PATH/Contents/Resources/app/build/HID.node" +sign_cmd "$ABS_PATH/Contents/Resources/app/build/detection.node" sign_cmd "$ABS_PATH/Contents/Resources/app/node_modules/keccak/bin/darwin-x64-"*"/keccak.node" sign_cmd "$ABS_PATH/Contents/Resources/app/node_modules/keccak/build/Release/addon.node" sign_cmd "$ABS_PATH/Contents/Resources/app/node_modules/keccak/prebuilds/darwin-x64/node.napi.node" @@ -281,7 +282,7 @@ buildElectronApp darwinConfig@DarwinConfig{dcAppName, dcAppNameApp} installerCon cptree ("../node_modules" lib) ((fromText pathtoapp) "Contents/Resources/app/node_modules" lib) ) externalYarn mktree ((fromText pathtoapp) "Contents/Resources/app/build") - mapM_ (\(srcdir, name) -> cp ("../node_modules" srcdir name) ((fromText pathtoapp) "Contents/Resources/app/build" name)) [ ("usb/build/Release","usb_bindings.node"), ("node-hid/build/Release", "HID.node") ] + mapM_ (\(srcdir, name) -> cp ("../node_modules" srcdir name) ((fromText pathtoapp) "Contents/Resources/app/build" name)) [ ("usb/build/Release","usb_bindings.node"), ("node-hid/build/Release", "HID.node"), ("usb-detection/build/Release", "detection.node") ] rewritePackageJson (T.unpack $ pathtoapp <> "/Contents/Resources/app/package.json") (spacedName installerConfig) pure $ fromString $ T.unpack $ pathtoapp @@ -293,6 +294,7 @@ npmPackage DarwinConfig{dcAppName} = do echo "Running electron packager script..." export "NODE_ENV" "production" procs "yarn" ["run", "package", "--", "--name", dcAppName ] empty + procs "node_modules/.bin/electron-rebuild" ["-w", "usb-detection", "--useCache", "-s"] empty -- size <- inproc "du" ["-sh", "release"] empty printf ("Size of Electron app is " % l % "\n") size procs "find" ["-name", "*.node"] empty @@ -341,8 +343,12 @@ makeComponentRoot Options{oBackend,oCluster} appRoot darwinConfig@DarwinConfig{d sortaMove filename = do mv (appRoot "Contents/Resources/app/build" filename) (dirfilename) symlink ("../../../MacOS" filename) (appRoot "Contents/Resources/app/build" filename) - mapM_ sortaMove [ "usb_bindings.node" ] + mapM_ sortaMove [ "usb_bindings.node", "detection.node" ] void $ chain (encodeString dir) [ tt $ dir "usb_bindings.node" ] + void $ chain (encodeString dir) [ tt $ dir "detection.node" ] + -- + -- TODO: why is it duplicated??? and above? – a TODO for @michalrus + -- let sortaMove :: FilePath -> IO () sortaMove filename = do diff --git a/nix/windows-usb-libs.zip b/nix/windows-usb-libs.zip index 82a4b9e3f0af2362cce4118800ca5eaaf1516c91..86e1455ef368ab02321e7e28fd15b4eb3fcf0ef0 100644 GIT binary patch delta 267019 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

s;5y(_{^s}&z?mrHZI%STW=lz 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%0D$0O86*IJl=-XtUldfc0jA)q2yU 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 Date: Thu, 10 Mar 2022 18:12:49 -0300 Subject: [PATCH 05/51] [DDW-722] Remove polling --- package.json | 3 +- source/main/ipc/getHardwareWalletChannel.ts | 7 ++-- source/main/ipc/listenDevices.ts | 28 +++++++++++++-- yarn.lock | 38 +++++++++++++++------ 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 9de4b9fea4..cc0e082412 100644 --- a/package.json +++ b/package.json @@ -244,6 +244,7 @@ "moment": "2.29.0", "nanoid": "3.2.0", "node-downloader-helper": "1.0.18", + "node-hid": "2.1.1", "omit-deep-lodash": "1.1.5", "pbkdf2": "3.0.17", "pdfkit": "0.8.3", @@ -281,7 +282,7 @@ "tcp-port-used": "1.0.1", "trezor-connect": "8.2.4-extended", "unorm": "1.6.0", - "usb-detection": "4.13.0", + "usb-detection": "4.14.1", "validator": "13.7.0" }, "devEngines": { diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 6cae1f95aa..819a201d25 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -1,8 +1,7 @@ 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 TransportNodeHid, { + getDevices, +} from '@ledgerhq/hw-transport-node-hid-noevents'; import AppAda, { utils } from '@cardano-foundation/ledgerjs-hw-app-cardano'; import TrezorConnect, { DEVICE, diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index aa19dfda42..4e56744384 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -1,6 +1,8 @@ -import { identifyUSBProductId } from '@ledgerhq/devices'; +import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; +import usbDetect from 'usb-detection'; + import { log } from '@ledgerhq/logs'; import debounce from 'lodash/debounce'; @@ -19,6 +21,17 @@ export type Device = { usage: number; }; +let monitoring = false; + +const monitor = () => { + if (!monitoring) { + monitoring = true; + usbDetect.startMonitoring(); + } + + return () => {}; +}; + let usbDebounce = 100; export const setUsbDebounce = (n: number) => { usbDebounce = n; @@ -49,6 +62,11 @@ const getPayloadData = (type: 'add' | 'remove', device: Device) => { // No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 process.on('exit', () => { + if (monitoring) { + // redeem the monitoring so the process can be terminated. + usbDetect.stopMonitoring(); + } + if (timer) { clearInterval(timer); } @@ -67,6 +85,11 @@ export const listenDevices = ( onAdd: (arg0: Payload) => void, onRemove: (arg0: Payload) => void ) => { + const addEvent = `add:${ledgerUSBVendorId}`; + const removeEvent = `remove:${ledgerUSBVendorId}`; + + monitor(); + Promise.resolve(getDevices()).then((devices) => { // this needs to run asynchronously so the subscription is defined during this phase for (const device of devices) { @@ -112,5 +135,6 @@ export const listenDevices = ( const debouncedPoll = debounce(poll, usbDebounce); - timer = setInterval(debouncedPoll, 1000); + usbDetect.on(addEvent, debouncedPoll); + usbDetect.on(removeEvent, debouncedPoll); }; diff --git a/yarn.lock b/yarn.lock index 34409f88a3..b17483b7ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6946,6 +6946,10 @@ detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" +detect-libc@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -12304,6 +12308,12 @@ node-abi@^2.21.0, node-abi@^2.7.0: dependencies: semver "^5.4.1" +node-abi@^3.3.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.8.0.tgz#679957dc8e7aa47b0a02589dbfde4f77b29ccb32" + dependencies: + semver "^7.3.5" + node-addon-api@3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" @@ -13606,21 +13616,21 @@ prebuild-install@^6.0.0: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -prebuild-install@^6.1.4: - version "6.1.4" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.4.tgz#ae3c0142ad611d58570b89af4986088a4937e00f" +prebuild-install@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" dependencies: - detect-libc "^1.0.3" + detect-libc "^2.0.0" expand-template "^2.0.3" github-from-package "0.0.0" minimist "^1.2.3" mkdirp-classic "^0.5.3" napi-build-utils "^1.0.1" - node-abi "^2.21.0" + node-abi "^3.3.0" npmlog "^4.0.1" pump "^3.0.0" rc "^1.2.7" - simple-get "^3.0.3" + simple-get "^4.0.0" tar-fs "^2.0.0" tunnel-agent "^0.6.0" @@ -15677,6 +15687,14 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-html-tokenizer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.1.1.tgz#05c2eec579ffffe145a030ac26cfea61b980fabe" @@ -17518,14 +17536,14 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -usb-detection@4.13.0: - version "4.13.0" - resolved "https://registry.yarnpkg.com/usb-detection/-/usb-detection-4.13.0.tgz#f98f350d236034645db99f4ce1b80928349c83a0" +usb-detection@4.14.1: + version "4.14.1" + resolved "https://registry.yarnpkg.com/usb-detection/-/usb-detection-4.14.1.tgz#fe0d4a28299e98b77fe75e416408ebeda38feb0e" dependencies: bindings "^1.5.0" eventemitter2 "^5.0.1" nan "^2.15.0" - prebuild-install "^6.1.4" + prebuild-install "^7.0.1" usb@1.7.2, usb@^1.7.0: version "1.7.2" From 205b2d74e6bff913469eb0d76892177ea22e0a7f Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 14 Mar 2022 10:51:38 -0300 Subject: [PATCH 06/51] [DDW-722] Add timeout --- source/main/ipc/listenDevices.ts | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index 4e56744384..f30a6e5f07 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -3,7 +3,7 @@ import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; import usbDetect from 'usb-detection'; -import { log } from '@ledgerhq/logs'; +import { log, listen } from '@ledgerhq/logs'; import debounce from 'lodash/debounce'; export type Device = { @@ -21,8 +21,13 @@ export type Device = { usage: number; }; +listen(console.log); + let monitoring = false; +const deviceToLog = ({ productId, locationId, deviceAddress }) => + `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; + const monitor = () => { if (!monitoring) { monitoring = true; @@ -87,6 +92,7 @@ export const listenDevices = ( ) => { const addEvent = `add:${ledgerUSBVendorId}`; const removeEvent = `remove:${ledgerUSBVendorId}`; + let timeout; monitor(); @@ -135,6 +141,30 @@ export const listenDevices = ( const debouncedPoll = debounce(poll, usbDebounce); - usbDetect.on(addEvent, debouncedPoll); - usbDetect.on(removeEvent, debouncedPoll); + const add = (device: usbDetect.Device) => { + log('usb-detection', `add: ${deviceToLog(device)}`); + + if (!timeout) { + // a time is needed for the device to actually be connectable over HID.. + // we also take this time to not emit the device yet and potentially cancel it if a remove happens. + timeout = setTimeout(() => { + debouncedPoll(); + timeout = null; + }, usbDebounce); + } + }; + + const remove = (device: usbDetect.Device) => { + log('usb-detection', `remove: ${deviceToLog(device)}`); + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } else { + debouncedPoll(); + } + }; + + usbDetect.on(addEvent, add); + usbDetect.on(removeEvent, remove); }; From a4cc4c972d656783b7b78b5d477025870df8fd6c Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 14 Mar 2022 14:12:37 -0300 Subject: [PATCH 07/51] [DDW-722] Downgrade usb-detection --- package.json | 2 +- yarn.lock | 38 ++++++++++---------------------------- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index cc0e082412..b29d16099f 100644 --- a/package.json +++ b/package.json @@ -282,7 +282,7 @@ "tcp-port-used": "1.0.1", "trezor-connect": "8.2.4-extended", "unorm": "1.6.0", - "usb-detection": "4.14.1", + "usb-detection": "4.13.0", "validator": "13.7.0" }, "devEngines": { diff --git a/yarn.lock b/yarn.lock index b17483b7ed..34409f88a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6946,10 +6946,6 @@ detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" -detect-libc@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -12308,12 +12304,6 @@ node-abi@^2.21.0, node-abi@^2.7.0: dependencies: semver "^5.4.1" -node-abi@^3.3.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.8.0.tgz#679957dc8e7aa47b0a02589dbfde4f77b29ccb32" - dependencies: - semver "^7.3.5" - node-addon-api@3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" @@ -13616,21 +13606,21 @@ prebuild-install@^6.0.0: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -prebuild-install@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" +prebuild-install@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.4.tgz#ae3c0142ad611d58570b89af4986088a4937e00f" dependencies: - detect-libc "^2.0.0" + detect-libc "^1.0.3" expand-template "^2.0.3" github-from-package "0.0.0" minimist "^1.2.3" mkdirp-classic "^0.5.3" napi-build-utils "^1.0.1" - node-abi "^3.3.0" + node-abi "^2.21.0" npmlog "^4.0.1" pump "^3.0.0" rc "^1.2.7" - simple-get "^4.0.0" + simple-get "^3.0.3" tar-fs "^2.0.0" tunnel-agent "^0.6.0" @@ -15687,14 +15677,6 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" -simple-get@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" - dependencies: - decompress-response "^6.0.0" - once "^1.3.1" - simple-concat "^1.0.0" - simple-html-tokenizer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.1.1.tgz#05c2eec579ffffe145a030ac26cfea61b980fabe" @@ -17536,14 +17518,14 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -usb-detection@4.14.1: - version "4.14.1" - resolved "https://registry.yarnpkg.com/usb-detection/-/usb-detection-4.14.1.tgz#fe0d4a28299e98b77fe75e416408ebeda38feb0e" +usb-detection@4.13.0: + version "4.13.0" + resolved "https://registry.yarnpkg.com/usb-detection/-/usb-detection-4.13.0.tgz#f98f350d236034645db99f4ce1b80928349c83a0" dependencies: bindings "^1.5.0" eventemitter2 "^5.0.1" nan "^2.15.0" - prebuild-install "^7.0.1" + prebuild-install "^6.1.4" usb@1.7.2, usb@^1.7.0: version "1.7.2" From 07f107583a2ed91c090a463690901dded73df870 Mon Sep 17 00:00:00 2001 From: Michal Rus Date: Thu, 17 Mar 2022 12:57:05 +0100 Subject: [PATCH 08/51] =?UTF-8?q?[DDW-722]=20[DDW-1010]=20Fix=20local=20`y?= =?UTF-8?q?arn=20dev`=E2=80=A6=20sorta=20wrong,=20but=20it=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shell.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell.nix b/shell.nix index 45253f38a5..c4bf6e8044 100644 --- a/shell.nix +++ b/shell.nix @@ -116,7 +116,8 @@ let ln -svf $PWD/node_modules/usb/build/${BUILDTYPE}/usb_bindings.node ${BUILDTYPE}/ ln -svf $PWD/node_modules/node-hid/build/${BUILDTYPE}/HID.node ${BUILDTYPE}/ ln -svf $PWD/node_modules/node-hid/build/${BUILDTYPE}/HID_hidraw.node ${BUILDTYPE}/ - ln -svf $PWD/node_modules/usb-detection/build/${BUILDTYPE}/detection.node ${BUILDTYPE}/ + # XXX: right now we only build Release/detection.node (TODO: investigate why – @michalrus) + ln -svf $PWD/node_modules/usb-detection/build/Release/detection.node ${BUILDTYPE}/ ${pkgs.lib.optionalString (nodeImplementation == "cardano") '' source <(cardano-node --bash-completion-script `type -p cardano-node`) ''} From 5e651b1465dbc32ddcacfc2408dec81acaebe82f Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Fri, 18 Mar 2022 09:38:45 -0300 Subject: [PATCH 09/51] [DDW-722] Add --frozen-lockfile --- installers/common/MacInstaller.hs | 2 +- shell.nix | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/installers/common/MacInstaller.hs b/installers/common/MacInstaller.hs index dc225d4e1e..8bd7088fee 100644 --- a/installers/common/MacInstaller.hs +++ b/installers/common/MacInstaller.hs @@ -290,7 +290,7 @@ npmPackage :: DarwinConfig -> Shell () npmPackage DarwinConfig{dcAppName} = do mktree "release" echo "Installing nodejs dependencies..." - procs "yarn" ["install"] empty + procs "yarn" ["install", "--frozen-lockfile"] empty echo "Running electron packager script..." export "NODE_ENV" "production" procs "yarn" ["run", "package", "--", "--name", dcAppName ] empty diff --git a/shell.nix b/shell.nix index c4bf6e8044..19a3615dc9 100644 --- a/shell.nix +++ b/shell.nix @@ -30,7 +30,7 @@ let buildInputs = [ daedalusPkgs.nodejs daedalusPkgs.yarn pkgs.git ]; shellHook = '' git diff > pre-yarn.diff - yarn + yarn --frozen-lockfile git diff > post-yarn.diff diff pre-yarn.diff post-yarn.diff > /dev/null if [ $? != 0 ] @@ -130,7 +130,7 @@ let npm cache clean --force '' } - yarn install + yarn install --frozen-lockfile yarn build:electron ${localLib.optionalString pkgs.stdenv.isLinux '' ${pkgs.patchelf}/bin/patchelf --set-rpath ${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc pkgs.udev ]} ${BUILDTYPE}/usb_bindings.node From 25b63e409afb9e9f30d6bb6d5ec9de448755d80d Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Fri, 18 Mar 2022 12:34:07 -0300 Subject: [PATCH 10/51] [DDW-722] Revert polling --- source/main/ipc/listenDevices.ts | 60 ++------------------------------ 1 file changed, 3 insertions(+), 57 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index f30a6e5f07..aa19dfda42 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -1,9 +1,7 @@ -import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; +import { identifyUSBProductId } from '@ledgerhq/devices'; import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; -import usbDetect from 'usb-detection'; - -import { log, listen } from '@ledgerhq/logs'; +import { log } from '@ledgerhq/logs'; import debounce from 'lodash/debounce'; export type Device = { @@ -21,22 +19,6 @@ export type Device = { usage: number; }; -listen(console.log); - -let monitoring = false; - -const deviceToLog = ({ productId, locationId, deviceAddress }) => - `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; - -const monitor = () => { - if (!monitoring) { - monitoring = true; - usbDetect.startMonitoring(); - } - - return () => {}; -}; - let usbDebounce = 100; export const setUsbDebounce = (n: number) => { usbDebounce = n; @@ -67,11 +49,6 @@ const getPayloadData = (type: 'add' | 'remove', device: Device) => { // No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 process.on('exit', () => { - if (monitoring) { - // redeem the monitoring so the process can be terminated. - usbDetect.stopMonitoring(); - } - if (timer) { clearInterval(timer); } @@ -90,12 +67,6 @@ export const listenDevices = ( onAdd: (arg0: Payload) => void, onRemove: (arg0: Payload) => void ) => { - const addEvent = `add:${ledgerUSBVendorId}`; - const removeEvent = `remove:${ledgerUSBVendorId}`; - let timeout; - - monitor(); - Promise.resolve(getDevices()).then((devices) => { // this needs to run asynchronously so the subscription is defined during this phase for (const device of devices) { @@ -141,30 +112,5 @@ export const listenDevices = ( const debouncedPoll = debounce(poll, usbDebounce); - const add = (device: usbDetect.Device) => { - log('usb-detection', `add: ${deviceToLog(device)}`); - - if (!timeout) { - // a time is needed for the device to actually be connectable over HID.. - // we also take this time to not emit the device yet and potentially cancel it if a remove happens. - timeout = setTimeout(() => { - debouncedPoll(); - timeout = null; - }, usbDebounce); - } - }; - - const remove = (device: usbDetect.Device) => { - log('usb-detection', `remove: ${deviceToLog(device)}`); - - if (timeout) { - clearTimeout(timeout); - timeout = null; - } else { - debouncedPoll(); - } - }; - - usbDetect.on(addEvent, add); - usbDetect.on(removeEvent, remove); + timer = setInterval(debouncedPoll, 1000); }; From 4b9797b3bf45792c38afccd1f22021465f2e9b96 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 21 Mar 2022 16:15:51 -0300 Subject: [PATCH 11/51] [DDW-722] Fix race condition --- source/main/ipc/listenDevices.ts | 60 ++++++++++++++++++- .../app/stores/HardwareWalletsStore.ts | 57 ++++++++++++++---- 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index aa19dfda42..f30a6e5f07 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -1,7 +1,9 @@ -import { identifyUSBProductId } from '@ledgerhq/devices'; +import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; -import { log } from '@ledgerhq/logs'; +import usbDetect from 'usb-detection'; + +import { log, listen } from '@ledgerhq/logs'; import debounce from 'lodash/debounce'; export type Device = { @@ -19,6 +21,22 @@ export type Device = { usage: number; }; +listen(console.log); + +let monitoring = false; + +const deviceToLog = ({ productId, locationId, deviceAddress }) => + `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; + +const monitor = () => { + if (!monitoring) { + monitoring = true; + usbDetect.startMonitoring(); + } + + return () => {}; +}; + let usbDebounce = 100; export const setUsbDebounce = (n: number) => { usbDebounce = n; @@ -49,6 +67,11 @@ const getPayloadData = (type: 'add' | 'remove', device: Device) => { // No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 process.on('exit', () => { + if (monitoring) { + // redeem the monitoring so the process can be terminated. + usbDetect.stopMonitoring(); + } + if (timer) { clearInterval(timer); } @@ -67,6 +90,12 @@ export const listenDevices = ( onAdd: (arg0: Payload) => void, onRemove: (arg0: Payload) => void ) => { + const addEvent = `add:${ledgerUSBVendorId}`; + const removeEvent = `remove:${ledgerUSBVendorId}`; + let timeout; + + monitor(); + Promise.resolve(getDevices()).then((devices) => { // this needs to run asynchronously so the subscription is defined during this phase for (const device of devices) { @@ -112,5 +141,30 @@ export const listenDevices = ( const debouncedPoll = debounce(poll, usbDebounce); - timer = setInterval(debouncedPoll, 1000); + const add = (device: usbDetect.Device) => { + log('usb-detection', `add: ${deviceToLog(device)}`); + + if (!timeout) { + // a time is needed for the device to actually be connectable over HID.. + // we also take this time to not emit the device yet and potentially cancel it if a remove happens. + timeout = setTimeout(() => { + debouncedPoll(); + timeout = null; + }, usbDebounce); + } + }; + + const remove = (device: usbDetect.Device) => { + log('usb-detection', `remove: ${deviceToLog(device)}`); + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } else { + debouncedPoll(); + } + }; + + usbDetect.on(addEvent, add); + usbDetect.on(removeEvent, remove); }; diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 2db2cbbcc4..9ae843dd8f 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1,5 +1,14 @@ import { observable, action, runInAction, computed } from 'mobx'; -import { get, map, find, findLast, includes } from 'lodash'; +import { + get, + map, + find, + findLast, + includes, + last, + sortBy, + filter, +} from 'lodash'; import semver from 'semver'; import { TransactionSigningMode, @@ -638,15 +647,14 @@ export default class HardwareWalletsStore extends Store { ); } - const lastUnpairedDevice = findLast( - this.hardwareWalletDevices, - (hardwareWalletDevice) => - // @ts-ignore ts-migrate(2339) FIXME: Property 'paired' does not exist on type 'Hardware... Remove this comment to see the full error message - !hardwareWalletDevice.paired && !hardwareWalletDevice.disconnected - ); + const lastUnpairedDevice = this.getLastUnpairedDevice(); + // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.debug( - '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: START' + '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: START', + { + lastUnpairedDevice: toJS(lastUnpairedDevice), + } ); // Tx Special cases! // This means that transaction needs to be signed but we don't know device connected to Software wallet @@ -2657,10 +2665,19 @@ export default class HardwareWalletsStore extends Store { await this._refreshHardwareWalletDevices(); // Start connection establishing process if devices listener flag is UP + const isCardanoAppInProgress = + !disconnected && this.cardanoAdaAppPollingInterval !== null; + + logger.debug('[HW-DEBUG] HWStore - establish connection guard: ', { + isCardanoAppInProgress, + isListeningForDevice: this.isListeningForDevice, + }); + if ( - this.isListeningForDevice && - !disconnected && - (!eventType || eventType === DeviceEvents.CONNECT) + (this.isListeningForDevice && + !disconnected && + (!eventType || eventType === DeviceEvents.CONNECT)) || + isCardanoAppInProgress ) { runInAction('HardwareWalletsStore:: remove device listener', () => { this.isListeningForDevice = false; @@ -2728,6 +2745,23 @@ export default class HardwareWalletsStore extends Store { ); } }; + + getLastUnpairedDevice = () => + last( + sortBy( + filter( + Object.entries(this.hardwareWalletDevices).map(([key, value]) => ({ + ...value, + id: key, + })), + (hardwareWalletDevice) => + // @ts-ignore ts-migrate(2339) FIXME: Property 'paired' does not exist on type 'Hardware... Remove this comment to see the full error message + !hardwareWalletDevice.paired && !hardwareWalletDevice.disconnected + ), + ['id'] + ) + ); + @action resetInitializedConnection = async ( params: @@ -2964,6 +2998,7 @@ export default class HardwareWalletsStore extends Store { if (this.cardanoAdaAppPollingInterval) { clearInterval(this.cardanoAdaAppPollingInterval); + this.cardanoAdaAppPollingInterval = null; } }; } From 43e4fa9db3668c0245d9e6a9250f40e449f6d292 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 22 Mar 2022 15:07:48 -0300 Subject: [PATCH 12/51] [DDW-722] Add logs and fallback to polling --- source/main/ipc/listenDevices.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index f30a6e5f07..e359d9765d 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -1,10 +1,13 @@ import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; -import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; +import TransportNodeHid, { + getDevices, +} from '@ledgerhq/hw-transport-node-hid-noevents'; import usbDetect from 'usb-detection'; import { log, listen } from '@ledgerhq/logs'; import debounce from 'lodash/debounce'; +import { logger } from '../utils/logging'; export type Device = { vendorId: number; @@ -21,7 +24,7 @@ export type Device = { usage: number; }; -listen(console.log); +listen(logger.info); let monitoring = false; @@ -104,19 +107,19 @@ export const listenDevices = ( }); const poll = () => { - log('hid-listen', 'Polling for added or removed devices'); + 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); + log('[HID-LISTEN]', 'New device found:', newDevices); listDevices = getDevices(); onAdd(getPayloadData('add', getDeviceByPaths(newDevices))); addLastDevice(listDevices); } else { - log('hid-listen', 'No new device found'); + log('[HID-LISTEN]', 'No new device found'); } const removeDevices = Array.from(lastDevices.keys()) @@ -126,7 +129,7 @@ export const listenDevices = ( if (removeDevices.length > 0) { const key = removeDevices[0]; const removedDevice = lastDevices.get(key); - log('hid-listen', 'Removed device found:', { + log('[HID-LISTEN]', 'Removed device found:', { removeDevices, devices: removedDevice, }); @@ -135,14 +138,14 @@ export const listenDevices = ( lastDevices.delete(key); } else { - log('hid-listen', 'No removed device found'); + log('[HID-LISTEN]', 'No removed device found'); } }; const debouncedPoll = debounce(poll, usbDebounce); const add = (device: usbDetect.Device) => { - log('usb-detection', `add: ${deviceToLog(device)}`); + log('[USB-DETECTION]', `add: ${deviceToLog(device)}`); if (!timeout) { // a time is needed for the device to actually be connectable over HID.. @@ -155,7 +158,7 @@ export const listenDevices = ( }; const remove = (device: usbDetect.Device) => { - log('usb-detection', `remove: ${deviceToLog(device)}`); + log('[USB-DETECTION]', `remove: ${deviceToLog(device)}`); if (timeout) { clearTimeout(timeout); @@ -165,6 +168,12 @@ export const listenDevices = ( } }; - usbDetect.on(addEvent, add); - usbDetect.on(removeEvent, remove); + if (TransportNodeHid.isSupported()) { + logger.info('[LISTEN-LEDGER-DEVICES] Using usb-detection'); + usbDetect.on(addEvent, add); + usbDetect.on(removeEvent, remove); + } else { + logger.info('[LISTEN-LEDGER-DEVICES] Using polling'); + timer = setInterval(debouncedPoll, 1000); + } }; From 450f0a754fd6b289a6bb6e4fd52dd8fba750c6b8 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 23 Mar 2022 09:20:49 -0300 Subject: [PATCH 13/51] [DDW-722] Add logs --- source/main/ipc/listenDevices.ts | 42 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index e359d9765d..3e723937d8 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -26,18 +26,16 @@ export type Device = { listen(logger.info); -let monitoring = false; +let isMonitoring = false; const deviceToLog = ({ productId, locationId, deviceAddress }) => `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; const monitor = () => { - if (!monitoring) { - monitoring = true; + if (!isMonitoring) { + isMonitoring = true; usbDetect.startMonitoring(); } - - return () => {}; }; let usbDebounce = 100; @@ -45,22 +43,22 @@ export const setUsbDebounce = (n: number) => { usbDebounce = n; }; -let listDevices = getDevices(); +let deviceList = getDevices(); -const flatDevice = (d: Device) => d.path; +const getDevicePath = (d: Device) => d.path; const getFlatDevices = () => [ - ...new Set(getDevices().map((d: Device) => flatDevice(d))), + ...new Set(getDevices().map((d: Device) => getDevicePath(d))), ]; const getDeviceByPaths = (paths) => - listDevices.find((d: Device) => paths.includes(flatDevice(d))); + deviceList.find((d: Device) => paths.includes(getDevicePath(d))); const lastDevices = new Map(); const addLastDevice = (newDevices: Device[]) => newDevices.forEach((d) => lastDevices.set(d.path, d)); -addLastDevice(listDevices); +addLastDevice(deviceList); const getPayloadData = (type: 'add' | 'remove', device: Device) => { const descriptor: string = device.path; @@ -70,7 +68,7 @@ const getPayloadData = (type: 'add' | 'remove', device: Device) => { // No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 process.on('exit', () => { - if (monitoring) { + if (isMonitoring) { // redeem the monitoring so the process can be terminated. usbDetect.stopMonitoring(); } @@ -113,13 +111,20 @@ export const listenDevices = ( const newDevices = currentDevices.filter((d) => !lastDevices.has(d)); if (newDevices.length > 0) { - log('[HID-LISTEN]', 'New device found:', newDevices); + log('[HID-LISTEN]', 'New device found:', { + newDevices, + currentDevices, + lastDevices: Array.from(lastDevices.keys()), + }); - listDevices = getDevices(); + deviceList = getDevices(); onAdd(getPayloadData('add', getDeviceByPaths(newDevices))); - addLastDevice(listDevices); + addLastDevice(deviceList); } else { - log('[HID-LISTEN]', 'No new device found'); + log('[HID-LISTEN]', 'No new device found', { + currentDevices, + lastDevices: Array.from(lastDevices.keys()), + }); } const removeDevices = Array.from(lastDevices.keys()) @@ -132,13 +137,18 @@ export const listenDevices = ( log('[HID-LISTEN]', 'Removed device found:', { removeDevices, devices: removedDevice, + currentDevices, + lastDevices: Array.from(lastDevices.keys()), }); onRemove(getPayloadData('remove', removedDevice)); lastDevices.delete(key); } else { - log('[HID-LISTEN]', 'No removed device found'); + log('[HID-LISTEN]', 'No removed device found', { + currentDevices, + lastDevices: Array.from(lastDevices.keys()), + }); } }; From d6af64c01cb5f5939b4a4333238be1a9c95aeb25 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 23 Mar 2022 12:22:51 -0300 Subject: [PATCH 14/51] [DDW-722] Increase timeout --- source/main/ipc/listenDevices.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts index 3e723937d8..88f41275a5 100644 --- a/source/main/ipc/listenDevices.ts +++ b/source/main/ipc/listenDevices.ts @@ -152,8 +152,6 @@ export const listenDevices = ( } }; - const debouncedPoll = debounce(poll, usbDebounce); - const add = (device: usbDetect.Device) => { log('[USB-DETECTION]', `add: ${deviceToLog(device)}`); @@ -161,9 +159,9 @@ export const listenDevices = ( // a time is needed for the device to actually be connectable over HID.. // we also take this time to not emit the device yet and potentially cancel it if a remove happens. timeout = setTimeout(() => { - debouncedPoll(); + poll(); timeout = null; - }, usbDebounce); + }, 1500); } }; @@ -174,7 +172,7 @@ export const listenDevices = ( clearTimeout(timeout); timeout = null; } else { - debouncedPoll(); + poll(); } }; @@ -184,6 +182,7 @@ export const listenDevices = ( usbDetect.on(removeEvent, remove); } else { logger.info('[LISTEN-LEDGER-DEVICES] Using polling'); + const debouncedPoll = debounce(poll, usbDebounce); timer = setInterval(debouncedPoll, 1000); } }; From 0ee74d068001fc46066a37b5cb0ed966f4aef2aa Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 24 Mar 2022 14:52:49 -0300 Subject: [PATCH 15/51] [DDW-722] Refactor polling function --- .../app/stores/HardwareWalletsStore.ts | 133 +++++++++--------- 1 file changed, 63 insertions(+), 70 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 9ae843dd8f..15a8c21131 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -135,31 +135,6 @@ export const AddressVerificationCheckStatuses: { const CARDANO_ADA_APP_POLLING_INTERVAL = 1000; const DEFAULT_HW_NAME = 'Hardware Wallet'; -const useCardanoAppInterval = ( - getCardanoAdaApp: any, - interval: number, - path: string | null | undefined, - address: string | null | undefined, - addressVerification: WalletAddress | null | undefined -) => - setInterval( - (devicePath, txWalletId, verificationAddress): any => { - try { - return getCardanoAdaApp({ - path: devicePath, - walletId: txWalletId, - address: verificationAddress, - }); - } catch (_error) { - return null; - } - }, - interval, - path, - address, - addressVerification - ); - const { network, isDev } = global.environment; const hardwareWalletsNetworkConfig = getHardwareWalletsNetworkConfig(network); export default class HardwareWalletsStore extends Store { @@ -262,8 +237,10 @@ export default class HardwareWalletsStore extends Store { activeVotingWalletId: string | null | undefined = null; @observable votingData: VotingDataType | null | undefined = null; - // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. - cardanoAdaAppPollingInterval: IntervalID | null | undefined = null; + cardanoAdaAppPollingInterval: { + stop: () => void; + isRunning: () => boolean; + } | null; // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. checkTransactionTimeInterval: IntervalID | null | undefined = null; @@ -333,6 +310,56 @@ export default class HardwareWalletsStore extends Store { }); } }; + + useCardanoAppInterval = ( + devicePath: string | null | undefined, + txWalletId: string | null | undefined, + verificationAddress?: WalletAddress | null | undefined + ) => { + this.cardanoAdaAppPollingInterval?.stop(); + + const poller = () => { + let canRun = true; + let isRunning = false; + + const run = async () => { + try { + if (!canRun) { + return; + } + + isRunning = true; + + await this.getCardanoAdaApp({ + path: devicePath, + walletId: txWalletId, + address: verificationAddress, + }); + } catch (_error) { + if (!canRun) { + return; + } + + isRunning = false; + + setTimeout(run, CARDANO_ADA_APP_POLLING_INTERVAL); + } + }; + + run(); + + return { + stop: () => { + canRun = false; + isRunning = false; + }, + isRunning: () => isRunning, + }; + }; + + this.cardanoAdaAppPollingInterval = poller(); + }; + getAvailableDevices = async (params: { isTrezor: boolean }) => { const { isTrezor } = params; // @ts-ignore ts-migrate(1320) FIXME: Type of 'await' operand must either be a valid pro... Remove this comment to see the full error message @@ -696,9 +723,7 @@ export default class HardwareWalletsStore extends Store { this.unfinishedWalletAddressVerification ); } else { - this.cardanoAdaAppPollingInterval = useCardanoAppInterval( - this.getCardanoAdaApp, - CARDANO_ADA_APP_POLLING_INTERVAL, + this.useCardanoAppInterval( recognizedPairedHardwareWallet.path, activeWalletId, this.unfinishedWalletAddressVerification @@ -748,9 +773,7 @@ export default class HardwareWalletsStore extends Store { this.unfinishedWalletAddressVerification ); } else { - this.cardanoAdaAppPollingInterval = useCardanoAppInterval( - this.getCardanoAdaApp, - CARDANO_ADA_APP_POLLING_INTERVAL, + this.useCardanoAppInterval( lastDeviceTransport.path, activeWalletId, this.unfinishedWalletAddressVerification @@ -879,11 +902,7 @@ export default class HardwareWalletsStore extends Store { ); this.stopCardanoAdaAppFetchPoller(); // @ts-ignore ts-migrate(2554) FIXME: Expected 5 arguments, but got 3. - this.cardanoAdaAppPollingInterval = useCardanoAppInterval( - this.getCardanoAdaApp, - CARDANO_ADA_APP_POLLING_INTERVAL, - devicePath - ); + this.useCardanoAppInterval(devicePath); } } else { runInAction( @@ -1076,18 +1095,7 @@ export default class HardwareWalletsStore extends Store { ); } - this.cardanoAdaAppPollingInterval = setInterval( - (devicePath, txWalletId, verificationAddress) => - this.getCardanoAdaApp({ - path: devicePath, - walletId: txWalletId, - address: verificationAddress, - }), - CARDANO_ADA_APP_POLLING_INTERVAL, - error.path, - walletId, - address - ); + this.useCardanoAppInterval(error.path, walletId, address); } throw error; @@ -1209,15 +1217,9 @@ export default class HardwareWalletsStore extends Store { devicePath, }); this.stopCardanoAdaAppFetchPoller(); - this.cardanoAdaAppPollingInterval = setInterval( - (verificationDevicePath, addressToVerify) => - this.getCardanoAdaApp({ - path: verificationDevicePath, - walletId, - address: addressToVerify, - }), - CARDANO_ADA_APP_POLLING_INTERVAL, + this.useCardanoAppInterval( devicePath, + // @ts-ignore Argument of type 'WalletAddress' is not assignable to parameter of type 'string'.ts(2345) address ); } @@ -2439,16 +2441,7 @@ export default class HardwareWalletsStore extends Store { if (walletId) { this.stopCardanoAdaAppFetchPoller(); - this.cardanoAdaAppPollingInterval = setInterval( - (path, wid) => - this.getCardanoAdaApp({ - path, - walletId: wid, - }), - CARDANO_ADA_APP_POLLING_INTERVAL, - devicePath, - walletId - ); + this.useCardanoAppInterval(devicePath, walletId); } } }; @@ -2666,7 +2659,7 @@ export default class HardwareWalletsStore extends Store { // Start connection establishing process if devices listener flag is UP const isCardanoAppInProgress = - !disconnected && this.cardanoAdaAppPollingInterval !== null; + !disconnected && this.cardanoAdaAppPollingInterval?.isRunning(); logger.debug('[HW-DEBUG] HWStore - establish connection guard: ', { isCardanoAppInProgress, @@ -2997,7 +2990,7 @@ export default class HardwareWalletsStore extends Store { logger.debug('[HW-DEBUG] HWStore - STOP Ada App poller'); if (this.cardanoAdaAppPollingInterval) { - clearInterval(this.cardanoAdaAppPollingInterval); + this.cardanoAdaAppPollingInterval.stop(); this.cardanoAdaAppPollingInterval = null; } }; From d40e53bd59731fd09159d85266e8d77aae85454c Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Fri, 25 Mar 2022 20:16:22 -0300 Subject: [PATCH 16/51] [DDW-722] Fix transaction issue https://github.com/input-output-hk/daedalus/pull/2930#issuecomment-1076306432 --- source/main/ipc/getHardwareWalletChannel.ts | 38 ++-- .../ledger/deviceDetection/deviceDetection.ts | 80 ++++++++ .../ledger/deviceDetection/deviceTracker.ts | 95 +++++++++ .../deviceDetection/eventDrivenDetection.ts | 89 +++++++++ .../ledger/deviceDetection/index.ts | 1 + .../deviceDetection/pollingDrivenDetection.ts | 40 ++++ .../ledger/deviceDetection/types.ts | 27 +++ source/main/ipc/listenDevices.ts | 188 ------------------ .../app/stores/HardwareWalletsStore.ts | 10 +- 9 files changed, 352 insertions(+), 216 deletions(-) create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/index.ts create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts create mode 100644 source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts delete mode 100644 source/main/ipc/listenDevices.ts diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 819a201d25..cc173785a5 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -13,7 +13,10 @@ import TrezorConnect, { } from 'trezor-connect'; import { find, get, includes, last, omit } from 'lodash'; import { derivePublic as deriveChildXpub } from 'cardano-crypto.js'; -import { listenDevices } from './listenDevices'; +import { + deviceDetection, + waitForDevice, +} from './hardwareWallets/ledger/deviceDetection'; import { IpcSender } from '../../common/ipc/lib/IpcChannel'; import { logger } from '../utils/logging'; import { HardwareWalletTransportDeviceRequest } from '../../common/types/hardware-wallets.types'; @@ -270,7 +273,7 @@ export const handleHardwareWalletRequests = async ( try { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] getHardwareWalletTransportChannel:: LEDGER'); - let transportList = await TransportNodeHid.list(); + const transportList = await TransportNodeHid.list(); let hw; let lastConnectedPath; // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -286,27 +289,19 @@ export const handleHardwareWalletRequests = async ( try { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] INIT NEW transport'); - hw = await TransportNodeHid.create(); - transportList = await TransportNodeHid.list(); - lastConnectedPath = last(transportList); - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.info( - `[HW-DEBUG] getHardwareWalletTransportChannel::lastConnectedPath=${JSON.stringify( - lastConnectedPath - )}` - ); - const deviceList = getDevices(); - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.info( - `[HW-DEBUG] getHardwareWalletTransportChannel::deviceList=${JSON.stringify( - deviceList - )}` - ); - const device = find(deviceList, ['path', lastConnectedPath]); + + const { device } = await waitForDevice(); + if (devicesMemo[device.path]) { + logger.info('[HW-DEBUG] CLOSING EXISTING TRANSPORT'); + await devicesMemo[device.path].transport.close(); + } + const transport = await TransportNodeHid.open(device.path); + hw = transport; + lastConnectedPath = device.path; // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] INIT NEW transport - DONE'); // @ts-ignore - deviceConnection = new AppAda(hw); + deviceConnection = new AppAda(transport); devicesMemo[lastConnectedPath] = { device, transport: hw, @@ -414,7 +409,7 @@ export const handleHardwareWalletRequests = async ( observer.next(payload); }; - listenDevices(onAdd, onRemove); + deviceDetection(onAdd, onRemove); logger.info('[HW-DEBUG] OBSERVER INIT - listener started'); } catch (e) { @@ -728,6 +723,7 @@ export const handleHardwareWalletRequests = async ( deviceId: deviceSerial.serial, }); } catch (error) { + logger.info('[HW-DEBUG] EXPORT KEY ERROR', error); throw error; } }); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts new file mode 100644 index 0000000000..a0c28b6859 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts @@ -0,0 +1,80 @@ +import TransportNodeHid from '@ledgerhq/hw-transport-node-hid-noevents'; + +import { logger } from '../../../../utils/logging'; +import { DeviceTracker } from './deviceTracker'; +import { detectDevices as useEventDrivenDetection } from './eventDrivenDetection'; +import { detectDevices as usePollingDrivenDetection } from './pollingDrivenDetection'; +import { Detector, TrackedDevice, DectorUnsubscriber } from './types'; + +type Payload = { + type: 'add' | 'remove'; +} & TrackedDevice; + +export const deviceDetection = ( + onAdd: (arg0: Payload) => void, + onRemove: (arg0: Payload) => void +) => { + Promise.resolve(DeviceTracker.getDevices()).then((devices) => { + // this needs to run asynchronously so the subscription is defined during this phase + for (const device of devices) { + onAdd({ + type: 'add', + ...DeviceTracker.getTrackedDeviceByPath(device.path), + }); + } + }); + + const handleOnAdd = (trackedDevice: TrackedDevice) => + onAdd({ type: 'add', ...trackedDevice }); + const handleOnRemove = (trackedDevice: TrackedDevice) => + onRemove({ type: 'remove', ...trackedDevice }); + + let detectDevices: Detector; + + if (TransportNodeHid.isSupported()) { + logger.info('[HW-DEBUG] Using usb-detection'); + + detectDevices = useEventDrivenDetection; + } else { + logger.info('[HW-DEBUG] Using polling'); + + detectDevices = usePollingDrivenDetection; + } + + detectDevices(handleOnAdd, handleOnRemove); +}; + +export const waitForDevice = () => { + return new Promise(async (resolve) => { + const currentDevices = await DeviceTracker.getDevices(); + + for (const device of currentDevices) { + return resolve(DeviceTracker.getTrackedDeviceByPath(device.path)); + } + + let detectDevices: Detector; + let unsubscribe: DectorUnsubscriber = null; + + if (TransportNodeHid.isSupported()) { + logger.info('[HW-DEBUG] Using usb-detection'); + + detectDevices = useEventDrivenDetection; + } else { + logger.info('[HW-DEBUG] Using polling'); + + detectDevices = usePollingDrivenDetection; + } + + const handleOnAdd = (trackedDevice: TrackedDevice) => { + if (unsubscribe) { + unsubscribe(); + } + + return resolve(trackedDevice); + }; + + const handleOnRemove = () => false; + + unsubscribe = detectDevices(handleOnAdd, handleOnRemove); + }); +}; diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts new file mode 100644 index 0000000000..54b3121a65 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts @@ -0,0 +1,95 @@ +import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; +import { identifyUSBProductId } from '@ledgerhq/devices'; + +import { logger } from '../../../../utils/logging'; +import { Device, TrackedDevice } from './types'; + +export class DeviceTracker { + knownDevices: Map; + + static getUniqueDevices() { + return [...new Set(getDevices().map((d: Device) => d.path))]; + } + + static getDeviceByPath(path: string): Device { + return getDevices().find((d: Device) => d.path === path); + } + + static getTrackedDeviceByPath(path: string) { + const device = DeviceTracker.getDeviceByPath(path); + + const descriptor: string = device.path; + const deviceModel = (identifyUSBProductId( + device.productId + ) as unknown) as string; + + return { device, deviceModel, descriptor } as TrackedDevice; + } + + static getDevices() { + return getDevices(); + } + + constructor() { + this.knownDevices = new Map(); + + getDevices().forEach((d) => this.knownDevices.set(d.path, d)); + } + + findNewDevice() { + const currentDevices = DeviceTracker.getUniqueDevices(); + const [newDevicePath] = currentDevices.filter( + (d) => !this.knownDevices.has(d) + ); + const knownDevicesPath = Array.from(this.knownDevices.keys()); + + if (newDevicePath) { + const newDevice = DeviceTracker.getTrackedDeviceByPath(newDevicePath); + this.knownDevices.set(newDevicePath, newDevice); + + logger.info('[HW-DEBUG] DeviceTracker - New device found:', { + newDevicePath, + currentDevices, + knownDevicesPath, + }); + + return newDevice; + } + + logger.info('[HW-DEBUG] DeviceTracker - No new device found:', { + currentDevices, + knownDevicesPath, + }); + + return null; + } + + findRemovedDevice() { + const currentDevices = DeviceTracker.getUniqueDevices(); + const [removedDevicePath] = Array.from(this.knownDevices.keys()) + .filter((d) => !currentDevices.includes(d)) + .map((d) => d); + const knownDevicesPath = Array.from(this.knownDevices.keys()); + + if (removedDevicePath) { + const removedDevice = this.knownDevices.get(removedDevicePath); + this.knownDevices.delete(removedDevicePath); + + logger.info('[HW-DEBUG] DeviceTracker - Removed device found:', { + removedDevicePath, + removedDevice, + currentDevices, + knownDevicesPath, + }); + + return removedDevice; + } + + logger.info('[HW-DEBUG] DeviceTracker - No removed device found:', { + currentDevices, + knownDevicesPath, + }); + + return null; + } +} diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts new file mode 100644 index 0000000000..d6c9528d11 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts @@ -0,0 +1,89 @@ +import { ledgerUSBVendorId } from '@ledgerhq/devices'; +import usbDetect from 'usb-detection'; + +import { logger } from '../../../../utils/logging'; +import { DeviceTracker } from './deviceTracker'; +import { Detector } from './types'; + +const deviceToLog = ({ productId, locationId, deviceAddress }) => + `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; + +let isMonitoring = false; + +const monitorUSBDevices = () => { + if (!isMonitoring) { + isMonitoring = true; + usbDetect.startMonitoring(); + } +}; + +const stopMonitoring = () => { + if (isMonitoring) { + // redeem the monitoring so the process can be terminated. + usbDetect.stopMonitoring(); + } +}; + +// No better way for now. see https://github.com/LedgerHQ/ledgerjs/issues/434 +process.on('exit', () => { + stopMonitoring(); +}); + +const addEvent = `add:${ledgerUSBVendorId}`; +const removeEvent = `remove:${ledgerUSBVendorId}`; + +export const detectDevices: Detector = (onAdd, onRemove) => { + let timeout; + + monitorUSBDevices(); + + const deviceTracker = new DeviceTracker(); + + const add = (device: usbDetect.Device) => { + logger.info( + `[HW-DEBUG] USB-DETECTION ADDED DEVICE: ${deviceToLog(device)}` + ); + + if (!timeout) { + // a time is needed for the device to actually be connectable over HID.. + // we also take this time to not emit the device yet and potentially cancel it if a remove happens. + timeout = setTimeout(() => { + const newDevice = deviceTracker.findNewDevice(); + + if (newDevice) { + onAdd(newDevice); + } + + timeout = null; + }, 1500); + } + }; + + const remove = (device: usbDetect.Device) => { + logger.info( + `[HW-DEBUG] USB-DETECTION REMOVED DEVICE: ${deviceToLog(device)}` + ); + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } else { + const removedDevice = deviceTracker.findNewDevice(); + + if (removedDevice) { + onRemove(removedDevice); + } + } + }; + + usbDetect.on(addEvent, add); + usbDetect.on(removeEvent, remove); + + return () => { + if (timeout) clearTimeout(timeout); + // @ts-expect-error not all EventEmitter methods are covered in its definition file + usbDetect.off(addEvent, add); + // @ts-expect-error not all EventEmitter methods are covered in its definition file + usbDetect.off(removeEvent, remove); + }; +}; diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/index.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/index.ts new file mode 100644 index 0000000000..4ad86e56d6 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/index.ts @@ -0,0 +1 @@ +export { deviceDetection, waitForDevice } from './deviceDetection'; diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts new file mode 100644 index 0000000000..1f166be9b2 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts @@ -0,0 +1,40 @@ +import { logger } from '../../../../utils/logging'; +import { DeviceTracker } from './deviceTracker'; +import { Detector } from './types'; + +export const detectDevices: Detector = (onAdd, onRemove) => { + let timer; + + const stopPolling = () => { + if (timer) { + clearInterval(timer); + } + }; + + process.on('exit', () => { + stopPolling(); + }); + + const deviceTracker = new DeviceTracker(); + + const runPolling = () => { + logger.info('[HW-DEBUG] Polling devices'); + const newDevice = deviceTracker.findNewDevice(); + + if (newDevice) { + onAdd(newDevice); + } + + const removedDevice = deviceTracker.findNewDevice(); + + if (removedDevice) { + onRemove(removedDevice); + } + }; + + timer = setInterval(runPolling, 1000); + + return () => { + stopPolling(); + }; +}; diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts new file mode 100644 index 0000000000..ba06d43925 --- /dev/null +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts @@ -0,0 +1,27 @@ +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; +}; + +export type TrackedDevice = { + deviceModel: string; + descriptor: string; + device: Device; +}; + +export type DectorUnsubscriber = () => void; + +export type Detector = ( + onAdd: (arg0: TrackedDevice) => void, + onRemove: (arg0: TrackedDevice) => void +) => DectorUnsubscriber; diff --git a/source/main/ipc/listenDevices.ts b/source/main/ipc/listenDevices.ts deleted file mode 100644 index 88f41275a5..0000000000 --- a/source/main/ipc/listenDevices.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { identifyUSBProductId, ledgerUSBVendorId } from '@ledgerhq/devices'; -import TransportNodeHid, { - getDevices, -} from '@ledgerhq/hw-transport-node-hid-noevents'; - -import usbDetect from 'usb-detection'; - -import { log, listen } from '@ledgerhq/logs'; -import debounce from 'lodash/debounce'; -import { logger } from '../utils/logging'; - -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; -}; - -listen(logger.info); - -let isMonitoring = false; - -const deviceToLog = ({ productId, locationId, deviceAddress }) => - `productId=${productId} locationId=${locationId} deviceAddress=${deviceAddress}`; - -const monitor = () => { - if (!isMonitoring) { - isMonitoring = true; - usbDetect.startMonitoring(); - } -}; - -let usbDebounce = 100; -export const setUsbDebounce = (n: number) => { - usbDebounce = n; -}; - -let deviceList = getDevices(); - -const getDevicePath = (d: Device) => d.path; - -const getFlatDevices = () => [ - ...new Set(getDevices().map((d: Device) => getDevicePath(d))), -]; - -const getDeviceByPaths = (paths) => - deviceList.find((d: Device) => paths.includes(getDevicePath(d))); - -const lastDevices = new Map(); - -const addLastDevice = (newDevices: Device[]) => - newDevices.forEach((d) => lastDevices.set(d.path, d)); -addLastDevice(deviceList); - -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 (isMonitoring) { - // redeem the monitoring so the process can be terminated. - usbDetect.stopMonitoring(); - } - - 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 -) => { - const addEvent = `add:${ledgerUSBVendorId}`; - const removeEvent = `remove:${ledgerUSBVendorId}`; - let timeout; - - monitor(); - - 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, - currentDevices, - lastDevices: Array.from(lastDevices.keys()), - }); - - deviceList = getDevices(); - onAdd(getPayloadData('add', getDeviceByPaths(newDevices))); - addLastDevice(deviceList); - } else { - log('[HID-LISTEN]', 'No new device found', { - currentDevices, - lastDevices: Array.from(lastDevices.keys()), - }); - } - - 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, - currentDevices, - lastDevices: Array.from(lastDevices.keys()), - }); - - onRemove(getPayloadData('remove', removedDevice)); - - lastDevices.delete(key); - } else { - log('[HID-LISTEN]', 'No removed device found', { - currentDevices, - lastDevices: Array.from(lastDevices.keys()), - }); - } - }; - - const add = (device: usbDetect.Device) => { - log('[USB-DETECTION]', `add: ${deviceToLog(device)}`); - - if (!timeout) { - // a time is needed for the device to actually be connectable over HID.. - // we also take this time to not emit the device yet and potentially cancel it if a remove happens. - timeout = setTimeout(() => { - poll(); - timeout = null; - }, 1500); - } - }; - - const remove = (device: usbDetect.Device) => { - log('[USB-DETECTION]', `remove: ${deviceToLog(device)}`); - - if (timeout) { - clearTimeout(timeout); - timeout = null; - } else { - poll(); - } - }; - - if (TransportNodeHid.isSupported()) { - logger.info('[LISTEN-LEDGER-DEVICES] Using usb-detection'); - usbDetect.on(addEvent, add); - usbDetect.on(removeEvent, remove); - } else { - logger.info('[LISTEN-LEDGER-DEVICES] Using polling'); - const debouncedPoll = debounce(poll, usbDebounce); - timer = setInterval(debouncedPoll, 1000); - } -}; diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 15a8c21131..d0ffa7b99e 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -30,6 +30,7 @@ import { isLedgerEnabled, getHardwareWalletsNetworkConfig, } from '../config/hardwareWalletsConfig'; +import { DEVICE_NOT_CONNECTED } from '../../../common/ipc/api'; import { TIME_TO_LIVE } from '../config/txnsConfig'; import { getHardwareWalletTransportChannel, @@ -1023,7 +1024,7 @@ export default class HardwareWalletsStore extends Store { ); } - if (error.code === 'DEVICE_NOT_CONNECTED') { + if (error.code === DEVICE_NOT_CONNECTED) { // Special case. E.g. device unplugged before cardano app is opened // Stop poller and re-initiate connecting state / don't kill devices listener this.stopCardanoAdaAppFetchPoller(); @@ -2359,12 +2360,7 @@ export default class HardwareWalletsStore extends Store { hardwareWalletConnectionData.device.deviceType === DeviceTypes.TREZOR ) { // Do I have unpaired Trezor devices - const lastUnpairedDevice = findLast( - this.hardwareWalletDevices, - (hardwareWalletDevice) => - // @ts-ignore ts-migrate(2339) FIXME: Property 'paired' does not exist on type 'Hardware... Remove this comment to see the full error message - !hardwareWalletDevice.paired && !hardwareWalletDevice.disconnected - ); + const lastUnpairedDevice = this.getLastUnpairedDevice(); if (lastUnpairedDevice) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. From 03b6cffd111a48a3055c4f4d36387685d6db61ee Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Fri, 25 Mar 2022 20:17:06 -0300 Subject: [PATCH 17/51] [DDW-722] Review changes --- .eslintrc | 6 +++ ...ted.ts => cardano-app-already-launched.ts} | 11 +++-- .../cardano-app-not-started.ts | 17 ++++---- hardware-wallet-tests/index.ts | 17 +++++--- .../multiple-hardware-wallets.ts | 9 ++-- .../remove-multiple-hardware-wallets.ts | 5 +-- .../remove-single-hardware-wallet.ts | 5 +-- hardware-wallet-tests/utils.ts | 43 ++++++++++++------- source/common/ipc/api.ts | 1 + 9 files changed, 67 insertions(+), 47 deletions(-) rename hardware-wallet-tests/{cardano-app-already-connected.ts => cardano-app-already-launched.ts} (90%) diff --git a/.eslintrc b/.eslintrc index e663872998..86c1c23963 100755 --- a/.eslintrc +++ b/.eslintrc @@ -109,6 +109,12 @@ "no-useless-constructor": "off", "@typescript-eslint/no-useless-constructor": "error" } + }, + { + "files": "hardware-wallet-tests/**/*.ts", + "rules": { + "jest/no-standalone-expect": "off", + } } ] } diff --git a/hardware-wallet-tests/cardano-app-already-connected.ts b/hardware-wallet-tests/cardano-app-already-launched.ts similarity index 90% rename from hardware-wallet-tests/cardano-app-already-connected.ts rename to hardware-wallet-tests/cardano-app-already-launched.ts index 75ff4d5249..0af690ea66 100644 --- a/hardware-wallet-tests/cardano-app-already-connected.ts +++ b/hardware-wallet-tests/cardano-app-already-launched.ts @@ -1,11 +1,10 @@ -/* eslint-disable jest/no-standalone-expect */ import expect from 'expect'; import { createAndRegisterHardwareWalletChannels, createHardwareWalletConnectionChannel, initLedgerChannel, - createSequentialPromptMessages, + createTestInstructions, createCardanoAppChannel, createGetPublicKeyChannel, ipcRenderer, @@ -14,9 +13,9 @@ import { export const run = () => { expect.assertions(3); - createSequentialPromptMessages([ + createTestInstructions([ 'Plug Ledger Nano S to your computer', - 'Start Cardano APP on Nano S', + 'Launch Cardano APP on Nano S/Nano X', 'Run the test again with Cardano App opened', 'Export the public key', ]); @@ -27,7 +26,7 @@ export const run = () => { const publicKeyChannel = createGetPublicKeyChannel(); const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - return new Promise((resolve) => { + return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (params: { path: string }) => { expect(params).toEqual({ @@ -69,7 +68,7 @@ export const run = () => { deviceId: expect.any(String), }); - resolve(null); + resolve(); } ); diff --git a/hardware-wallet-tests/cardano-app-not-started.ts b/hardware-wallet-tests/cardano-app-not-started.ts index fb71d6919d..b8d95ec79e 100644 --- a/hardware-wallet-tests/cardano-app-not-started.ts +++ b/hardware-wallet-tests/cardano-app-not-started.ts @@ -1,20 +1,19 @@ -/* eslint-disable jest/no-standalone-expect */ import expect from 'expect'; import { createAndRegisterHardwareWalletChannels, createHardwareWalletConnectionChannel, initLedgerChannel, - createSequentialPromptMessages, + createTestInstructions, createGetPublicKeyChannel, ipcRenderer, - pollCardarnoApp, + requestLaunchingCardanoAppOnLedger, } from './utils'; export const run = () => { expect.assertions(3); - createSequentialPromptMessages([ + createTestInstructions([ 'Start test runner', 'Plug Ledger Nano S to your computer', 'Start Cardano APP on Nano S', @@ -27,7 +26,7 @@ export const run = () => { const publicKeyChannel = createGetPublicKeyChannel(); const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - return new Promise((resolve) => { + return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (params: { path: string }) => { expect(params).toEqual({ @@ -40,9 +39,11 @@ export const run = () => { }); try { - const cardanoAppChannelReply = await pollCardarnoApp(params.path); + const cardanoAppChannelResponse = await requestLaunchingCardanoAppOnLedger( + params.path + ); - expect(cardanoAppChannelReply).toEqual({ + expect(cardanoAppChannelResponse).toEqual({ minor: expect.any(Number), major: expect.any(Number), patch: expect.any(Number), @@ -66,7 +67,7 @@ export const run = () => { deviceId: expect.any(String), }); - resolve(null); + resolve(); } catch (err) { return null; } diff --git a/hardware-wallet-tests/index.ts b/hardware-wallet-tests/index.ts index 256512e0a1..a32ce357ff 100644 --- a/hardware-wallet-tests/index.ts +++ b/hardware-wallet-tests/index.ts @@ -1,14 +1,14 @@ 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 runCardanoAppAlreadyLaunched } from './cardano-app-already-launched'; 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 SINGLE_LEDGER_DISCONNECTED = 'SINGLE_LEDGER_DISCONNECTED'; const MULTIPLE_HARDWARE_WALLETS = 'MULTIPLE_HARDWARE_WALLETS'; const MULTIPLE_HARDWARE_WALLETS_REMOVED = 'MULTIPLE_HARDWARE_WALLETS_REMOVED'; @@ -19,15 +19,18 @@ const MULTIPLE_HARDWARE_WALLETS_REMOVED = 'MULTIPLE_HARDWARE_WALLETS_REMOVED'; message: 'Ledger Hardware Wallets Channel', choices: [ { - title: 'export public key when Cardano APP is already connected', + title: 'export public key when Cardano APP is already launched', value: CARDANO_APP_ALREADY_LAUNCHED, }, { title: - 'export public key when Cardano APP is launched after test runner already has started', + 'export public key when Cardano APP is launched after running HW test script', value: CARDANO_APP_NOT_STARTED, }, - { title: 'detect when ledger is removed', value: SINGLE_LEDGER_REMOVED }, + { + title: 'detect when ledger is disconnected from computer', + value: SINGLE_LEDGER_DISCONNECTED, + }, { title: 'connect both Nano S and Nano X', value: MULTIPLE_HARDWARE_WALLETS, @@ -42,14 +45,14 @@ const MULTIPLE_HARDWARE_WALLETS_REMOVED = 'MULTIPLE_HARDWARE_WALLETS_REMOVED'; switch (testType) { case CARDANO_APP_ALREADY_LAUNCHED: - await runCardanoAppAlreadyConnected(); + await runCardanoAppAlreadyLaunched(); break; case CARDANO_APP_NOT_STARTED: await runCardanoAppNotStarted(); break; - case SINGLE_LEDGER_REMOVED: + case SINGLE_LEDGER_DISCONNECTED: await runRemoveSingleHardwareWallet(); break; diff --git a/hardware-wallet-tests/multiple-hardware-wallets.ts b/hardware-wallet-tests/multiple-hardware-wallets.ts index 88b20edbba..fd17a1c699 100644 --- a/hardware-wallet-tests/multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/multiple-hardware-wallets.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-standalone-expect */ import expect from 'expect'; import { @@ -6,7 +5,7 @@ import { createHardwareWalletConnectionChannel, createSequentialResult, initLedgerChannel, - createSequentialPromptMessages, + createTestInstructions, } from './utils'; export const run = () => { @@ -16,7 +15,7 @@ export const run = () => { const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - const promptMessages = createSequentialPromptMessages([ + const promptMessages = createTestInstructions([ 'Start test runner', 'Plug Ledger Nano S to your computer', 'Plug Ledger Nano X to your computer', @@ -37,9 +36,9 @@ export const run = () => { return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( - async (params: { path: string; deviceModel: string }) => { + async (message: { path: string; deviceModel: string }) => { const [expectedValue, isOver] = expectedSequence(); - expect(params).toEqual(expectedValue); + expect(message).toEqual(expectedValue); if (isOver) { resolve(null); diff --git a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts index 44e3dd3460..e9a2f69c97 100644 --- a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-standalone-expect */ import expect from 'expect'; import { @@ -6,7 +5,7 @@ import { createHardwareWalletConnectionChannel, createSequentialResult, initLedgerChannel, - createSequentialPromptMessages, + createTestInstructions, } from './utils'; const expectedSequence = createSequentialResult([ @@ -35,7 +34,7 @@ export const run = () => { const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - const promptMessages = createSequentialPromptMessages([ + const promptMessages = createTestInstructions([ 'Connect Nano S', 'Connect Nano X', 'Disconnect Nano S', diff --git a/hardware-wallet-tests/remove-single-hardware-wallet.ts b/hardware-wallet-tests/remove-single-hardware-wallet.ts index 58ebadf95d..bec8fe92d0 100644 --- a/hardware-wallet-tests/remove-single-hardware-wallet.ts +++ b/hardware-wallet-tests/remove-single-hardware-wallet.ts @@ -1,18 +1,17 @@ -/* eslint-disable jest/no-standalone-expect */ import expect from 'expect'; import { createAndRegisterHardwareWalletChannels, createHardwareWalletConnectionChannel, initLedgerChannel, - createSequentialPromptMessages, + createTestInstructions, createSequentialResult, } from './utils'; export const run = () => { expect.assertions(3); - const promptMessages = createSequentialPromptMessages([ + const promptMessages = createTestInstructions([ 'Plug Ledger Nano S to your computer', 'Disconnect Nano S', ]); diff --git a/hardware-wallet-tests/utils.ts b/hardware-wallet-tests/utils.ts index 0327d3c285..9319ea9a40 100644 --- a/hardware-wallet-tests/utils.ts +++ b/hardware-wallet-tests/utils.ts @@ -1,18 +1,23 @@ import expect from 'expect'; -import chalk from 'chalk'; import createIPCMock from 'electron-mock-ipc'; +import chalk from 'chalk'; import { IpcChannel, IpcReceiver, IpcSender, } from '../source/common/ipc/lib/IpcChannel'; +import { + GET_INIT_LEDGER_CONNECT_CHANNEL, + GET_CARDANO_ADA_APP_CHANNEL, + GET_EXTENDED_PUBLIC_KEY_CHANNEL, + GET_HARDWARE_WALLET_CONNECTION_CHANNEL, + DEVICE_NOT_CONNECTED, +} from '../source/common/ipc/api'; import { createChannels } from '../source/main/ipc/createHardwareWalletIPCChannels'; import { handleHardwareWalletRequests } from '../source/main/ipc/getHardwareWalletChannel'; -const mocked = createIPCMock(); -const { ipcMain } = mocked; -const { ipcRenderer } = mocked; +const { ipcMain, ipcRenderer } = createIPCMock(); export { ipcMain, ipcRenderer }; @@ -57,44 +62,52 @@ export const createAndRegisterHardwareWalletChannels = () => export const initLedgerChannel = () => { const initLedgerConnectChannel = new MockIpcChannel( - 'GET_INIT_LEDGER_CONNECT_CHANNEL' + GET_INIT_LEDGER_CONNECT_CHANNEL ); initLedgerConnectChannel.request({}, ipcRenderer, ipcMain); }; export const createCardanoAppChannel = () => - new MockIpcChannel('GET_CARDANO_ADA_APP_CHANNEL'); + new MockIpcChannel(GET_CARDANO_ADA_APP_CHANNEL); export const createGetPublicKeyChannel = () => - new MockIpcChannel('GET_EXTENDED_PUBLIC_KEY_CHANNEL'); + new MockIpcChannel(GET_EXTENDED_PUBLIC_KEY_CHANNEL); export const createHardwareWalletConnectionChannel = () => - new MockIpcChannel('GET_HARDWARE_WALLET_CONNECTION_CHANNEL'); + new MockIpcChannel(GET_HARDWARE_WALLET_CONNECTION_CHANNEL); -export const pollCardarnoApp = (deviceId: string) => +export const requestLaunchingCardanoAppOnLedger = (deviceId: string) => new Promise((resolve, reject) => { const cardanoAppChannel = createCardanoAppChannel(); const interval = setInterval(async () => { try { - const cardanoAppChannelReply = await cardanoAppChannel.request( + const cardanoAppChannelResponse = await cardanoAppChannel.request( { path: deviceId }, ipcRenderer, ipcRenderer ); clearInterval(interval); - return resolve(cardanoAppChannelReply); + return resolve(cardanoAppChannelResponse); } catch (err) { - if (err.code === 'DEVICE_NOT_CONNECTED') { + if (err.code === DEVICE_NOT_CONNECTED) { clearInterval(interval); return reject(err); } - return null; } }, 2000); }); -export const createSequentialResult = (sequence) => { +interface Result { + disconnected: boolean; + deviceType: string; + deviceId: string | null; + deviceModel: string; + deviceName: string; + path: string; +} + +export const createSequentialResult = (sequence: Array>) => { const common = { disconnected: expect.any(Boolean), deviceType: expect.any(String), @@ -112,7 +125,7 @@ export const createSequentialResult = (sequence) => { export const log = (message: string) => console.log(chalk.whiteBright.bgBlackBright.bold(message)); // eslint-disable-line no-console -export const createSequentialPromptMessages = (messages: string[]) => { +export const createTestInstructions = (messages: string[]) => { messages.forEach((m, i) => log(`${i + 1} - ${m}`)); return () => log(messages.shift()); diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index bc4e286169..7d59760563 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -511,3 +511,4 @@ export type showAddressMainResponse = void; export const TOGGLE_RTS_FLAGS_MODE_CHANNEL = 'TOGGLE_RTS_FLAGS_MODE_CHANNEL'; export type ToggleRTSFlagsModeRendererRequest = void; export type ToggleRTSFlagsModeMainResponse = void; +export const DEVICE_NOT_CONNECTED = 'DEVICE_NOT_CONNECTED'; From be15be711aee20ca20fa459a01ecf6043ffea999 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 31 Mar 2022 18:37:22 -0300 Subject: [PATCH 18/51] [DDW-722] Fix initial transaction --- source/common/ipc/api.ts | 3 + .../ipc/createHardwareWalletIPCChannels.ts | 17 +++- source/main/ipc/getHardwareWalletChannel.ts | 94 ++++++++++++++----- .../ledger/deviceDetection/deviceTracker.ts | 4 + .../deviceDetection/eventDrivenDetection.ts | 2 +- .../deviceDetection/pollingDrivenDetection.ts | 2 +- source/main/utils/logging.ts | 2 +- .../app/ipc/getHardwareWalletChannel.ts | 7 ++ .../app/stores/HardwareWalletsStore.ts | 63 ++++++++++--- 9 files changed, 146 insertions(+), 48 deletions(-) diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index 7d59760563..90a561990b 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -512,3 +512,6 @@ export const TOGGLE_RTS_FLAGS_MODE_CHANNEL = 'TOGGLE_RTS_FLAGS_MODE_CHANNEL'; export type ToggleRTSFlagsModeRendererRequest = void; export type ToggleRTSFlagsModeMainResponse = void; export const DEVICE_NOT_CONNECTED = 'DEVICE_NOT_CONNECTED'; +export const WAIT_FOR_LEDGER_DEVICES = 'WAIT_FOR_LEDGER_DEVICES'; +export type waitForLedgerDevicesRequest = void; +export type waitForLedgerDevicesResponse = void; diff --git a/source/main/ipc/createHardwareWalletIPCChannels.ts b/source/main/ipc/createHardwareWalletIPCChannels.ts index 6abed87ac6..6bcfae595c 100644 --- a/source/main/ipc/createHardwareWalletIPCChannels.ts +++ b/source/main/ipc/createHardwareWalletIPCChannels.ts @@ -8,8 +8,8 @@ import { getCardanoAdaAppRendererRequest, getExtendedPublicKeyMainResponse, getExtendedPublicKeyRendererRequest, - getHardwareWalletConnectiontMainRequest, - getHardwareWalletConnectiontRendererResponse, + getHardwareWalletConnectionMainRequest, + getHardwareWalletConnectionRendererResponse, getHardwareWalletTransportMainResponse, getHardwareWalletTransportRendererRequest, handleInitLedgerConnectMainResponse, @@ -36,6 +36,9 @@ import { SHOW_ADDRESS_CHANNEL, SIGN_TRANSACTION_LEDGER_CHANNEL, SIGN_TRANSACTION_TREZOR_CHANNEL, + WAIT_FOR_LEDGER_DEVICES, + waitForLedgerDevicesRequest, + waitForLedgerDevicesResponse, } from '../../common/ipc/api'; export interface HardwareWalletChannels { @@ -53,8 +56,8 @@ export interface HardwareWalletChannels { >; getHardwareWalletConnectionChannel: IpcChannel< - getHardwareWalletConnectiontMainRequest, - getHardwareWalletConnectiontRendererResponse + getHardwareWalletConnectionMainRequest, + getHardwareWalletConnectionRendererResponse >; signTransactionLedgerChannel: IpcChannel< @@ -96,6 +99,11 @@ export interface HardwareWalletChannels { showAddressRendererRequest, showAddressMainResponse >; + + waitForLedgerDevicesChannel: IpcChannel< + waitForLedgerDevicesRequest, + waitForLedgerDevicesResponse + >; } export const createChannels = ( @@ -122,5 +130,6 @@ export const createChannels = ( deriveXpubChannel: new Channel(DERIVE_XPUB_CHANNEL), deriveAddressChannel: new Channel(DERIVE_ADDRESS_CHANNEL), showAddressChannel: new Channel(SHOW_ADDRESS_CHANNEL), + waitForLedgerDevicesChannel: new Channel(WAIT_FOR_LEDGER_DEVICES), }; }; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index cc173785a5..65240c07cb 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -22,6 +22,7 @@ import { logger } from '../utils/logging'; import { HardwareWalletTransportDeviceRequest } from '../../common/types/hardware-wallets.types'; import { HardwareWalletChannels } from './createHardwareWalletIPCChannels'; +import { Device } from './hardwareWallets/ledger/deviceDetection/types'; type ListenerType = { unsubscribe: (...args: Array) => any; @@ -101,6 +102,7 @@ class EventObserver { } else { logger.info('[HW-DEBUG] CONSTRUCTOR REMOVE'); devicesMemo = omit(devicesMemo, [device.path]); + this.getHardwareWalletConnectionChannel.send( { disconnected: true, @@ -151,6 +153,7 @@ export const handleHardwareWalletRequests = async ( deriveXpubChannel, deriveAddressChannel, showAddressChannel, + waitForLedgerDevicesChannel, }: HardwareWalletChannels ) => { let deviceConnection = null; @@ -220,6 +223,12 @@ export const handleHardwareWalletRequests = async ( }); }; + waitForLedgerDevicesChannel.onRequest(async () => { + logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::waiting'); + await waitForDevice(); + logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::found'); + }); + getHardwareWalletTransportChannel.onRequest( async (request: HardwareWalletTransportDeviceRequest) => { const { isTrezor, devicePath } = request; @@ -283,6 +292,28 @@ export const handleHardwareWalletRequests = async ( )}` ); + const openTransportLayer = async ( + pathToOpen: string, + device: Device + ) => { + if (devicesMemo[pathToOpen]) { + logger.info('[HW-DEBUG] CLOSING EXISTING TRANSPORT'); + await devicesMemo[pathToOpen].transport.close(); + } + const transport = await TransportNodeHid.open(pathToOpen); + hw = transport; + lastConnectedPath = pathToOpen; + + logger.info('[HW-DEBUG] INIT NEW transport - DONE'); + + deviceConnection = new AppAda(transport); + devicesMemo[lastConnectedPath] = { + device, + transport: hw, + AdaConnection: deviceConnection, + }; + }; + // @ts-ignore if (transportList && !transportList.length) { // Establish connection with last device @@ -291,22 +322,26 @@ export const handleHardwareWalletRequests = async ( logger.info('[HW-DEBUG] INIT NEW transport'); const { device } = await waitForDevice(); - if (devicesMemo[device.path]) { - logger.info('[HW-DEBUG] CLOSING EXISTING TRANSPORT'); - await devicesMemo[device.path].transport.close(); - } - const transport = await TransportNodeHid.open(device.path); - hw = transport; - lastConnectedPath = device.path; - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.info('[HW-DEBUG] INIT NEW transport - DONE'); - // @ts-ignore - deviceConnection = new AppAda(transport); - devicesMemo[lastConnectedPath] = { - device, - transport: hw, - AdaConnection: deviceConnection, - }; + + await openTransportLayer(device.path, device); + + // const { device } = await waitForDevice(); + // if (devicesMemo[device.path]) { + // logger.info('[HW-DEBUG] CLOSING EXISTING TRANSPORT'); + // await devicesMemo[device.path].transport.close(); + // } + // const transport = await TransportNodeHid.open(device.path); + // hw = transport; + // lastConnectedPath = device.path; + // // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. + // logger.info('[HW-DEBUG] INIT NEW transport - DONE'); + // // @ts-ignore + // deviceConnection = new AppAda(transport); + // devicesMemo[lastConnectedPath] = { + // device, + // transport: hw, + // AdaConnection: deviceConnection, + // }; } catch (e) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] INIT NEW transport - ERROR'); @@ -314,14 +349,18 @@ export const handleHardwareWalletRequests = async ( } } else if (!devicePath || !devicesMemo[devicePath]) { // Use first like native usb nodeHID - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.info('[HW-DEBUG] USE First'); // @ts-ignore lastConnectedPath = transportList[0]; // eslint-disable-line + logger.info('[HW-DEBUG] USE First transport', { lastConnectedPath }); if (devicesMemo[lastConnectedPath]) { - hw = devicesMemo[lastConnectedPath].transport; - deviceConnection = devicesMemo[lastConnectedPath].AdaConnection; + await openTransportLayer( + lastConnectedPath, + devicesMemo[lastConnectedPath].device + ); + + // hw = devicesMemo[lastConnectedPath].transport; + // deviceConnection = devicesMemo[lastConnectedPath].AdaConnection; } else { throw new Error('Device not connected!'); } @@ -336,12 +375,8 @@ export const handleHardwareWalletRequests = async ( const { deviceModel } = hw; if (deviceModel) { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.info( - '[HW-DEBUG] getHardwareWalletTransportChannel:: LEDGER case RESPONSE' - ); const { id, productName } = deviceModel; - return Promise.resolve({ + const ledgerData = { deviceId: null, // @TODO - to be defined deviceType: 'ledger', @@ -351,7 +386,14 @@ export const handleHardwareWalletRequests = async ( // e.g. Ledger Nano S path: lastConnectedPath || devicePath, firmwareVersion: null, - }); + }; + + logger.info( + '[HW-DEBUG] getHardwareWalletTransportChannel:: LEDGER case RESPONSE', + { ledgerData } + ); + + return Promise.resolve(ledgerData); } throw new Error('Missing device info'); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts index 54b3121a65..5279fc801d 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts @@ -71,6 +71,10 @@ export class DeviceTracker { .map((d) => d); const knownDevicesPath = Array.from(this.knownDevices.keys()); + logger.info('[HW-DEBUG] DeviceTracker - Removed device path', { + removedDevicePath, + }); + if (removedDevicePath) { const removedDevice = this.knownDevices.get(removedDevicePath); this.knownDevices.delete(removedDevicePath); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts index d6c9528d11..cdaef1ce0f 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts @@ -68,7 +68,7 @@ export const detectDevices: Detector = (onAdd, onRemove) => { clearTimeout(timeout); timeout = null; } else { - const removedDevice = deviceTracker.findNewDevice(); + const removedDevice = deviceTracker.findRemovedDevice(); if (removedDevice) { onRemove(removedDevice); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts index 1f166be9b2..740d8f1095 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/pollingDrivenDetection.ts @@ -25,7 +25,7 @@ export const detectDevices: Detector = (onAdd, onRemove) => { onAdd(newDevice); } - const removedDevice = deviceTracker.findNewDevice(); + const removedDevice = deviceTracker.findRemovedDevice(); if (removedDevice) { onRemove(removedDevice); diff --git a/source/main/utils/logging.ts b/source/main/utils/logging.ts index a8d7cfe3b1..ab921c4c48 100644 --- a/source/main/utils/logging.ts +++ b/source/main/utils/logging.ts @@ -34,7 +34,7 @@ const logToLevel = (level: string) => ( }); export const logger: Logger = { - debug: logToLevel('debug'), + debug: logToLevel('info'), info: logToLevel('info'), error: logToLevel('error'), warn: logToLevel('warn'), diff --git a/source/renderer/app/ipc/getHardwareWalletChannel.ts b/source/renderer/app/ipc/getHardwareWalletChannel.ts index 1097114465..fa9c298e57 100644 --- a/source/renderer/app/ipc/getHardwareWalletChannel.ts +++ b/source/renderer/app/ipc/getHardwareWalletChannel.ts @@ -12,6 +12,9 @@ import { DERIVE_XPUB_CHANNEL, DERIVE_ADDRESS_CHANNEL, SHOW_ADDRESS_CHANNEL, + WAIT_FOR_LEDGER_DEVICES, + waitForLedgerDevicesRequest, + waitForLedgerDevicesResponse, } from '../../../common/ipc/api'; import type { getHardwareWalletTransportRendererRequest, @@ -93,3 +96,7 @@ export const showAddressChannel: RendererIpcChannel< showAddressMainResponse, showAddressRendererRequest > = new RendererIpcChannel(SHOW_ADDRESS_CHANNEL); +export const waitForLedgerDevicesChannel: RendererIpcChannel< + waitForLedgerDevicesResponse, + waitForLedgerDevicesRequest +> = new RendererIpcChannel(WAIT_FOR_LEDGER_DEVICES); diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index d0ffa7b99e..2876bc9600 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1,14 +1,5 @@ import { observable, action, runInAction, computed } from 'mobx'; -import { - get, - map, - find, - findLast, - includes, - last, - sortBy, - filter, -} from 'lodash'; +import { get, map, find, includes, last, sortBy, filter } from 'lodash'; import semver from 'semver'; import { TransactionSigningMode, @@ -44,6 +35,7 @@ import { resetTrezorActionChannel, deriveAddressChannel, showAddressChannel, + waitForLedgerDevicesChannel, } from '../ipc/getHardwareWalletChannel'; import { prepareLedgerInput, @@ -244,6 +236,7 @@ export default class HardwareWalletsStore extends Store { } | null; // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. checkTransactionTimeInterval: IntervalID | null | undefined = null; + connectedHardwareWalletsDevices: Set = new Set(); setup() { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -678,7 +671,7 @@ export default class HardwareWalletsStore extends Store { const lastUnpairedDevice = this.getLastUnpairedDevice(); // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.debug( + logger.info( '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: START', { lastUnpairedDevice: toJS(lastUnpairedDevice), @@ -690,7 +683,7 @@ export default class HardwareWalletsStore extends Store { if (this.isTransactionInitiated || this.isAddressVerificationInitiated) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.debug( + logger.info( '[HW-DEBUG] HWStore - Establish connection:: New Transaction / Address verification initiated - check device' ); @@ -783,6 +776,10 @@ export default class HardwareWalletsStore extends Store { } // End of special case } + logger.debug('[HW-DEBUG] HWStore - return lastDeviceTransport', { + lastDeviceTransport: toJS(lastDeviceTransport), + }); + return lastDeviceTransport; } @@ -814,8 +811,20 @@ export default class HardwareWalletsStore extends Store { devicePath, isTrezor, }); - } else { + } else if ( + this.connectedHardwareWalletsDevices.has( + lastUnpairedDevice?.device?.path + ) + ) { transportDevice = lastUnpairedDevice; + } else { + logger.debug( + '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: last unpaired device not connected' + ); + runInAction('HardwareWalletsStore:: set device listener', () => { + this.isListeningForDevice = true; + }); + return null; } // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -1024,9 +1033,12 @@ export default class HardwareWalletsStore extends Store { ); } - if (error.code === DEVICE_NOT_CONNECTED) { + if (error.code === DEVICE_NOT_CONNECTED && !this.isTransactionInitiated) { // Special case. E.g. device unplugged before cardano app is opened // Stop poller and re-initiate connecting state / don't kill devices listener + logger.info( + '[HW-DEBUG] HW Store::getCardanoAdaApp::DEVICE_NOT_CONNECTED' + ); this.stopCardanoAdaAppFetchPoller(); runInAction( 'HardwareWalletsStore:: Re-run initiated connection', @@ -2378,7 +2390,12 @@ export default class HardwareWalletsStore extends Store { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.debug('[HW-DEBUG] INITIATE tx - I have transport'); } else { + logger.info('[HW-DEBUG] HW STORE WAIT FOR LEDGER DEVICE'); + await waitForLedgerDevicesChannel.request(); transportDevice = await this.establishHardwareWalletConnection(); + logger.info('[HW-DEBUG] HW STORE Transport received', { + transportDevice, + }); } if (!transportDevice) { @@ -2399,6 +2416,16 @@ export default class HardwareWalletsStore extends Store { }); throw e; } + } else if ( + deviceType === DeviceTypes.LEDGER && + !this.connectedHardwareWalletsDevices.has(devicePath) + ) { + logger.info( + '[HW-DEBUG] HWStore::initiateTransaction::Device not connected' + ); + const transportDevice = await this.establishHardwareWalletConnection(); + console.log('transportDevice', transportDevice); + devicePath = transportDevice.path; } runInAction( @@ -2495,6 +2522,12 @@ export default class HardwareWalletsStore extends Store { params, }); + if (disconnected) { + this.connectedHardwareWalletsDevices.delete(path); + } else { + this.connectedHardwareWalletsDevices.add(path); + } + // Handle Trezor Bridge instance checker if (error && deviceType === DeviceTypes.TREZOR) { if ( @@ -2666,7 +2699,7 @@ export default class HardwareWalletsStore extends Store { (this.isListeningForDevice && !disconnected && (!eventType || eventType === DeviceEvents.CONNECT)) || - isCardanoAppInProgress + (isCardanoAppInProgress && !this.isTransactionInitiated) ) { runInAction('HardwareWalletsStore:: remove device listener', () => { this.isListeningForDevice = false; From 7d375e74af4d34953e70819c78064669ba37504a Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 4 Apr 2022 15:33:33 -0300 Subject: [PATCH 19/51] [DDW-722] Fix receiving address flow --- package.json | 3 +- source/common/ipc/api.ts | 5 +- source/common/types/hardware-wallets.types.ts | 1 + source/main/ipc/getHardwareWalletChannel.ts | 93 +++++++++++++----- source/renderer/app/api/utils/localStorage.ts | 19 ++-- .../containers/wallet/WalletReceivePage.tsx | 8 +- .../app/stores/HardwareWalletsStore.ts | 95 +++++++++++++++---- 7 files changed, 170 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index b29d16099f..8820734fe3 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "lockfile:check": "ts-node utils/lockfile-checker/index.ts --check", "lockfile:fix": "ts-node utils/lockfile-checker/index.ts --fix", "postinstall": "./scripts/postinstall.sh", - "typedef:sass": "typed-scss-modules source/renderer/app" + "typedef:sass": "typed-scss-modules source/renderer/app", + "dev:windows": "cross-env DAEDALUS_INSTALL_DIRECTORY=\"C:\\Program Files\\Daedalus Testnet\" LAUNCHER_CONFIG=\"C:\\Program Files\\Daedalus Testnet\\launcher-config.yaml\" IS_WATCH_MODE=true gulp dev" }, "bin": { "electron": "./node_modules/.bin/electron" diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index 90a561990b..c3fbb794f6 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -474,6 +474,7 @@ export type getExtendedPublicKeyMainResponse = HardwareWalletExtendedPublicKeyRe export const GET_CARDANO_ADA_APP_CHANNEL = 'GET_CARDANO_ADA_APP_CHANNEL'; export type getCardanoAdaAppRendererRequest = { path: string | null | undefined; + product?: string; }; export type getCardanoAdaAppMainResponse = HardwareWalletCardanoAdaAppResponse; export const GET_HARDWARE_WALLET_CONNECTION_CHANNEL = @@ -514,4 +515,6 @@ export type ToggleRTSFlagsModeMainResponse = void; export const DEVICE_NOT_CONNECTED = 'DEVICE_NOT_CONNECTED'; export const WAIT_FOR_LEDGER_DEVICES = 'WAIT_FOR_LEDGER_DEVICES'; export type waitForLedgerDevicesRequest = void; -export type waitForLedgerDevicesResponse = void; +export type waitForLedgerDevicesResponse = { + device: { path: string; product: string }; +}; diff --git a/source/common/types/hardware-wallets.types.ts b/source/common/types/hardware-wallets.types.ts index 1cf779b407..a59c9b0d04 100644 --- a/source/common/types/hardware-wallets.types.ts +++ b/source/common/types/hardware-wallets.types.ts @@ -290,6 +290,7 @@ export type HardwareWalletConnectionRequest = { deviceModel: string; deviceName: string; path: string | null | undefined; + product: string | null; error?: { payload: { code: string; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 65240c07cb..2c0cdee2a0 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -19,7 +19,10 @@ import { } from './hardwareWallets/ledger/deviceDetection'; import { IpcSender } from '../../common/ipc/lib/IpcChannel'; import { logger } from '../utils/logging'; -import { HardwareWalletTransportDeviceRequest } from '../../common/types/hardware-wallets.types'; +import { + HardwareWalletTransportDeviceRequest, + TransportDevice, +} from '../../common/types/hardware-wallets.types'; import { HardwareWalletChannels } from './createHardwareWalletIPCChannels'; import { Device } from './hardwareWallets/ledger/deviceDetection/types'; @@ -92,6 +95,7 @@ class EventObserver { deviceName: deviceModel.productName, // e.g. Test Name path: device.path, + product: device.product, }, this.mainWindow ); @@ -114,6 +118,10 @@ class EventObserver { deviceName: deviceModel.productName, // e.g. Test Name path: device.path, + + productId: device.productId, + + product: device.product, }, this.mainWindow ); @@ -225,8 +233,14 @@ export const handleHardwareWalletRequests = async ( waitForLedgerDevicesChannel.onRequest(async () => { logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::waiting'); - await waitForDevice(); + const trackedDevice = await waitForDevice(); logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::found'); + return { + device: { + path: trackedDevice.device.path, + product: trackedDevice.device.product, + }, + }; }); getHardwareWalletTransportChannel.onRequest( @@ -268,7 +282,7 @@ export const handleHardwareWalletRequests = async ( deviceName: label, path: devicePath, firmwareVersion, - }); + } as TransportDevice); } throw deviceFeatures.payload; // Error is in payload @@ -324,24 +338,6 @@ export const handleHardwareWalletRequests = async ( const { device } = await waitForDevice(); await openTransportLayer(device.path, device); - - // const { device } = await waitForDevice(); - // if (devicesMemo[device.path]) { - // logger.info('[HW-DEBUG] CLOSING EXISTING TRANSPORT'); - // await devicesMemo[device.path].transport.close(); - // } - // const transport = await TransportNodeHid.open(device.path); - // hw = transport; - // lastConnectedPath = device.path; - // // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - // logger.info('[HW-DEBUG] INIT NEW transport - DONE'); - // // @ts-ignore - // deviceConnection = new AppAda(transport); - // devicesMemo[lastConnectedPath] = { - // device, - // transport: hw, - // AdaConnection: deviceConnection, - // }; } catch (e) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] INIT NEW transport - ERROR'); @@ -358,9 +354,6 @@ export const handleHardwareWalletRequests = async ( lastConnectedPath, devicesMemo[lastConnectedPath].device ); - - // hw = devicesMemo[lastConnectedPath].transport; - // deviceConnection = devicesMemo[lastConnectedPath].AdaConnection; } else { throw new Error('Device not connected!'); } @@ -376,7 +369,7 @@ export const handleHardwareWalletRequests = async ( if (deviceModel) { const { id, productName } = deviceModel; - const ledgerData = { + const ledgerData: TransportDevice = { deviceId: null, // @TODO - to be defined deviceType: 'ledger', @@ -583,9 +576,57 @@ export const handleHardwareWalletRequests = async ( } }); getCardanoAdaAppChannel.onRequest(async (request) => { - const { path } = request; + const { path, product } = request; try { + if (!devicesMemo[path]) { + const deviceList = getDevices(); + const device = + find(deviceList, ['product', product]) || + find(deviceList, ['path', path]); + + logger.info('[HW-DEBUG] getCardanoAdaAppChannel:: Path not found', { + product, + deviceList, + oldPath: path, + }); + + if (!device) { + logger.info('[HW-DEBUG] Device not instantiated!', { + path, + devicesMemo, + }); + // eslint-disable-next-line + throw { + code: 'DEVICE_NOT_CONNECTED', + }; + } + + const newTransport = await TransportNodeHid.open(device.path); + const newDeviceConnection = new AppAda(newTransport); + + logger.info('[HW-DEBUG] getCardanoAdaAppChannel::Use new device path', { + product, + device, + newPath: device.path, + oldPath: path, + }); + + devicesMemo[device.path] = { + device, + transport: newTransport, + AdaConnection: newDeviceConnection, + }; + + if (device.path !== path) { + // eslint-disable-next-line + throw { + code: 'DEVICE_PATH_CHANGED', + path: device.path, + }; + } + } + if (!path || !devicesMemo[path]) { logger.info('[HW-DEBUG] Device not instantiated!', { path, diff --git a/source/renderer/app/api/utils/localStorage.ts b/source/renderer/app/api/utils/localStorage.ts index 4951c37aba..f9974c70a6 100644 --- a/source/renderer/app/api/utils/localStorage.ts +++ b/source/renderer/app/api/utils/localStorage.ts @@ -39,15 +39,18 @@ export type SetHardwareWalletLocalDataRequestType = { export type SetHardwareWalletDeviceRequestType = { deviceId: string | null | undefined; // @TODO - mark as mandatory parameter once Ledger improver - data: { - deviceType?: DeviceType; - deviceModel?: string; - deviceName?: string; - path?: string | null | undefined; - paired?: string | null | undefined; - disconnected?: boolean; - }; + data: UnpairedHardwareWalletData; }; + +export type UnpairedHardwareWalletData = { + deviceType?: DeviceType; + deviceModel?: string; + deviceName?: string; + path?: string | null | undefined; + paired?: string | null | undefined; + disconnected?: boolean; +}; + export type HardwareWalletLocalData = { id: string; deviceType: DeviceType; diff --git a/source/renderer/app/containers/wallet/WalletReceivePage.tsx b/source/renderer/app/containers/wallet/WalletReceivePage.tsx index e028f5070c..bdc40fb7dc 100755 --- a/source/renderer/app/containers/wallet/WalletReceivePage.tsx +++ b/source/renderer/app/containers/wallet/WalletReceivePage.tsx @@ -70,7 +70,13 @@ class WalletReceivePage extends Component { const dialog = WalletReceiveDialog; if (activeWallet && activeWallet.isHardwareWallet) { - hardwareWallets.initiateAddressVerification(addressToShare); + try { + hardwareWallets.initiateAddressVerification(addressToShare); + } catch (err) { + hardwareWallets.resetInitializedAddressVerification({ + cancelDeviceAction: true, + }); + } } dialogs.open.trigger({ diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 2876bc9600..3a19a88c3f 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -87,6 +87,7 @@ import type { HardwareWalletDevicesType, SetHardwareWalletLocalDataRequestType, SetHardwareWalletDeviceRequestType, + UnpairedHardwareWalletData, } from '../api/utils/localStorage'; import type { TransportDevice, @@ -236,7 +237,7 @@ export default class HardwareWalletsStore extends Store { } | null; // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. checkTransactionTimeInterval: IntervalID | null | undefined = null; - connectedHardwareWalletsDevices: Set = new Set(); + connectedHardwareWalletsDevices: Map = new Map(); setup() { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -305,6 +306,12 @@ export default class HardwareWalletsStore extends Store { } }; + waitForLedgerDevices = async () => { + const { device } = await waitForLedgerDevicesChannel.request(); + this.connectedHardwareWalletsDevices.set(device.path, device.product); + return device; + }; + useCardanoAppInterval = ( devicePath: string | null | undefined, txWalletId: string | null | undefined, @@ -315,6 +322,7 @@ export default class HardwareWalletsStore extends Store { const poller = () => { let canRun = true; let isRunning = false; + const product = this.connectedHardwareWalletsDevices.get(devicePath); const run = async () => { try { @@ -328,6 +336,7 @@ export default class HardwareWalletsStore extends Store { path: devicePath, walletId: txWalletId, address: verificationAddress, + product, }); } catch (_error) { if (!canRun) { @@ -806,15 +815,21 @@ export default class HardwareWalletsStore extends Store { '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: Listening for device' ); + let lastUnpairedDevicePath; + + if ('path' in lastUnpairedDevice) { + lastUnpairedDevicePath = lastUnpairedDevice.path; + } else if ('device' in lastUnpairedDevice) { + lastUnpairedDevicePath = lastUnpairedDevice.device.path; + } + if (lastUnpairedDevice.deviceType === DeviceTypes.TREZOR) { transportDevice = await getHardwareWalletTransportChannel.request({ devicePath, isTrezor, }); } else if ( - this.connectedHardwareWalletsDevices.has( - lastUnpairedDevice?.device?.path - ) + this.connectedHardwareWalletsDevices.has(lastUnpairedDevicePath) ) { transportDevice = lastUnpairedDevice; } else { @@ -953,8 +968,9 @@ export default class HardwareWalletsStore extends Store { path: string | null | undefined; walletId?: string; address?: WalletAddress | null | undefined; + product?: string; }) => { - const { path, walletId, address } = params; + const { path, walletId, address, product } = params; logger.debug( '[HW-DEBUG] HWStore - START FUNCTION getCardanoAdaApp PARAMS: ', { @@ -968,6 +984,7 @@ export default class HardwareWalletsStore extends Store { try { const cardanoAdaApp = await getCardanoAdaAppChannel.request({ path, + product, }); logger.debug( '[HW-DEBUG] HWStore - cardanoAdaApp RESPONSE: ', @@ -1033,7 +1050,11 @@ export default class HardwareWalletsStore extends Store { ); } - if (error.code === DEVICE_NOT_CONNECTED && !this.isTransactionInitiated) { + if ( + error.code === DEVICE_NOT_CONNECTED && + !this.isTransactionInitiated && + !this.isAddressVerificationInitiated + ) { // Special case. E.g. device unplugged before cardano app is opened // Stop poller and re-initiate connecting state / don't kill devices listener logger.info( @@ -1056,13 +1077,21 @@ export default class HardwareWalletsStore extends Store { // @ts-ignore ts-migrate(2339) FIXME: Property 'path' does not exist on type 'HardwareWa... Remove this comment to see the full error message (recognizedDevice) => recognizedDevice.path === path ); + + if ( + !pairedDevice && + walletId && + (this.isTransactionInitiated || this.isAddressVerificationInitiated) + ) { + this.useCardanoAppInterval(error.path, walletId, address); + throw error; + } // Update device with new path - LC await this._setHardwareWalletDevice({ deviceId: pairedDevice.id, // @ts-ignore ts-migrate(2322) FIXME: Type '{ path: any; isPending: false; id: string; d... Remove this comment to see the full error message data: { ...pairedDevice, path: error.path, isPending: false }, }); - // Update connected wallet data with new path - LC if (walletId) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -1173,7 +1202,15 @@ export default class HardwareWalletsStore extends Store { logger.debug('[HW-DEBUG] CHECK FOR NEXT device'); try { - transportDevice = await this.establishHardwareWalletConnection(); + if (deviceType === DeviceTypes.LEDGER) { + logger.info( + '[HW-DEBUG] HW STORE::initiateAddressVerification:: wait for ledger devices' + ); + await this.waitForLedgerDevices(); + transportDevice = await this.establishHardwareWalletConnection(); + } else { + transportDevice = await this.establishHardwareWalletConnection(); + } if (transportDevice) { devicePath = transportDevice.path; @@ -1191,6 +1228,26 @@ export default class HardwareWalletsStore extends Store { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.debug('[HW-DEBUG] HWStore - Establishing connection failed'); } + } else if (deviceType === DeviceTypes.LEDGER) { + const connectedDevice = await this.waitForLedgerDevices(); + devicePath = connectedDevice.path; + + if (!transportDevice) { + transportDevice = await this.establishHardwareWalletConnection(); + logger.debug( + '[HW-DEBUG] HWStore - Set transport device for ledger device', + { + transportDevice: toJS(transportDevice), + } + ); + } + + runInAction( + 'HardwareWalletsStore:: Set transport device from tx init', + () => { + this.transportDevice = transportDevice; + } + ); } if (deviceType === DeviceTypes.TREZOR) { @@ -1233,6 +1290,7 @@ export default class HardwareWalletsStore extends Store { this.useCardanoAppInterval( devicePath, // @ts-ignore Argument of type 'WalletAddress' is not assignable to parameter of type 'string'.ts(2345) + walletId, address ); } @@ -2325,6 +2383,7 @@ export default class HardwareWalletsStore extends Store { this.activeDevicePath = null; }); } catch (error) { + logger.info('[HW-DEBUG] HWStore:: sign Transaction Ledger', { error }); runInAction( 'HardwareWalletsStore:: set Transaction verifying failed', () => { @@ -2391,7 +2450,7 @@ export default class HardwareWalletsStore extends Store { logger.debug('[HW-DEBUG] INITIATE tx - I have transport'); } else { logger.info('[HW-DEBUG] HW STORE WAIT FOR LEDGER DEVICE'); - await waitForLedgerDevicesChannel.request(); + await this.waitForLedgerDevices(); transportDevice = await this.establishHardwareWalletConnection(); logger.info('[HW-DEBUG] HW STORE Transport received', { transportDevice, @@ -2423,9 +2482,8 @@ export default class HardwareWalletsStore extends Store { logger.info( '[HW-DEBUG] HWStore::initiateTransaction::Device not connected' ); - const transportDevice = await this.establishHardwareWalletConnection(); - console.log('transportDevice', transportDevice); - devicePath = transportDevice.path; + const connectedDevice = await this.waitForLedgerDevices(); + devicePath = connectedDevice.path; } runInAction( @@ -2517,6 +2575,7 @@ export default class HardwareWalletsStore extends Store { path, error, eventType, + product, } = params; logger.debug('[HW-DEBUG] HWStore - CHANGE status: ', { params, @@ -2525,7 +2584,7 @@ export default class HardwareWalletsStore extends Store { if (disconnected) { this.connectedHardwareWalletsDevices.delete(path); } else { - this.connectedHardwareWalletsDevices.add(path); + this.connectedHardwareWalletsDevices.set(path, product); } // Handle Trezor Bridge instance checker @@ -2699,7 +2758,7 @@ export default class HardwareWalletsStore extends Store { (this.isListeningForDevice && !disconnected && (!eventType || eventType === DeviceEvents.CONNECT)) || - (isCardanoAppInProgress && !this.isTransactionInitiated) + isCardanoAppInProgress ) { runInAction('HardwareWalletsStore:: remove device listener', () => { this.isListeningForDevice = false; @@ -2768,7 +2827,9 @@ export default class HardwareWalletsStore extends Store { } }; - getLastUnpairedDevice = () => + getLastUnpairedDevice = (): + | HardwareWalletLocalData + | UnpairedHardwareWalletData => last( sortBy( filter( @@ -3014,9 +3075,9 @@ export default class HardwareWalletsStore extends Store { await this._refreshHardwareWalletDevices(); } }; + stopCardanoAdaAppFetchPoller = () => { - // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.debug('[HW-DEBUG] HWStore - STOP Ada App poller'); + logger.info('[HW-DEBUG] HWStore - STOP Ada App poller'); if (this.cardanoAdaAppPollingInterval) { this.cardanoAdaAppPollingInterval.stop(); From 178dd6112982af47f8756595921717c991c902dd Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 10:00:01 -0300 Subject: [PATCH 20/51] [DDW-722] Add a few logs --- package.json | 1 - .../renderer/app/stores/HardwareWalletsStore.ts | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8820734fe3..6410964801 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "start": "gulp start", "start:dev": "NODE_ENV=development gulp start", "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": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", "test:generate:report": "ts-node tests/reporter.ts", diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 3a19a88c3f..dc45e63a17 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -831,6 +831,15 @@ export default class HardwareWalletsStore extends Store { } else if ( this.connectedHardwareWalletsDevices.has(lastUnpairedDevicePath) ) { + logger.debug( + '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: last unpaired matched with connected devices', + { + connectedHardwareWalletsDevices: Array.from( + this.connectedHardwareWalletsDevices.keys() + ), + lastUnpairedDevicePath, + } + ); transportDevice = lastUnpairedDevice; } else { logger.debug( @@ -923,7 +932,13 @@ export default class HardwareWalletsStore extends Store { const devicePath = transportDevice.path; // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.debug( - '[HW-DEBUG] HWStore - getCardanoAdaApp - from establishHardwareWalletConnection' + '[HW-DEBUG] HWStore - getCardanoAdaApp - from establishHardwareWalletConnection', + { + devicePath, + connectedHardwareWalletsDevices: Array.from( + this.connectedHardwareWalletsDevices.keys() + ), + } ); this.stopCardanoAdaAppFetchPoller(); // @ts-ignore ts-migrate(2554) FIXME: Expected 5 arguments, but got 3. From 9d66298032e0a7bcfa637ed6ca6eccb4850e5ea5 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 13:54:18 -0300 Subject: [PATCH 21/51] [DDW-722] Add entry point for wallet pairing --- .../app/containers/wallet/WalletAddPage.tsx | 10 +++++-- .../app/stores/HardwareWalletsStore.ts | 27 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/source/renderer/app/containers/wallet/WalletAddPage.tsx b/source/renderer/app/containers/wallet/WalletAddPage.tsx index e7472d8f4c..5b1c30a83b 100644 --- a/source/renderer/app/containers/wallet/WalletAddPage.tsx +++ b/source/renderer/app/containers/wallet/WalletAddPage.tsx @@ -29,6 +29,10 @@ class WalletAddPage extends Component { onClose = () => { this.props.actions.dialogs.closeActiveDialog.trigger(); }; + onCloseWalletPairing = () => { + this.props.stores.hardwareWallets.resetWalletPairing(); + this.onClose(); + }; render() { const { actions, stores } = this.props; @@ -62,7 +66,7 @@ class WalletAddPage extends Component { actions.dialogs.open.trigger({ dialog: WalletConnectDialog, }); - stores.hardwareWallets.establishHardwareWalletConnection(); + stores.hardwareWallets.initiateWalletPairing(); }; let activeDialog = null; @@ -84,7 +88,9 @@ class WalletAddPage extends Component { // @ts-ignore ts-migrate(2769) FIXME: No overload matches this call. activeDialog = ; } else if (uiDialogs.isOpen(WalletConnectDialog)) { - activeDialog = ; + activeDialog = ( + + ); } return ( diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index dc45e63a17..a68ce8dcbe 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -213,6 +213,8 @@ export default class HardwareWalletsStore extends Store { @observable isAddressVerificationInitiated = false; @observable + isWalletPairingInitiated = false; + @observable unfinishedWalletAddressVerification: WalletAddress | null | undefined = null; @observable isAddressDerived = false; @@ -1068,7 +1070,8 @@ export default class HardwareWalletsStore extends Store { if ( error.code === DEVICE_NOT_CONNECTED && !this.isTransactionInitiated && - !this.isAddressVerificationInitiated + !this.isAddressVerificationInitiated && + !this.isWalletPairingInitiated ) { // Special case. E.g. device unplugged before cardano app is opened // Stop poller and re-initiate connecting state / don't kill devices listener @@ -1096,7 +1099,9 @@ export default class HardwareWalletsStore extends Store { if ( !pairedDevice && walletId && - (this.isTransactionInitiated || this.isAddressVerificationInitiated) + (this.isTransactionInitiated || + this.isAddressVerificationInitiated || + this.isWalletPairingInitiated) ) { this.useCardanoAppInterval(error.path, walletId, address); throw error; @@ -1419,6 +1424,24 @@ export default class HardwareWalletsStore extends Store { throw error; } }; + + @action + initiateWalletPairing = () => { + if (this.isWalletPairingInitiated) return; + logger.debug('[HW-DEBUG] HWStore::initiateWalletPairing'); + + this.isWalletPairingInitiated = true; + + this.establishHardwareWalletConnection(); + }; + + @action + resetWalletPairing = () => { + this.stopCardanoAdaAppFetchPoller(); + + this.isWalletPairingInitiated = false; + }; + @action showAddress = async (params: { address: WalletAddress; From d54351d6334389388a445fb7f44182f5e04593c8 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 14:39:26 -0300 Subject: [PATCH 22/51] [DDW-722] Restart polling for pairing --- .../app/stores/HardwareWalletsStore.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index a68ce8dcbe..4c40fd8bff 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1096,12 +1096,31 @@ export default class HardwareWalletsStore extends Store { (recognizedDevice) => recognizedDevice.path === path ); + logger.info( + '[HW-DEBUG] HW Store::getCardanoAdaApp::DEVICE_PATH_CHANGED', + { + path, + newPath: error.path, + pairedDevice, + walletId, + } + ); + + if (this.isWalletPairingInitiated) { + logger.info( + '[HW-DEBUG] HW Store::getCardanoAdaApp::wallet pairing::Retry with new path', + { + newPath: error.path, + } + ); + this.useCardanoAppInterval(error.path, walletId, address); + return; + } + if ( !pairedDevice && walletId && - (this.isTransactionInitiated || - this.isAddressVerificationInitiated || - this.isWalletPairingInitiated) + (this.isTransactionInitiated || this.isAddressVerificationInitiated) ) { this.useCardanoAppInterval(error.path, walletId, address); throw error; From 9c0c158e8bbdc7eb0a15771982438fd4574e0d84 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 15:32:13 -0300 Subject: [PATCH 23/51] [DDW-722] Restart polling for pairing --- source/renderer/app/stores/HardwareWalletsStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 4c40fd8bff..51350b8d1c 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1446,7 +1446,6 @@ export default class HardwareWalletsStore extends Store { @action initiateWalletPairing = () => { - if (this.isWalletPairingInitiated) return; logger.debug('[HW-DEBUG] HWStore::initiateWalletPairing'); this.isWalletPairingInitiated = true; From 6041cdb6101c0ad7fc5c066121fbcf1716189667 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 18:44:52 -0300 Subject: [PATCH 24/51] [DDW-722] Check if path is null --- .../app/stores/HardwareWalletsStore.ts | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 51350b8d1c..ca4eb665b5 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -825,12 +825,23 @@ export default class HardwareWalletsStore extends Store { lastUnpairedDevicePath = lastUnpairedDevice.device.path; } + logger.debug( + '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: compare last unpaired path', + { + connectedHardwareWalletsDevices: Array.from( + this.connectedHardwareWalletsDevices.keys() + ), + lastUnpairedDevicePath, + } + ); + if (lastUnpairedDevice.deviceType === DeviceTypes.TREZOR) { transportDevice = await getHardwareWalletTransportChannel.request({ devicePath, isTrezor, }); } else if ( + lastUnpairedDevicePath && this.connectedHardwareWalletsDevices.has(lastUnpairedDevicePath) ) { logger.debug( @@ -2637,10 +2648,31 @@ export default class HardwareWalletsStore extends Store { params, }); - if (disconnected) { - this.connectedHardwareWalletsDevices.delete(path); + if (path) { + if (disconnected) { + logger.debug( + '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::removing path from memory ', + { + path, + } + ); + this.connectedHardwareWalletsDevices.delete(path); + } else { + this.connectedHardwareWalletsDevices.set(path, product); + logger.debug( + '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::adding path to memory ', + { + path, + } + ); + } } else { - this.connectedHardwareWalletsDevices.set(path, product); + logger.debug( + '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::missing path', + { + path, + } + ); } // Handle Trezor Bridge instance checker From 86bfb2fa3f6d1815fb8184f3227bb91431b12d04 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 5 Apr 2022 22:17:26 -0300 Subject: [PATCH 25/51] [DDW-722] Wait for last device --- .../app/stores/HardwareWalletsStore.ts | 43 +++---------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index ca4eb665b5..b47c11b2f5 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -817,50 +817,21 @@ export default class HardwareWalletsStore extends Store { '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: Listening for device' ); - let lastUnpairedDevicePath; - - if ('path' in lastUnpairedDevice) { - lastUnpairedDevicePath = lastUnpairedDevice.path; - } else if ('device' in lastUnpairedDevice) { - lastUnpairedDevicePath = lastUnpairedDevice.device.path; - } - - logger.debug( - '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: compare last unpaired path', - { - connectedHardwareWalletsDevices: Array.from( - this.connectedHardwareWalletsDevices.keys() - ), - lastUnpairedDevicePath, - } - ); - if (lastUnpairedDevice.deviceType === DeviceTypes.TREZOR) { transportDevice = await getHardwareWalletTransportChannel.request({ devicePath, isTrezor, }); - } else if ( - lastUnpairedDevicePath && - this.connectedHardwareWalletsDevices.has(lastUnpairedDevicePath) - ) { - logger.debug( - '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: last unpaired matched with connected devices', - { - connectedHardwareWalletsDevices: Array.from( - this.connectedHardwareWalletsDevices.keys() - ), - lastUnpairedDevicePath, - } - ); - transportDevice = lastUnpairedDevice; } else { logger.debug( - '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: last unpaired device not connected' + '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: wait for new ledger devices' ); - runInAction('HardwareWalletsStore:: set device listener', () => { - this.isListeningForDevice = true; - }); + + const { path } = await this.waitForLedgerDevices(); + + this.stopCardanoAdaAppFetchPoller(); + // @ts-ignore ts-migrate(2554) FIXME: Expected 5 arguments, but got 3. + this.useCardanoAppInterval(path); return null; } From 24b3e417597bfd346a9cecd117c4b4cb3a60fb07 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 6 Apr 2022 09:35:18 -0300 Subject: [PATCH 26/51] [DDW-722] Remove unused code --- .../app/stores/HardwareWalletsStore.ts | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index b47c11b2f5..5fcb255722 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -2619,31 +2619,22 @@ export default class HardwareWalletsStore extends Store { params, }); - if (path) { - if (disconnected) { - logger.debug( - '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::removing path from memory ', - { - path, - } - ); - this.connectedHardwareWalletsDevices.delete(path); - } else { - this.connectedHardwareWalletsDevices.set(path, product); - logger.debug( - '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::adding path to memory ', - { - path, - } - ); - } + if (disconnected) { + logger.debug( + '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::removing path from memory ', + { + path, + } + ); + this.connectedHardwareWalletsDevices.delete(path); } else { logger.debug( - '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::missing path', + '[HW-DEBUG] HWStore - CHANGE status::in-memory-path::adding path to memory ', { path, } ); + this.connectedHardwareWalletsDevices.set(path, product); } // Handle Trezor Bridge instance checker From e4990c8ff0e932cb797d851037ee0ca8dc15d422 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 6 Apr 2022 13:49:10 -0300 Subject: [PATCH 27/51] [DDW-722] Forward ledger payload to HW store --- source/common/ipc/api.ts | 42 +++++++++++++++-- source/common/types/hardware-wallets.types.ts | 17 ++++++- source/main/ipc/getHardwareWalletChannel.ts | 25 +++++----- .../ledger/deviceDetection/deviceDetection.ts | 6 +-- .../ledger/deviceDetection/deviceTracker.ts | 4 +- .../ledger/deviceDetection/types.ts | 12 ++++- .../app/stores/HardwareWalletsStore.ts | 47 +++++++++++++++---- 7 files changed, 122 insertions(+), 31 deletions(-) diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index c3fbb794f6..31887689d8 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -1,3 +1,4 @@ +import { BridgeInfo, Device as TrezorDevice, UdevInfo } from 'trezor-connect'; import type { BugReportRequestHttpOptions, BugReportRequestPayload, @@ -463,6 +464,38 @@ export type GetBlockSyncProgressMainResponse = { /** * Channels for connecting / interacting with Hardware Wallet devices */ + +export type LedgerDevicePayload = { + disconnected: boolean; + deviceType: 'ledger'; + deviceId: string | null; + deviceModel: string; + deviceName: string; + path: string; + product: string; +}; + +export type TrezorDevicePayload = { + disconnected: boolean; + deviceId: string; + deviceType: 'trezor'; + deviceModel: TrezorDevice; + // e.g. "1" or "T" + deviceName: string; + path: string; + eventType: string; +}; + +export type TrezorDeviceErrorPayload = { + error?: { + payload: { + error: string; + bridge?: BridgeInfo; + udev?: UdevInfo; + }; + }; +}; + export const GET_HARDWARE_WALLET_TRANSPORT_CHANNEL = 'GET_HARDWARE_WALLET_TRANSPORT_CHANNEL'; export type getHardwareWalletTransportRendererRequest = HardwareWalletTransportDeviceRequest; @@ -480,7 +513,10 @@ export type getCardanoAdaAppMainResponse = HardwareWalletCardanoAdaAppResponse; export const GET_HARDWARE_WALLET_CONNECTION_CHANNEL = 'GET_HARDWARE_WALLET_CONNECTION_CHANNEL'; export type getHardwareWalletConnectionMainRequest = HardwareWalletConnectionRequest; -export type getHardwareWalletConnectionRendererResponse = Record; +export type getHardwareWalletConnectionRendererResponse = + | LedgerDevicePayload + | TrezorDevicePayload + | TrezorDeviceErrorPayload; export const SIGN_TRANSACTION_LEDGER_CHANNEL = 'SIGN_TRANSACTION_LEDGER_CHANNEL'; export type signTransactionLedgerRendererRequest = LedgerSignTransactionRequest; @@ -515,6 +551,4 @@ export type ToggleRTSFlagsModeMainResponse = void; export const DEVICE_NOT_CONNECTED = 'DEVICE_NOT_CONNECTED'; export const WAIT_FOR_LEDGER_DEVICES = 'WAIT_FOR_LEDGER_DEVICES'; export type waitForLedgerDevicesRequest = void; -export type waitForLedgerDevicesResponse = { - device: { path: string; product: string }; -}; +export type waitForLedgerDevicesResponse = LedgerDevicePayload; diff --git a/source/common/types/hardware-wallets.types.ts b/source/common/types/hardware-wallets.types.ts index a59c9b0d04..2f8084b6b5 100644 --- a/source/common/types/hardware-wallets.types.ts +++ b/source/common/types/hardware-wallets.types.ts @@ -99,15 +99,28 @@ export const CertificateTypes: { STAKE_DEREGISTRATION: 1, STAKE_DELEGATION: 2, }; -export type TransportDevice = { + +export type LedgerTransportDevice = { deviceId: string | null | undefined; // @TODO - mark as mandatory parameter once Ledger improver - deviceType: DeviceType; + deviceType: 'ledger'; + deviceModel: string; + deviceName: string; + path: string | null | undefined; +}; + +export type TrezorTransportDevice = { + deviceId: string | null | undefined; + // @TODO - mark as mandatory parameter once Ledger improver + deviceType: 'trezor'; deviceModel: string; deviceName: string; path: string | null | undefined; firmwareVersion: string | null | undefined; }; + +export type TransportDevice = LedgerTransportDevice | TrezorTransportDevice; + export type Certificate = { address: string; type: string; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 2c0cdee2a0..805a488385 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -26,6 +26,7 @@ import { import { HardwareWalletChannels } from './createHardwareWalletIPCChannels'; import { Device } from './hardwareWallets/ledger/deviceDetection/types'; +import { DeviceDetectionPayload } from './hardwareWallets/ledger/deviceDetection/deviceDetection'; type ListenerType = { unsubscribe: (...args: Array) => any; @@ -55,7 +56,7 @@ class EventObserver { this.getHardwareWalletConnectionChannel = getHardwareWalletConnectionChannel; } - next = async (event) => { + next = async (event: DeviceDetectionPayload) => { try { const transportList = await TransportNodeHid.list(); const connectionChanged = event.type === 'add' || event.type === 'remove'; @@ -69,8 +70,7 @@ class EventObserver { if (connectionChanged) { logger.info('[HW-DEBUG] Ledger NEXT - connection changed'); - const device = get(event, 'device', {}); - const deviceModel = get(event, 'deviceModel', {}); + const { device, deviceModel } = event; if (event.type === 'add') { if (!devicesMemo[device.path]) { @@ -118,9 +118,6 @@ class EventObserver { deviceName: deviceModel.productName, // e.g. Test Name path: device.path, - - productId: device.productId, - product: device.product, }, this.mainWindow @@ -233,13 +230,19 @@ export const handleHardwareWalletRequests = async ( waitForLedgerDevicesChannel.onRequest(async () => { logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::waiting'); - const trackedDevice = await waitForDevice(); + const { device, deviceModel } = await waitForDevice(); logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::found'); return { - device: { - path: trackedDevice.device.path, - product: trackedDevice.device.product, - }, + disconnected: false, + deviceType: 'ledger', + deviceId: null, + // Available only when Cardano APP opened + deviceModel: deviceModel.id, + // e.g. nanoS + deviceName: deviceModel.productName, + // e.g. Test Name + path: device.path, + product: device.product, }; }); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts index a0c28b6859..b7c22526cd 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts @@ -6,13 +6,13 @@ import { detectDevices as useEventDrivenDetection } from './eventDrivenDetection import { detectDevices as usePollingDrivenDetection } from './pollingDrivenDetection'; import { Detector, TrackedDevice, DectorUnsubscriber } from './types'; -type Payload = { +export type DeviceDetectionPayload = { type: 'add' | 'remove'; } & TrackedDevice; export const deviceDetection = ( - onAdd: (arg0: Payload) => void, - onRemove: (arg0: Payload) => void + onAdd: (arg0: DeviceDetectionPayload) => void, + onRemove: (arg0: DeviceDetectionPayload) => void ) => { Promise.resolve(DeviceTracker.getDevices()).then((devices) => { // this needs to run asynchronously so the subscription is defined during this phase diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts index 5279fc801d..e75862c360 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts @@ -2,7 +2,7 @@ import { getDevices } from '@ledgerhq/hw-transport-node-hid-noevents'; import { identifyUSBProductId } from '@ledgerhq/devices'; import { logger } from '../../../../utils/logging'; -import { Device, TrackedDevice } from './types'; +import { Device, TrackedDevice, DeviceModel } from './types'; export class DeviceTracker { knownDevices: Map; @@ -21,7 +21,7 @@ export class DeviceTracker { const descriptor: string = device.path; const deviceModel = (identifyUSBProductId( device.productId - ) as unknown) as string; + ) as unknown) as DeviceModel; return { device, deviceModel, descriptor } as TrackedDevice; } diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts index ba06d43925..9666cdd9b4 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts @@ -13,8 +13,18 @@ export type Device = { usage: number; }; +export type DeviceModel = { + id: string; + productName: string; + productIdMM: number; + legacyUsbProductId: number; + usbOnly: boolean; + memorySize: number; + blockSize: number; +}; + export type TrackedDevice = { - deviceModel: string; + deviceModel: DeviceModel; descriptor: string; device: Device; }; diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 5fcb255722..ba327e13d3 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -21,7 +21,11 @@ import { isLedgerEnabled, getHardwareWalletsNetworkConfig, } from '../config/hardwareWalletsConfig'; -import { DEVICE_NOT_CONNECTED } from '../../../common/ipc/api'; +import { + DEVICE_NOT_CONNECTED, + LedgerDevicePayload, + TrezorDevicePayload, +} from '../../../common/ipc/api'; import { TIME_TO_LIVE } from '../config/txnsConfig'; import { getHardwareWalletTransportChannel, @@ -239,7 +243,10 @@ export default class HardwareWalletsStore extends Store { } | null; // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. checkTransactionTimeInterval: IntervalID | null | undefined = null; - connectedHardwareWalletsDevices: Map = new Map(); + connectedHardwareWalletsDevices: Map< + string, + LedgerDevicePayload | TrezorDevicePayload + > = new Map(); setup() { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. @@ -309,8 +316,8 @@ export default class HardwareWalletsStore extends Store { }; waitForLedgerDevices = async () => { - const { device } = await waitForLedgerDevicesChannel.request(); - this.connectedHardwareWalletsDevices.set(device.path, device.product); + const device = await waitForLedgerDevicesChannel.request(); + this.connectedHardwareWalletsDevices.set(device.path, device); return device; }; @@ -324,7 +331,15 @@ export default class HardwareWalletsStore extends Store { const poller = () => { let canRun = true; let isRunning = false; - const product = this.connectedHardwareWalletsDevices.get(devicePath); + + const connectedDevice = this.connectedHardwareWalletsDevices.get( + devicePath + ); + + const product = + connectedDevice?.deviceType === 'ledger' + ? connectedDevice?.product + : null; const run = async () => { try { @@ -827,11 +842,19 @@ export default class HardwareWalletsStore extends Store { '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: wait for new ledger devices' ); - const { path } = await this.waitForLedgerDevices(); + const ledgerDevice = await this.waitForLedgerDevices(); + + logger.debug('[HW-DEBUG] HWStore - Use ledger device as transport', { + transportDevice: toJS(transportDevice), + }); + + runInAction('HardwareWalletsStore:: set HW device CONNECTED', () => { + this.transportDevice = ledgerDevice; + }); this.stopCardanoAdaAppFetchPoller(); // @ts-ignore ts-migrate(2554) FIXME: Expected 5 arguments, but got 3. - this.useCardanoAppInterval(path); + this.useCardanoAppInterval(ledgerDevice.path); return null; } @@ -2634,7 +2657,15 @@ export default class HardwareWalletsStore extends Store { path, } ); - this.connectedHardwareWalletsDevices.set(path, product); + this.connectedHardwareWalletsDevices.set(path, { + product, + path, + disconnected, + deviceId, + deviceModel, + deviceName, + deviceType, + }); } // Handle Trezor Bridge instance checker From 3886239bab7891118b6bffb6f2cda3c2c1836600 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 6 Apr 2022 19:13:41 -0300 Subject: [PATCH 28/51] [DDW-1012] Cleanup pending wallets --- .../app/stores/HardwareWalletsStore.ts | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index ba327e13d3..53436e78af 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1459,10 +1459,12 @@ export default class HardwareWalletsStore extends Store { }; @action - resetWalletPairing = () => { + resetWalletPairing = async () => { this.stopCardanoAdaAppFetchPoller(); this.isWalletPairingInitiated = false; + + return this.cleanUpPendingDevices(); }; @action @@ -1858,6 +1860,8 @@ export default class HardwareWalletsStore extends Store { this._refreshHardwareWalletsLocalData(); this._refreshHardwareWalletDevices(); + + await this.resetWalletPairing(); } catch (error) { // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.debug('[HW-DEBUG] HWStore - Export key error'); @@ -1924,6 +1928,8 @@ export default class HardwareWalletsStore extends Store { ); } + await this.resetWalletPairing(); + // Pass other errors to caller (establishHardwareWalletConnection() in this case) and handle additional actions if needed throw error; } @@ -2827,19 +2833,14 @@ export default class HardwareWalletsStore extends Store { await this._refreshHardwareWalletDevices(); // Start connection establishing process if devices listener flag is UP - const isCardanoAppInProgress = - !disconnected && this.cardanoAdaAppPollingInterval?.isRunning(); - logger.debug('[HW-DEBUG] HWStore - establish connection guard: ', { - isCardanoAppInProgress, isListeningForDevice: this.isListeningForDevice, }); if ( - (this.isListeningForDevice && - !disconnected && - (!eventType || eventType === DeviceEvents.CONNECT)) || - isCardanoAppInProgress + this.isListeningForDevice && + !disconnected && + (!eventType || eventType === DeviceEvents.CONNECT) ) { runInAction('HardwareWalletsStore:: remove device listener', () => { this.isListeningForDevice = false; @@ -2926,6 +2927,34 @@ export default class HardwareWalletsStore extends Store { ) ); + cleanUpPendingDevices = async () => { + const transformedData: Array<{ + id: string; + isPending: boolean; + }> = Object.entries(this.hardwareWalletDevices).map(([key, value]) => ({ + // @ts-ignore ts-migrate(2339) FIXME: Property 'paired' does not exist on type 'Hardware... Remove this comment to see the full error message + isPending: value.isPending, + id: key, + })); + + const pendingHardwareWallets = transformedData.filter( + ({ isPending }) => isPending + ); + + const pendingHardwareWalletsIds = pendingHardwareWallets.map( + ({ id }) => id + ); + + const unsetHardwareWalletDeviceRequests = pendingHardwareWalletsIds.map( + (id) => + this._unsetHardwareWalletDevice({ + deviceId: id, + }) + ); + + return Promise.all(unsetHardwareWalletDeviceRequests); + }; + @action resetInitializedConnection = async ( params: From 0d35bf5600fed7149f96b368704603b658043b1e Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Wed, 6 Apr 2022 21:23:25 -0300 Subject: [PATCH 29/51] [DDW-1012] Use public key as device id --- source/renderer/app/stores/HardwareWalletsStore.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 53436e78af..c04d6dad6f 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1578,7 +1578,10 @@ export default class HardwareWalletsStore extends Store { isTrezor, devicePath, }); - const deviceId = extendedPublicKey.deviceId || transportDevice.deviceId; + const deviceId = + extendedPublicKey.deviceId || + transportDevice.deviceId || + extendedPublicKey.publicKeyHex; logger.debug('[HW-DEBUG] HWStore - EXPORT - deviceID: ', { deviceId, }); @@ -2945,6 +2948,10 @@ export default class HardwareWalletsStore extends Store { ({ id }) => id ); + logger.debug('[HW-DEBUG] HWStore - cleanUpPendingDevices - cleanup ids: ', { + pendingHardwareWalletsIds, + }); + const unsetHardwareWalletDeviceRequests = pendingHardwareWalletsIds.map( (id) => this._unsetHardwareWalletDevice({ From 97f0b972fa47ff3368def8b723d6678ab29eb4b6 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 7 Apr 2022 11:03:39 -0300 Subject: [PATCH 30/51] [DDW-1012] Fix getSerial access property --- source/main/ipc/getHardwareWalletChannel.ts | 8 ++++---- source/renderer/app/stores/HardwareWalletsStore.ts | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 805a488385..9ae2e2483c 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -647,17 +647,17 @@ export const handleHardwareWalletRequests = async ( const { version } = await deviceConnection.getVersion(); // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info('[HW-DEBUG] getCardanoAdaAppChannel:: appVersion'); - const { serial } = await deviceConnection.getSerial(); + const { serialHex } = await deviceConnection.getSerial(); // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. logger.info( - `[HW-DEBUG] getCardanoAdaAppChannel:: deviceSerial: ${serial}` + `[HW-DEBUG] getCardanoAdaAppChannel:: deviceSerial: ${serialHex}` ); const { minor, major, patch } = version; return Promise.resolve({ minor, major, patch, - deviceId: serial, + deviceId: serialHex, }); } catch (error) { const errorCode = error.code || ''; @@ -806,7 +806,7 @@ export const handleHardwareWalletRequests = async ( return Promise.resolve({ publicKeyHex: extendedPublicKey.publicKeyHex, chainCodeHex: extendedPublicKey.chainCodeHex, - deviceId: deviceSerial.serial, + deviceId: deviceSerial.serialHex, }); } catch (error) { logger.info('[HW-DEBUG] EXPORT KEY ERROR', error); diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index c04d6dad6f..07a9ec9ec0 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1578,10 +1578,8 @@ export default class HardwareWalletsStore extends Store { isTrezor, devicePath, }); - const deviceId = - extendedPublicKey.deviceId || - transportDevice.deviceId || - extendedPublicKey.publicKeyHex; + const deviceId = extendedPublicKey.deviceId || transportDevice.deviceId; + logger.debug('[HW-DEBUG] HWStore - EXPORT - deviceID: ', { deviceId, }); From 4c3402fa1481354995f67a4f81193a37981a623f Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 7 Apr 2022 11:07:57 -0300 Subject: [PATCH 31/51] [DDW-1012] Add logs to HW local storage --- source/renderer/app/stores/HardwareWalletsStore.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 07a9ec9ec0..711fbb69c7 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -2719,7 +2719,10 @@ export default class HardwareWalletsStore extends Store { if (disconnected && deviceType === DeviceTypes.LEDGER) { // Remove all stored Ledger instances from LC - both pending and paired (with software Wallets) // @ts-ignore ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - logger.debug('[HW-DEBUG] HWStore - device disconnected'); + logger.debug('[HW-DEBUG] HWStore - device disconnected', { + hardwareWalletDevices: toJS(hardwareWalletDevices), + path, + }); const recognizedLedgerDevice = find( hardwareWalletDevices, // @ts-ignore ts-migrate(2339) FIXME: Property 'path' does not exist on type 'HardwareWa... Remove this comment to see the full error message From 2ee31f3b151edffc544fb0a291d0a2986b57c8cc Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 7 Apr 2022 16:41:50 -0300 Subject: [PATCH 32/51] [DDW-722] Add logs to extended public key --- source/renderer/app/stores/HardwareWalletsStore.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 711fbb69c7..1f0560b522 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -1562,6 +1562,10 @@ export default class HardwareWalletsStore extends Store { const { transportDevice } = this; if (!transportDevice) { + logger.debug( + '[HW-DEBUG] HWStore::_getExtendedPublicKey:: Device not recognized ' + ); + throw new Error( 'Can not export extended public key: Device not recognized!' ); From fdb52b988b8e2b03421818487497d2085ad5a524 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Thu, 7 Apr 2022 17:10:47 -0300 Subject: [PATCH 33/51] [DDW-722] Change ledger transport handler --- .../app/stores/HardwareWalletsStore.ts | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 1f0560b522..9ec3b86bf5 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -842,15 +842,7 @@ export default class HardwareWalletsStore extends Store { '[HW-DEBUG] HWStore - establishHardwareWalletConnection:: wait for new ledger devices' ); - const ledgerDevice = await this.waitForLedgerDevices(); - - logger.debug('[HW-DEBUG] HWStore - Use ledger device as transport', { - transportDevice: toJS(transportDevice), - }); - - runInAction('HardwareWalletsStore:: set HW device CONNECTED', () => { - this.transportDevice = ledgerDevice; - }); + const ledgerDevice = await this.waitForLedgerTransportDevice(); this.stopCardanoAdaAppFetchPoller(); // @ts-ignore ts-migrate(2554) FIXME: Expected 5 arguments, but got 3. @@ -1547,6 +1539,24 @@ export default class HardwareWalletsStore extends Store { this.verifyAddress(this.tempAddressToVerify); } }; + + waitForLedgerTransportDevice = async () => { + const ledgerDevice = await this.waitForLedgerDevices(); + + logger.debug( + '[HW-DEBUG] HWStore::getLedgerTransportDevice::Use ledger device as transport', + { + transportDevice: toJS(ledgerDevice), + } + ); + + runInAction('HardwareWalletsStore:: set HW transportDevice', () => { + this.transportDevice = ledgerDevice; + }); + + return ledgerDevice; + }; + @action _getExtendedPublicKey = async ( forcedPath: string | null | undefined, @@ -2522,8 +2532,9 @@ export default class HardwareWalletsStore extends Store { logger.debug('[HW-DEBUG] INITIATE tx - I have transport'); } else { logger.info('[HW-DEBUG] HW STORE WAIT FOR LEDGER DEVICE'); - await this.waitForLedgerDevices(); - transportDevice = await this.establishHardwareWalletConnection(); + + transportDevice = await this.waitForLedgerTransportDevice(); + logger.info('[HW-DEBUG] HW STORE Transport received', { transportDevice, }); @@ -2547,15 +2558,12 @@ export default class HardwareWalletsStore extends Store { }); throw e; } - } else if ( - deviceType === DeviceTypes.LEDGER && - !this.connectedHardwareWalletsDevices.has(devicePath) - ) { + } else if (deviceType === DeviceTypes.LEDGER) { logger.info( '[HW-DEBUG] HWStore::initiateTransaction::Device not connected' ); - const connectedDevice = await this.waitForLedgerDevices(); - devicePath = connectedDevice.path; + const ledgerDevice = await this.waitForLedgerTransportDevice(); + devicePath = ledgerDevice.path; } runInAction( From 96eaa7e37d74d16a06fadfb2c84974ea01a06695 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 11 Apr 2022 16:36:32 -0300 Subject: [PATCH 34/51] [DDW-722] Code cleanup --- .../cardano-app-already-launched.ts | 1 + .../cardano-app-not-started.ts | 39 ++++++++------- .../multiple-hardware-wallets.ts | 8 ++- .../remove-multiple-hardware-wallets.ts | 10 ++-- .../remove-single-hardware-wallet.ts | 9 ++-- hardware-wallet-tests/utils.ts | 3 +- source/common/ipc/api.ts | 35 ++----------- source/common/types/hardware-wallets.types.ts | 49 ++++++++++++++++--- source/main/ipc/getHardwareWalletChannel.ts | 2 +- source/main/utils/logging.ts | 2 +- .../app/stores/HardwareWalletsStore.ts | 10 ++-- 11 files changed, 82 insertions(+), 86 deletions(-) diff --git a/hardware-wallet-tests/cardano-app-already-launched.ts b/hardware-wallet-tests/cardano-app-already-launched.ts index 0af690ea66..4e144b15b5 100644 --- a/hardware-wallet-tests/cardano-app-already-launched.ts +++ b/hardware-wallet-tests/cardano-app-already-launched.ts @@ -36,6 +36,7 @@ export const run = () => { deviceModel: expect.any(String), deviceName: expect.any(String), path: expect.any(String), + product: expect.any(String), }); const cardanoAppChannelReply = await cardanoAppChannel.request( diff --git a/hardware-wallet-tests/cardano-app-not-started.ts b/hardware-wallet-tests/cardano-app-not-started.ts index b8d95ec79e..af6f5ce237 100644 --- a/hardware-wallet-tests/cardano-app-not-started.ts +++ b/hardware-wallet-tests/cardano-app-not-started.ts @@ -36,6 +36,7 @@ export const run = () => { deviceModel: expect.any(String), deviceName: expect.any(String), path: expect.any(String), + product: expect.any(String), }); try { @@ -49,28 +50,28 @@ export const run = () => { 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(); } catch (err) { return null; } + + 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(); } ); diff --git a/hardware-wallet-tests/multiple-hardware-wallets.ts b/hardware-wallet-tests/multiple-hardware-wallets.ts index fd17a1c699..08cb092d26 100644 --- a/hardware-wallet-tests/multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/multiple-hardware-wallets.ts @@ -15,15 +15,13 @@ export const run = () => { const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - const promptMessages = createTestInstructions([ + createTestInstructions([ 'Start test runner', 'Plug Ledger Nano S to your computer', 'Plug Ledger Nano X to your computer', ]); - promptMessages(); - - const expectedSequence = createSequentialResult([ + const getNextExpectedSequence = createSequentialResult([ { disconnected: false, deviceModel: 'nanoS', @@ -37,7 +35,7 @@ export const run = () => { return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (message: { path: string; deviceModel: string }) => { - const [expectedValue, isOver] = expectedSequence(); + const [expectedValue, isOver] = getNextExpectedSequence(); expect(message).toEqual(expectedValue); if (isOver) { diff --git a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts index e9a2f69c97..a2bf202c5d 100644 --- a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/remove-multiple-hardware-wallets.ts @@ -8,7 +8,7 @@ import { createTestInstructions, } from './utils'; -const expectedSequence = createSequentialResult([ +const getNextExpectedSequence = createSequentialResult([ { disconnected: false, deviceModel: 'nanoS', @@ -34,27 +34,23 @@ export const run = () => { const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - const promptMessages = createTestInstructions([ + createTestInstructions([ '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(); + const [expectedValue, isOver] = getNextExpectedSequence(); expect(params).toEqual(expectedValue); if (isOver) { return resolve(null); } - - promptMessages(); } ); diff --git a/hardware-wallet-tests/remove-single-hardware-wallet.ts b/hardware-wallet-tests/remove-single-hardware-wallet.ts index bec8fe92d0..209963a477 100644 --- a/hardware-wallet-tests/remove-single-hardware-wallet.ts +++ b/hardware-wallet-tests/remove-single-hardware-wallet.ts @@ -11,7 +11,7 @@ import { export const run = () => { expect.assertions(3); - const promptMessages = createTestInstructions([ + createTestInstructions([ 'Plug Ledger Nano S to your computer', 'Disconnect Nano S', ]); @@ -20,7 +20,7 @@ export const run = () => { const hardwareWalletConnectionChannel = createHardwareWalletConnectionChannel(); - const expectedSequence = createSequentialResult([ + const getNextExpectedSequence = createSequentialResult([ { disconnected: false, }, @@ -29,16 +29,13 @@ export const run = () => { }, ]); - promptMessages(); - return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (params: { path: string }) => { - const [expectedValue, isOver] = expectedSequence(); + const [expectedValue, isOver] = getNextExpectedSequence(); expect(params).toEqual(expectedValue); if (isOver) return resolve(null); - promptMessages(); } ); diff --git a/hardware-wallet-tests/utils.ts b/hardware-wallet-tests/utils.ts index 9319ea9a40..46f925e57d 100644 --- a/hardware-wallet-tests/utils.ts +++ b/hardware-wallet-tests/utils.ts @@ -115,6 +115,7 @@ export const createSequentialResult = (sequence: Array>) => { deviceModel: expect.any(String), deviceName: expect.any(String), path: expect.any(String), + product: expect.any(String), }; const result = sequence.map((s) => ({ ...common, ...s })); @@ -127,6 +128,4 @@ export const log = (message: string) => export const createTestInstructions = (messages: string[]) => { messages.forEach((m, i) => log(`${i + 1} - ${m}`)); - - return () => log(messages.shift()); }; diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index 31887689d8..ac001aeeb0 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -1,4 +1,3 @@ -import { BridgeInfo, Device as TrezorDevice, UdevInfo } from 'trezor-connect'; import type { BugReportRequestHttpOptions, BugReportRequestPayload, @@ -62,6 +61,9 @@ import type { TrezorSignTransactionRequest, TrezorSignTransactionResponse, HardwareWalletConnectionRequest, + LedgerDevicePayload, + TrezorDevicePayload, + TrezorDeviceErrorPayload, } from '../types/hardware-wallets.types'; /** @@ -465,37 +467,6 @@ export type GetBlockSyncProgressMainResponse = { * Channels for connecting / interacting with Hardware Wallet devices */ -export type LedgerDevicePayload = { - disconnected: boolean; - deviceType: 'ledger'; - deviceId: string | null; - deviceModel: string; - deviceName: string; - path: string; - product: string; -}; - -export type TrezorDevicePayload = { - disconnected: boolean; - deviceId: string; - deviceType: 'trezor'; - deviceModel: TrezorDevice; - // e.g. "1" or "T" - deviceName: string; - path: string; - eventType: string; -}; - -export type TrezorDeviceErrorPayload = { - error?: { - payload: { - error: string; - bridge?: BridgeInfo; - udev?: UdevInfo; - }; - }; -}; - export const GET_HARDWARE_WALLET_TRANSPORT_CHANNEL = 'GET_HARDWARE_WALLET_TRANSPORT_CHANNEL'; export type getHardwareWalletTransportRendererRequest = HardwareWalletTransportDeviceRequest; diff --git a/source/common/types/hardware-wallets.types.ts b/source/common/types/hardware-wallets.types.ts index 2f8084b6b5..5eed7dd32f 100644 --- a/source/common/types/hardware-wallets.types.ts +++ b/source/common/types/hardware-wallets.types.ts @@ -1,3 +1,5 @@ +import { BridgeInfo, Device as TrezorDevice, UdevInfo } from 'trezor-connect'; + export type BIP32Path = Array; export type LedgerModel = 'nanoS' | 'nanoX'; export type TrezorModel = '1' | 'T'; @@ -31,13 +33,10 @@ export const DeviceModels: { TREZOR_ONE: '1', TREZOR_T: 'T', }; -export const DeviceTypes: { - LEDGER: DeviceType; - TREZOR: DeviceType; -} = { - LEDGER: 'ledger', - TREZOR: 'trezor', -}; +export enum DeviceTypes { + LEDGER = 'ledger', + TREZOR = 'trezor', +} export const DeviceEvents: { CONNECT: DeviceEvent; CONNECT_UNACQUIRED: DeviceEvent; @@ -296,6 +295,42 @@ export type TrezorSignTransactionResponse = { success: boolean; payload: TrezorSerializedTxPayload | TrezorRawTxPayload; }; + +export type LedgerDevicePayload = { + disconnected: boolean; + deviceType: 'ledger'; + deviceId: string | null; + deviceModel: string; + deviceName: string; + path: string; + product: string; +}; + +export type TrezorDevicePayload = { + disconnected: boolean; + deviceId: string; + deviceType: 'trezor'; + deviceModel: TrezorDevice; + // e.g. "1" or "T" + deviceName: string; + path: string; + eventType: string; +}; + +export type TrezorDeviceErrorPayload = { + deviceType: 'trezor'; + error?: { + payload: { + error: string; + bridge?: BridgeInfo; + udev?: UdevInfo; + code?: string; + }; + }; +}; + +// FIXME: This should be LedgerDevicePayload | TrezorDevicePayload | TrezorDeviceErrorPayload +// Changing so will result in a huge refactoring on HardwareWalletsStore._changeHardwareWalletConnectionStatus export type HardwareWalletConnectionRequest = { disconnected: boolean; deviceType: DeviceType; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 9ae2e2483c..3a4924cd52 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -188,6 +188,7 @@ export const handleHardwareWalletRequests = async ( // Send Transport error to Renderer getHardwareWalletConnectionChannel.send( { + deviceType: 'trezor', error: { payload: event.payload, }, @@ -381,7 +382,6 @@ export const handleHardwareWalletRequests = async ( deviceName: productName, // e.g. Ledger Nano S path: lastConnectedPath || devicePath, - firmwareVersion: null, }; logger.info( diff --git a/source/main/utils/logging.ts b/source/main/utils/logging.ts index ab921c4c48..a8d7cfe3b1 100644 --- a/source/main/utils/logging.ts +++ b/source/main/utils/logging.ts @@ -34,7 +34,7 @@ const logToLevel = (level: string) => ( }); export const logger: Logger = { - debug: logToLevel('info'), + debug: logToLevel('debug'), info: logToLevel('info'), error: logToLevel('error'), warn: logToLevel('warn'), diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 9ec3b86bf5..868568ae59 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -21,11 +21,7 @@ import { isLedgerEnabled, getHardwareWalletsNetworkConfig, } from '../config/hardwareWalletsConfig'; -import { - DEVICE_NOT_CONNECTED, - LedgerDevicePayload, - TrezorDevicePayload, -} from '../../../common/ipc/api'; +import { DEVICE_NOT_CONNECTED } from '../../../common/ipc/api'; import { TIME_TO_LIVE } from '../config/txnsConfig'; import { getHardwareWalletTransportChannel, @@ -70,6 +66,8 @@ import { DeviceModels, DeviceTypes, DeviceEvents, + TrezorDevicePayload, + LedgerDevicePayload, } from '../../../common/types/hardware-wallets.types'; import { formattedAmountToLovelace } from '../utils/formatters'; import { TransactionStates } from '../domains/WalletTransaction'; @@ -2684,7 +2682,7 @@ export default class HardwareWalletsStore extends Store { deviceModel, deviceName, deviceType, - }); + } as LedgerDevicePayload | TrezorDevicePayload); } // Handle Trezor Bridge instance checker From da1c6bb104cdc2dd3f3a81be61feaf84210343b9 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 12 Apr 2022 14:57:52 -0300 Subject: [PATCH 35/51] [DDW-722] Add sequence diagram --- ...dwareWalletsStore.address-verification.uml | 52 +++++++++++++ .../HardwareWalletsStore.transaction.uml | 48 ++++++++++++ .../HardwareWalletsStore.wallet-pairing.uml | 73 +++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 source/renderer/app/stores/HardwareWalletsStore.address-verification.uml create mode 100644 source/renderer/app/stores/HardwareWalletsStore.transaction.uml create mode 100644 source/renderer/app/stores/HardwareWalletsStore.wallet-pairing.uml diff --git a/source/renderer/app/stores/HardwareWalletsStore.address-verification.uml b/source/renderer/app/stores/HardwareWalletsStore.address-verification.uml new file mode 100644 index 0000000000..99cbd371fe --- /dev/null +++ b/source/renderer/app/stores/HardwareWalletsStore.address-verification.uml @@ -0,0 +1,52 @@ +title Ledger Address Verification + +participant NanoS +participant Daedalus +participant WalletReceivePage +participant initiateAddressVerification +participant establishHardwareWalletConnection +participant getCardanoAdaApp +participant getExtendedPublicKey +participant verifyAddress +participant getHardwareWalletChannel +participant deviceDetection + + +autoactivation on +WalletReceivePage->initiateAddressVerification:Start address verification + +group Wait for device + + +initiateAddressVerification->getHardwareWalletChannel:Subscribe to WAIT_FOR_LEDGER_DEVICES channel + + +getHardwareWalletChannel->deviceDetection:check for new devices + +NanoS->deviceDetection:Enter Pin + +getHardwareWalletChannel<--deviceDetection:Device info + +initiateAddressVerification<--getHardwareWalletChannel:Reply WAIT_FOR_LEDGER_DEVICES channel\nwith device info + +end +deactivateafter initiateAddressVerification +autoactivation off + + +initiateAddressVerification->establishHardwareWalletConnection:get transport info +initiateAddressVerification<--establishHardwareWalletConnection:device transport + +activate initiateAddressVerification +initiateAddressVerification->initiateAddressVerification:start Ada App polling + + +NanoS->deviceDetection:Open Ada App +getCardanoAdaApp<--deviceDetection:App opened + +deactivate initiateAddressVerification + +getCardanoAdaApp->getExtendedPublicKey:Get public key +getExtendedPublicKey->verifyAddress:Address verification + +WalletReceivePage<--verifyAddress:address verified diff --git a/source/renderer/app/stores/HardwareWalletsStore.transaction.uml b/source/renderer/app/stores/HardwareWalletsStore.transaction.uml new file mode 100644 index 0000000000..5118cfe9f3 --- /dev/null +++ b/source/renderer/app/stores/HardwareWalletsStore.transaction.uml @@ -0,0 +1,48 @@ +title Ledger Initiate Transaction + +participant NanoS +participant Daedalus +participant WalletReceivePage +participant initiateTransaction +participant getCardanoAdaApp +participant getExtendedPublicKey +participant signTransactionLedger +participant getHardwareWalletChannel +participant deviceDetection + + +autoactivation on +WalletReceivePage->initiateTransaction:Start a new transaction + +group Wait for device + + +initiateTransaction->getHardwareWalletChannel:Subscribe to WAIT_FOR_LEDGER_DEVICES channel + + +getHardwareWalletChannel->deviceDetection:check for new devices + +NanoS->deviceDetection:Enter Pin + +getHardwareWalletChannel<--deviceDetection:Device info + +initiateTransaction<--getHardwareWalletChannel:Reply WAIT_FOR_LEDGER_DEVICES channel\nwith device info + +end +deactivateafter initiateTransaction +autoactivation off + + +activate initiateTransaction +initiateTransaction->initiateTransaction:start Ada App polling + + +NanoS->deviceDetection:Open Ada App +getCardanoAdaApp<--deviceDetection:App opened + +deactivate initiateTransaction + +getCardanoAdaApp->getExtendedPublicKey:Get public key +getExtendedPublicKey->signTransactionLedger:Verify transaction + +WalletReceivePage<--signTransactionLedger:transaction completed diff --git a/source/renderer/app/stores/HardwareWalletsStore.wallet-pairing.uml b/source/renderer/app/stores/HardwareWalletsStore.wallet-pairing.uml new file mode 100644 index 0000000000..75aa07215c --- /dev/null +++ b/source/renderer/app/stores/HardwareWalletsStore.wallet-pairing.uml @@ -0,0 +1,73 @@ +# visit https://sequencediagram.org/ + +title Ledger Wallet Pairing + +participant NanoS +participant Daedalus +participant WalletAddPage +participant HardwareWalletsStore +participant changeHardwareWalletConnectionStatus +participant initiateWalletPairing +participant establishHardwareWalletConnection +participant getCardanoAdaApp +participant getExtendedPublicKey +participant getHardwareWalletChannel +participant deviceDetection +participant localStorage + +Daedalus->HardwareWalletsStore:Setup HW store + +HardwareWalletsStore->changeHardwareWalletConnectionStatus:setup listeners + +changeHardwareWalletConnectionStatus->getHardwareWalletChannel:subscribe to\n GET_HARDWARE_WALLET_CONNECTION_CHANNEL +activate changeHardwareWalletConnectionStatus +activate getHardwareWalletChannel + +getHardwareWalletChannel->deviceDetection:listen for new devices +activate getHardwareWalletChannel +activate deviceDetection + +par Device not connected +WalletAddPage->initiateWalletPairing: start pairing + +initiateWalletPairing->establishHardwareWalletConnection:Try to get device info + +note over initiateWalletPairing,establishHardwareWalletConnection:This method can:\n1- Return transport payload\n2- Start Ada App\n3- Mutate flag to wait for device + + +HardwareWalletsStore<--establishHardwareWalletConnection:Wait for device\nisListeningForDevice=true + + + +end + +NanoS->deviceDetection:Enter Pin + +getHardwareWalletChannel<--deviceDetection:Device information +deactivate getHardwareWalletChannel +deactivate deviceDetection + + +changeHardwareWalletConnectionStatus<--getHardwareWalletChannel:Reply to\n GET_HARDWARE_WALLET_CONNECTION_CHANNEL +deactivate changeHardwareWalletConnectionStatus +deactivate getHardwareWalletChannel + +changeHardwareWalletConnectionStatus->localStorage:Save temporary device info + +changeHardwareWalletConnectionStatus->establishHardwareWalletConnection:Re-start connection process + +activate establishHardwareWalletConnection +establishHardwareWalletConnection->establishHardwareWalletConnection:start Ada App polling() + + +NanoS->deviceDetection:Open Ada App +getCardanoAdaApp<--deviceDetection:App opened + +deactivate establishHardwareWalletConnection + +getCardanoAdaApp->getExtendedPublicKey:Get public key +getExtendedPublicKey->localStorage:Save device info + +getExtendedPublicKey<--localStorage:Device saved + +getExtendedPublicKey-->WalletAddPage:Redirect to wallet page From 1f8d398aefed85677058d71001a64613952cf329 Mon Sep 17 00:00:00 2001 From: shawnbusuttil Date: Wed, 20 Apr 2022 13:19:08 +0100 Subject: [PATCH 36/51] [DDW-783] Fixed dialogs being closed after receiving address shared --- source/renderer/app/containers/wallet/WalletReceivePage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/renderer/app/containers/wallet/WalletReceivePage.tsx b/source/renderer/app/containers/wallet/WalletReceivePage.tsx index e028f5070c..e3a1f085b6 100755 --- a/source/renderer/app/containers/wallet/WalletReceivePage.tsx +++ b/source/renderer/app/containers/wallet/WalletReceivePage.tsx @@ -132,7 +132,6 @@ class WalletReceivePage extends Component { const { address, filePath } = await this.getAddressAndFilepath(); // if cancel button is clicked or path is empty if (!filePath || !address) return; - this.handleCloseShareAddress(); this.props.actions.wallets.generateAddressPDF.trigger({ note, address, @@ -143,7 +142,6 @@ class WalletReceivePage extends Component { const { address, filePath } = await this.getAddressAndFilepath('png'); // if cancel button is clicked or path is empty if (!filePath || !address) return; - this.handleCloseShareAddress(); this.props.actions.wallets.saveQRCodeImage.trigger({ address, filePath, From dab44dd7b98e0eb7c1e0f297d6e784c0f9dd0abf Mon Sep 17 00:00:00 2001 From: shawnbusuttil Date: Wed, 20 Apr 2022 15:01:24 +0100 Subject: [PATCH 37/51] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 074000e552..ca90e2f70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixes +- Fixed dialogs being closed after receiving address shared ([PR 2965](https://github.com/input-output-hk/daedalus/pull/2965)) - Fixed PopOver overlap ([PR 2954](https://github.com/input-output-hk/daedalus/pull/2954)) - Fixed tooltip being hidden in several places ([PR-2934](https://github.com/input-output-hk/daedalus/pull/2934)) - Adjusted padding for search field in stake pools ([PR 2945](https://github.com/input-output-hk/daedalus/pull/2945)) From 761d05a814b1eb7e7f30c4cc21a4d35464d45abe Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Thu, 21 Apr 2022 23:41:41 +0200 Subject: [PATCH 38/51] [DDW-1088] Fix no progress shown on splash screen on Windows --- source/main/utils/handleCheckBlockReplayProgress.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 60510f5f93..38340774af 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -7,6 +7,7 @@ import { getBlockSyncProgressChannel } from '../ipc/get-block-sync-progress'; import type { GetBlockSyncProgressType } from '../../common/ipc/api'; import { BlockSyncType } from '../../common/types/cardano-node.types'; import { isItFreshLog } from './blockSyncProgressHelpers'; +import { environment } from '../environment'; const blockKeyword = 'Replayed block'; const validatingChunkKeyword = 'Validating chunk'; @@ -52,7 +53,12 @@ export const handleCheckBlockReplayProgress = ( const filePath = path.join(logFilePath, filename); if (!fs.existsSync(filePath)) return; - const tail = new Tail(filePath); + const tail = new Tail(filePath, { + // using fs.watchFile instead of fs.watch on Windows because of Node API inconsistency: + // https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_caveats + // https://github.com/lucagrulla/node-tail/issues/137 + useWatchFile: environment.isWindows, + }); tail.on('line', (line) => { if ( From 9b48a333983dc863999309b60c7f47dc97fdd996 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Fri, 22 Apr 2022 11:08:57 -0300 Subject: [PATCH 39/51] [DDW-1088] Use debounce --- .../utils/handleCheckBlockReplayProgress.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 38340774af..917fd507cd 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -3,11 +3,17 @@ import fs from 'fs'; import moment from 'moment'; import path from 'path'; import { Tail } from 'tail'; +import debounce from 'lodash/debounce'; import { getBlockSyncProgressChannel } from '../ipc/get-block-sync-progress'; -import type { GetBlockSyncProgressType } from '../../common/ipc/api'; +import type { + GetBlockSyncProgressMainResponse, + GetBlockSyncProgressRendererRequest, + GetBlockSyncProgressType, +} from '../../common/ipc/api'; import { BlockSyncType } from '../../common/types/cardano-node.types'; import { isItFreshLog } from './blockSyncProgressHelpers'; import { environment } from '../environment'; +import { MainIpcChannel } from '../ipc/lib/MainIpcChannel'; const blockKeyword = 'Replayed block'; const validatingChunkKeyword = 'Validating chunk'; @@ -44,6 +50,13 @@ function getProgressType(line: string): GetBlockSyncProgressType | null { const applicationStartDate = moment.utc(); +const debouncedSyncProgress = debounce< + MainIpcChannel< + GetBlockSyncProgressRendererRequest, + GetBlockSyncProgressMainResponse + >['send'] +>((...args) => getBlockSyncProgressChannel.send(...args), 1000); + export const handleCheckBlockReplayProgress = ( mainWindow: BrowserWindow, logsDirectoryPath: string @@ -57,7 +70,7 @@ export const handleCheckBlockReplayProgress = ( // using fs.watchFile instead of fs.watch on Windows because of Node API inconsistency: // https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_caveats // https://github.com/lucagrulla/node-tail/issues/137 - useWatchFile: environment.isWindows, + useWatchFile: true, }); tail.on('line', (line) => { @@ -75,7 +88,7 @@ export const handleCheckBlockReplayProgress = ( } const finalProgressPercentage = parseFloat(percentage); // Send result to renderer process (NetworkStatusStore) - getBlockSyncProgressChannel.send( + debouncedSyncProgress( { progress: finalProgressPercentage, type: progressType }, mainWindow.webContents ); From f37a79a81975fb7db2d9bcf56102d235cf641e0d Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Fri, 22 Apr 2022 17:26:44 +0200 Subject: [PATCH 40/51] [DDW-1088] Debounce entire fs.watchFile callback to reduce the resource usage --- .../utils/handleCheckBlockReplayProgress.ts | 57 ++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 917fd507cd..5a91cef700 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -5,15 +5,10 @@ import path from 'path'; import { Tail } from 'tail'; import debounce from 'lodash/debounce'; import { getBlockSyncProgressChannel } from '../ipc/get-block-sync-progress'; -import type { - GetBlockSyncProgressMainResponse, - GetBlockSyncProgressRendererRequest, - GetBlockSyncProgressType, -} from '../../common/ipc/api'; +import type { GetBlockSyncProgressType } from '../../common/ipc/api'; import { BlockSyncType } from '../../common/types/cardano-node.types'; import { isItFreshLog } from './blockSyncProgressHelpers'; import { environment } from '../environment'; -import { MainIpcChannel } from '../ipc/lib/MainIpcChannel'; const blockKeyword = 'Replayed block'; const validatingChunkKeyword = 'Validating chunk'; @@ -50,30 +45,8 @@ function getProgressType(line: string): GetBlockSyncProgressType | null { const applicationStartDate = moment.utc(); -const debouncedSyncProgress = debounce< - MainIpcChannel< - GetBlockSyncProgressRendererRequest, - GetBlockSyncProgressMainResponse - >['send'] ->((...args) => getBlockSyncProgressChannel.send(...args), 1000); - -export const handleCheckBlockReplayProgress = ( - mainWindow: BrowserWindow, - logsDirectoryPath: string -) => { - const filename = 'node.log'; - const logFilePath = `${logsDirectoryPath}/pub/`; - const filePath = path.join(logFilePath, filename); - if (!fs.existsSync(filePath)) return; - - const tail = new Tail(filePath, { - // using fs.watchFile instead of fs.watch on Windows because of Node API inconsistency: - // https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_caveats - // https://github.com/lucagrulla/node-tail/issues/137 - useWatchFile: true, - }); - - tail.on('line', (line) => { +const createHandleNewLine = (mainWindow: BrowserWindow) => + debounce((line: string) => { if ( !isItFreshLog(applicationStartDate, line) || !containProgressKeywords(line) @@ -87,10 +60,30 @@ export const handleCheckBlockReplayProgress = ( return; } const finalProgressPercentage = parseFloat(percentage); - // Send result to renderer process (NetworkStatusStore) - debouncedSyncProgress( + + getBlockSyncProgressChannel.send( { progress: finalProgressPercentage, type: progressType }, mainWindow.webContents ); + }, 1000); + +export const handleCheckBlockReplayProgress = ( + mainWindow: BrowserWindow, + logsDirectoryPath: string +) => { + const filename = 'node.log'; + const logFilePath = `${logsDirectoryPath}/pub/`; + const filePath = path.join(logFilePath, filename); + if (!fs.existsSync(filePath)) return; + + const tail = new Tail(filePath, { + // using fs.watchFile instead of fs.watch on Windows because of Node API issues: + // https://github.com/nodejs/node/issues/36888 + // https://github.com/lucagrulla/node-tail/issues/137 + // https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_caveats + useWatchFile: environment.isWindows, }); + + const handleNewLine = createHandleNewLine(mainWindow); + tail.on('line', handleNewLine); }; From 9f5a6c469563c6612ae62994e52dae94fde00ac6 Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Fri, 22 Apr 2022 18:29:10 +0200 Subject: [PATCH 41/51] [DDW-1088] Keep track of reported progress in memory and only send new messages on actual change --- .../utils/handleCheckBlockReplayProgress.ts | 42 ++++++++++++------- .../SyncingProgress/SyncingProgress.tsx | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 5a91cef700..95f2cfde15 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -3,7 +3,6 @@ import fs from 'fs'; import moment from 'moment'; import path from 'path'; import { Tail } from 'tail'; -import debounce from 'lodash/debounce'; import { getBlockSyncProgressChannel } from '../ipc/get-block-sync-progress'; import type { GetBlockSyncProgressType } from '../../common/ipc/api'; import { BlockSyncType } from '../../common/types/cardano-node.types'; @@ -45,8 +44,14 @@ function getProgressType(line: string): GetBlockSyncProgressType | null { const applicationStartDate = moment.utc(); -const createHandleNewLine = (mainWindow: BrowserWindow) => - debounce((line: string) => { +const createHandleNewLogLine = (mainWindow: BrowserWindow) => { + const lastReportedProgressByType: Record = { + [BlockSyncType.pushingLedger]: 0, + [BlockSyncType.replayedBlock]: 0, + [BlockSyncType.validatingChunk]: 0, + }; + + return (line: string) => { if ( !isItFreshLog(applicationStartDate, line) || !containProgressKeywords(line) @@ -54,18 +59,27 @@ const createHandleNewLine = (mainWindow: BrowserWindow) => return; } - const percentage = line.match(/Progress:([\s\d.,]+)%/)?.[1]; - const progressType = getProgressType(line); - if (!percentage || !progressType) { + const unparsedProgress = line.match(/Progress:([\s\d.,]+)%/)?.[1]; + const type = getProgressType(line); + if (!unparsedProgress || !type) { return; } - const finalProgressPercentage = parseFloat(percentage); - getBlockSyncProgressChannel.send( - { progress: finalProgressPercentage, type: progressType }, - mainWindow.webContents - ); - }, 1000); + const progress = Math.floor(parseFloat(unparsedProgress)); + + if (lastReportedProgressByType[type] !== progress) { + console.log( + `Reporting more progress. Old: ${lastReportedProgressByType[type]}, new ${progress}` + ); + lastReportedProgressByType[type] = progress; + + getBlockSyncProgressChannel.send( + { progress, type }, + mainWindow.webContents + ); + } + }; +}; export const handleCheckBlockReplayProgress = ( mainWindow: BrowserWindow, @@ -84,6 +98,6 @@ export const handleCheckBlockReplayProgress = ( useWatchFile: environment.isWindows, }); - const handleNewLine = createHandleNewLine(mainWindow); - tail.on('line', handleNewLine); + const handleNewLogLine = createHandleNewLogLine(mainWindow); + tail.on('line', handleNewLogLine); }; diff --git a/source/renderer/app/components/loading/syncing-connecting/SyncingProgress/SyncingProgress.tsx b/source/renderer/app/components/loading/syncing-connecting/SyncingProgress/SyncingProgress.tsx index 0fc509d96f..fe839bf5c7 100644 --- a/source/renderer/app/components/loading/syncing-connecting/SyncingProgress/SyncingProgress.tsx +++ b/source/renderer/app/components/loading/syncing-connecting/SyncingProgress/SyncingProgress.tsx @@ -79,7 +79,7 @@ const SyncingProgress: FC = (props, { intl }: Context) => ( key={type} className={makePercentageCellStyles(props[type] === 100)} > - {Math.floor(props[type])}% + {props[type]}% ))} From 02acfd3f68dec9c1d6dcf4fb1fd3b4adcf656e68 Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Fri, 22 Apr 2022 18:44:51 +0200 Subject: [PATCH 42/51] [DDW-1088] Remove debug statement --- source/main/utils/handleCheckBlockReplayProgress.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 95f2cfde15..ae88b83fe6 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -68,9 +68,6 @@ const createHandleNewLogLine = (mainWindow: BrowserWindow) => { const progress = Math.floor(parseFloat(unparsedProgress)); if (lastReportedProgressByType[type] !== progress) { - console.log( - `Reporting more progress. Old: ${lastReportedProgressByType[type]}, new ${progress}` - ); lastReportedProgressByType[type] = progress; getBlockSyncProgressChannel.send( From 849b7049a221e3837b8af9c34922a2012db387f4 Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Fri, 22 Apr 2022 19:03:30 +0200 Subject: [PATCH 43/51] [DDW-1088] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 074000e552..e050943a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixes +- Fixed no progress shown on loading screen on Windows ([PR 2967](https://github.com/input-output-hk/daedalus/pull/2967)) - Fixed PopOver overlap ([PR 2954](https://github.com/input-output-hk/daedalus/pull/2954)) - Fixed tooltip being hidden in several places ([PR-2934](https://github.com/input-output-hk/daedalus/pull/2934)) - Adjusted padding for search field in stake pools ([PR 2945](https://github.com/input-output-hk/daedalus/pull/2945)) From acbdaad3e0c90da58bc54f123c3acd4717f3fa7b Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Fri, 22 Apr 2022 19:15:04 +0200 Subject: [PATCH 44/51] [DDW-1088] Update storybook --- storybook/stories/nodes/syncing/SyncingConnecting.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/storybook/stories/nodes/syncing/SyncingConnecting.stories.tsx b/storybook/stories/nodes/syncing/SyncingConnecting.stories.tsx index 79f983c4fd..e7812adee3 100644 --- a/storybook/stories/nodes/syncing/SyncingConnecting.stories.tsx +++ b/storybook/stories/nodes/syncing/SyncingConnecting.stories.tsx @@ -13,7 +13,7 @@ const makeProgressValueKnob = ({ name, value }) => range: true, min: 0, max: 100, - step: 0.01, + step: 1, }); const makeBlockSyncProgress = () => ({ @@ -23,7 +23,7 @@ const makeBlockSyncProgress = () => ({ }), [BlockSyncType.replayedBlock]: makeProgressValueKnob({ name: 'Replaying ledger from on-disk blockchain', - value: 99.9, + value: 99, }), [BlockSyncType.pushingLedger]: makeProgressValueKnob({ name: 'Syncing blockchain', From 1c6212740e9c8741a1115dbdabd3366a4c2b14ea Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Mon, 25 Apr 2022 14:42:29 +0200 Subject: [PATCH 45/51] [DDW-1088] Trigger build From 040087d0022d48e3766244e7eeebe601da60b9c0 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Mon, 25 Apr 2022 10:50:10 -0300 Subject: [PATCH 46/51] [DDW-722] Review changes --- ...s => connect-multiple-hardware-wallets.ts} | 6 ++- ...> disconnect-multiple-hardware-wallets.ts} | 6 ++- ...s => disconnect-single-hardware-wallet.ts} | 8 +++- hardware-wallet-tests/index.ts | 16 +++---- hardware-wallet-tests/utils.ts | 9 ++-- .../ipc/createHardwareWalletIPCChannels.ts | 4 +- source/main/ipc/getHardwareWalletChannel.ts | 8 ++-- .../ledger/deviceDetection/deviceDetection.ts | 45 ++++++++----------- .../ledger/deviceDetection/deviceTracker.ts | 2 +- .../deviceDetection/eventDrivenDetection.ts | 4 +- .../ledger/deviceDetection/types.ts | 4 +- .../app/ipc/getHardwareWalletChannel.ts | 2 +- .../app/stores/HardwareWalletsStore.ts | 40 ++++++++--------- utils/lockfile-checker/index.ts | 1 - 14 files changed, 78 insertions(+), 77 deletions(-) rename hardware-wallet-tests/{multiple-hardware-wallets.ts => connect-multiple-hardware-wallets.ts} (89%) rename hardware-wallet-tests/{remove-multiple-hardware-wallets.ts => disconnect-multiple-hardware-wallets.ts} (89%) rename hardware-wallet-tests/{remove-single-hardware-wallet.ts => disconnect-single-hardware-wallet.ts} (84%) diff --git a/hardware-wallet-tests/multiple-hardware-wallets.ts b/hardware-wallet-tests/connect-multiple-hardware-wallets.ts similarity index 89% rename from hardware-wallet-tests/multiple-hardware-wallets.ts rename to hardware-wallet-tests/connect-multiple-hardware-wallets.ts index 08cb092d26..179bd75c1b 100644 --- a/hardware-wallet-tests/multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/connect-multiple-hardware-wallets.ts @@ -6,6 +6,7 @@ import { createSequentialResult, initLedgerChannel, createTestInstructions, + waitForZombieMessages, } from './utils'; export const run = () => { @@ -32,14 +33,15 @@ export const run = () => { }, ]); - return new Promise((resolve) => { + return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (message: { path: string; deviceModel: string }) => { const [expectedValue, isOver] = getNextExpectedSequence(); expect(message).toEqual(expectedValue); if (isOver) { - resolve(null); + await waitForZombieMessages(); + resolve(); } } ); diff --git a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts b/hardware-wallet-tests/disconnect-multiple-hardware-wallets.ts similarity index 89% rename from hardware-wallet-tests/remove-multiple-hardware-wallets.ts rename to hardware-wallet-tests/disconnect-multiple-hardware-wallets.ts index a2bf202c5d..497a75e836 100644 --- a/hardware-wallet-tests/remove-multiple-hardware-wallets.ts +++ b/hardware-wallet-tests/disconnect-multiple-hardware-wallets.ts @@ -6,6 +6,7 @@ import { createSequentialResult, initLedgerChannel, createTestInstructions, + waitForZombieMessages, } from './utils'; const getNextExpectedSequence = createSequentialResult([ @@ -41,7 +42,7 @@ export const run = () => { 'Disconnect Nano X', ]); - return new Promise((resolve) => { + return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (params: { path: string; deviceModel: string }) => { const [expectedValue, isOver] = getNextExpectedSequence(); @@ -49,7 +50,8 @@ export const run = () => { expect(params).toEqual(expectedValue); if (isOver) { - return resolve(null); + await waitForZombieMessages(); + return resolve(); } } ); diff --git a/hardware-wallet-tests/remove-single-hardware-wallet.ts b/hardware-wallet-tests/disconnect-single-hardware-wallet.ts similarity index 84% rename from hardware-wallet-tests/remove-single-hardware-wallet.ts rename to hardware-wallet-tests/disconnect-single-hardware-wallet.ts index 209963a477..e616d9b075 100644 --- a/hardware-wallet-tests/remove-single-hardware-wallet.ts +++ b/hardware-wallet-tests/disconnect-single-hardware-wallet.ts @@ -6,6 +6,7 @@ import { initLedgerChannel, createTestInstructions, createSequentialResult, + waitForZombieMessages, } from './utils'; export const run = () => { @@ -29,13 +30,16 @@ export const run = () => { }, ]); - return new Promise((resolve) => { + return new Promise((resolve) => { hardwareWalletConnectionChannel.onReceive( async (params: { path: string }) => { const [expectedValue, isOver] = getNextExpectedSequence(); expect(params).toEqual(expectedValue); - if (isOver) return resolve(null); + if (isOver) { + await waitForZombieMessages(); + return resolve(); + } } ); diff --git a/hardware-wallet-tests/index.ts b/hardware-wallet-tests/index.ts index a32ce357ff..ceb8eea430 100644 --- a/hardware-wallet-tests/index.ts +++ b/hardware-wallet-tests/index.ts @@ -1,10 +1,10 @@ import prompts from 'prompts'; -import { run as runRemoveMultipleHardwareWallets } from './remove-multiple-hardware-wallets'; +import { run as runDisconnectMultipleHardwareWallets } from './disconnect-multiple-hardware-wallets'; import { run as runCardanoAppAlreadyLaunched } from './cardano-app-already-launched'; -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'; +import { run as runCardanoAppNotLaunched } from './cardano-app-not-started'; +import { run as runDisconnectSingleHardwareWallet } from './disconnect-single-hardware-wallet'; +import { run as runConnectMultipleHardwareWallets } from './connect-multiple-hardware-wallets'; const CARDANO_APP_ALREADY_LAUNCHED = 'CARDANO_APP_ALREADY_LAUNCHED'; const CARDANO_APP_NOT_STARTED = 'CARDANO_APP_NOT_STARTED'; @@ -49,19 +49,19 @@ const MULTIPLE_HARDWARE_WALLETS_REMOVED = 'MULTIPLE_HARDWARE_WALLETS_REMOVED'; break; case CARDANO_APP_NOT_STARTED: - await runCardanoAppNotStarted(); + await runCardanoAppNotLaunched(); break; case SINGLE_LEDGER_DISCONNECTED: - await runRemoveSingleHardwareWallet(); + await runDisconnectSingleHardwareWallet(); break; case MULTIPLE_HARDWARE_WALLETS: - await runMultipleHardwareWallets(); + await runConnectMultipleHardwareWallets(); break; case MULTIPLE_HARDWARE_WALLETS_REMOVED: - await runRemoveMultipleHardwareWallets(); + await runDisconnectMultipleHardwareWallets(); break; default: diff --git a/hardware-wallet-tests/utils.ts b/hardware-wallet-tests/utils.ts index 46f925e57d..bab7bd1970 100644 --- a/hardware-wallet-tests/utils.ts +++ b/hardware-wallet-tests/utils.ts @@ -17,9 +17,7 @@ import { import { createChannels } from '../source/main/ipc/createHardwareWalletIPCChannels'; import { handleHardwareWalletRequests } from '../source/main/ipc/getHardwareWalletChannel'; -const { ipcMain, ipcRenderer } = createIPCMock(); - -export { ipcMain, ipcRenderer }; +export const { ipcMain, ipcRenderer } = createIPCMock(); export class MockIpcChannel extends IpcChannel< Incoming, @@ -57,7 +55,7 @@ export class MockIpcChannel extends IpcChannel< } export const createAndRegisterHardwareWalletChannels = () => - // @ts-ignore fix-me later + // @ts-expect-error Argument of type 'ipcRenderer' is not assignable to parameter of type 'BrowserWindow'. handleHardwareWalletRequests(ipcRenderer, createChannels(MockIpcChannel)); export const initLedgerChannel = () => { @@ -129,3 +127,6 @@ export const log = (message: string) => export const createTestInstructions = (messages: string[]) => { messages.forEach((m, i) => log(`${i + 1} - ${m}`)); }; + +export const waitForZombieMessages = () => + new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/source/main/ipc/createHardwareWalletIPCChannels.ts b/source/main/ipc/createHardwareWalletIPCChannels.ts index 6bcfae595c..9e5f9f9c2c 100644 --- a/source/main/ipc/createHardwareWalletIPCChannels.ts +++ b/source/main/ipc/createHardwareWalletIPCChannels.ts @@ -100,7 +100,7 @@ export interface HardwareWalletChannels { showAddressMainResponse >; - waitForLedgerDevicesChannel: IpcChannel< + waitForLedgerDevicesToConnectChannel: IpcChannel< waitForLedgerDevicesRequest, waitForLedgerDevicesResponse >; @@ -130,6 +130,6 @@ export const createChannels = ( deriveXpubChannel: new Channel(DERIVE_XPUB_CHANNEL), deriveAddressChannel: new Channel(DERIVE_ADDRESS_CHANNEL), showAddressChannel: new Channel(SHOW_ADDRESS_CHANNEL), - waitForLedgerDevicesChannel: new Channel(WAIT_FOR_LEDGER_DEVICES), + waitForLedgerDevicesToConnectChannel: new Channel(WAIT_FOR_LEDGER_DEVICES), }; }; diff --git a/source/main/ipc/getHardwareWalletChannel.ts b/source/main/ipc/getHardwareWalletChannel.ts index 3a4924cd52..4866e4db28 100644 --- a/source/main/ipc/getHardwareWalletChannel.ts +++ b/source/main/ipc/getHardwareWalletChannel.ts @@ -158,7 +158,7 @@ export const handleHardwareWalletRequests = async ( deriveXpubChannel, deriveAddressChannel, showAddressChannel, - waitForLedgerDevicesChannel, + waitForLedgerDevicesToConnectChannel, }: HardwareWalletChannels ) => { let deviceConnection = null; @@ -229,10 +229,10 @@ export const handleHardwareWalletRequests = async ( }); }; - waitForLedgerDevicesChannel.onRequest(async () => { - logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::waiting'); + waitForLedgerDevicesToConnectChannel.onRequest(async () => { + logger.info('[HW-DEBUG] waitForLedgerDevicesToConnectChannel::waiting'); const { device, deviceModel } = await waitForDevice(); - logger.info('[HW-DEBUG] waitForLedgerDevicesChannel::found'); + logger.info('[HW-DEBUG] waitForLedgerDevicesToConnectChannel::found'); return { disconnected: false, deviceType: 'ledger', diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts index b7c22526cd..b220ad6bd4 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceDetection.ts @@ -10,10 +10,23 @@ export type DeviceDetectionPayload = { type: 'add' | 'remove'; } & TrackedDevice; +const getDetector = () => { + if (TransportNodeHid.isSupported()) { + logger.info('[HW-DEBUG] Using usb-detection'); + + return useEventDrivenDetection; + } + logger.info('[HW-DEBUG] Using polling'); + + return usePollingDrivenDetection; +}; + export const deviceDetection = ( - onAdd: (arg0: DeviceDetectionPayload) => void, - onRemove: (arg0: DeviceDetectionPayload) => void + onAdd: (payload: DeviceDetectionPayload) => void, + onRemove: (payload: DeviceDetectionPayload) => void ) => { + // detect existing connected devices without blocking the subscription registration + // https://github.com/LedgerHQ/ledgerjs/blob/master/packages/hw-transport-node-hid-singleton/src/TransportNodeHid.ts#L56 Promise.resolve(DeviceTracker.getDevices()).then((devices) => { // this needs to run asynchronously so the subscription is defined during this phase for (const device of devices) { @@ -29,42 +42,22 @@ export const deviceDetection = ( const handleOnRemove = (trackedDevice: TrackedDevice) => onRemove({ type: 'remove', ...trackedDevice }); - let detectDevices: Detector; - - if (TransportNodeHid.isSupported()) { - logger.info('[HW-DEBUG] Using usb-detection'); - - detectDevices = useEventDrivenDetection; - } else { - logger.info('[HW-DEBUG] Using polling'); - - detectDevices = usePollingDrivenDetection; - } + const detectDevices = getDetector(); detectDevices(handleOnAdd, handleOnRemove); }; export const waitForDevice = () => { - return new Promise(async (resolve) => { - const currentDevices = await DeviceTracker.getDevices(); + return new Promise((resolve) => { + const currentDevices = DeviceTracker.getDevices(); for (const device of currentDevices) { return resolve(DeviceTracker.getTrackedDeviceByPath(device.path)); } - let detectDevices: Detector; + const detectDevices = getDetector(); let unsubscribe: DectorUnsubscriber = null; - if (TransportNodeHid.isSupported()) { - logger.info('[HW-DEBUG] Using usb-detection'); - - detectDevices = useEventDrivenDetection; - } else { - logger.info('[HW-DEBUG] Using polling'); - - detectDevices = usePollingDrivenDetection; - } - const handleOnAdd = (trackedDevice: TrackedDevice) => { if (unsubscribe) { unsubscribe(); diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts index e75862c360..6b52c99e4a 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/deviceTracker.ts @@ -26,7 +26,7 @@ export class DeviceTracker { return { device, deviceModel, descriptor } as TrackedDevice; } - static getDevices() { + static getDevices(): (Device & { deviceName?: string })[] { return getDevices(); } diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts index cdaef1ce0f..1381045806 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/eventDrivenDetection.ts @@ -10,6 +10,8 @@ const deviceToLog = ({ productId, locationId, deviceAddress }) => let isMonitoring = false; +const USB_EVENT_BUFFER_DELAY = 1500; + const monitorUSBDevices = () => { if (!isMonitoring) { isMonitoring = true; @@ -55,7 +57,7 @@ export const detectDevices: Detector = (onAdd, onRemove) => { } timeout = null; - }, 1500); + }, USB_EVENT_BUFFER_DELAY); } }; diff --git a/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts index 9666cdd9b4..0e50b1a705 100644 --- a/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts +++ b/source/main/ipc/hardwareWallets/ledger/deviceDetection/types.ts @@ -32,6 +32,6 @@ export type TrackedDevice = { export type DectorUnsubscriber = () => void; export type Detector = ( - onAdd: (arg0: TrackedDevice) => void, - onRemove: (arg0: TrackedDevice) => void + onAdd: (device: TrackedDevice) => void, + onRemove: (device: TrackedDevice) => void ) => DectorUnsubscriber; diff --git a/source/renderer/app/ipc/getHardwareWalletChannel.ts b/source/renderer/app/ipc/getHardwareWalletChannel.ts index fa9c298e57..20108b4501 100644 --- a/source/renderer/app/ipc/getHardwareWalletChannel.ts +++ b/source/renderer/app/ipc/getHardwareWalletChannel.ts @@ -96,7 +96,7 @@ export const showAddressChannel: RendererIpcChannel< showAddressMainResponse, showAddressRendererRequest > = new RendererIpcChannel(SHOW_ADDRESS_CHANNEL); -export const waitForLedgerDevicesChannel: RendererIpcChannel< +export const waitForLedgerDevicesToConnectChannel: RendererIpcChannel< waitForLedgerDevicesResponse, waitForLedgerDevicesRequest > = new RendererIpcChannel(WAIT_FOR_LEDGER_DEVICES); diff --git a/source/renderer/app/stores/HardwareWalletsStore.ts b/source/renderer/app/stores/HardwareWalletsStore.ts index 868568ae59..23a5000a39 100644 --- a/source/renderer/app/stores/HardwareWalletsStore.ts +++ b/source/renderer/app/stores/HardwareWalletsStore.ts @@ -35,7 +35,7 @@ import { resetTrezorActionChannel, deriveAddressChannel, showAddressChannel, - waitForLedgerDevicesChannel, + waitForLedgerDevicesToConnectChannel, } from '../ipc/getHardwareWalletChannel'; import { prepareLedgerInput, @@ -133,6 +133,12 @@ const DEFAULT_HW_NAME = 'Hardware Wallet'; const { network, isDev } = global.environment; const hardwareWalletsNetworkConfig = getHardwareWalletsNetworkConfig(network); + +interface CardanoAdaAppPoller { + stop: () => void; + isRunning: () => boolean; +} + export default class HardwareWalletsStore extends Store { @observable selectCoinsRequest: Request = new Request( @@ -235,10 +241,7 @@ export default class HardwareWalletsStore extends Store { activeVotingWalletId: string | null | undefined = null; @observable votingData: VotingDataType | null | undefined = null; - cardanoAdaAppPollingInterval: { - stop: () => void; - isRunning: () => boolean; - } | null; + cardanoAdaAppPoller: CardanoAdaAppPoller | null; // @ts-ignore ts-migrate(2304) FIXME: Cannot find name 'IntervalID'. checkTransactionTimeInterval: IntervalID | null | undefined = null; connectedHardwareWalletsDevices: Map< @@ -313,8 +316,8 @@ export default class HardwareWalletsStore extends Store { } }; - waitForLedgerDevices = async () => { - const device = await waitForLedgerDevicesChannel.request(); + waitForLedgerDevicesToConnect = async () => { + const device = await waitForLedgerDevicesToConnectChannel.request(); this.connectedHardwareWalletsDevices.set(device.path, device); return device; }; @@ -324,7 +327,7 @@ export default class HardwareWalletsStore extends Store { txWalletId: string | null | undefined, verificationAddress?: WalletAddress | null | undefined ) => { - this.cardanoAdaAppPollingInterval?.stop(); + this.cardanoAdaAppPoller?.stop(); const poller = () => { let canRun = true; @@ -375,7 +378,7 @@ export default class HardwareWalletsStore extends Store { }; }; - this.cardanoAdaAppPollingInterval = poller(); + this.cardanoAdaAppPoller = poller(); }; getAvailableDevices = async (params: { isTrezor: boolean }) => { @@ -1240,7 +1243,7 @@ export default class HardwareWalletsStore extends Store { logger.info( '[HW-DEBUG] HW STORE::initiateAddressVerification:: wait for ledger devices' ); - await this.waitForLedgerDevices(); + await this.waitForLedgerDevicesToConnect(); transportDevice = await this.establishHardwareWalletConnection(); } else { transportDevice = await this.establishHardwareWalletConnection(); @@ -1263,7 +1266,7 @@ export default class HardwareWalletsStore extends Store { logger.debug('[HW-DEBUG] HWStore - Establishing connection failed'); } } else if (deviceType === DeviceTypes.LEDGER) { - const connectedDevice = await this.waitForLedgerDevices(); + const connectedDevice = await this.waitForLedgerDevicesToConnect(); devicePath = connectedDevice.path; if (!transportDevice) { @@ -1321,12 +1324,7 @@ export default class HardwareWalletsStore extends Store { devicePath, }); this.stopCardanoAdaAppFetchPoller(); - this.useCardanoAppInterval( - devicePath, - // @ts-ignore Argument of type 'WalletAddress' is not assignable to parameter of type 'string'.ts(2345) - walletId, - address - ); + this.useCardanoAppInterval(devicePath, walletId, address); } }; @action @@ -1539,7 +1537,7 @@ export default class HardwareWalletsStore extends Store { }; waitForLedgerTransportDevice = async () => { - const ledgerDevice = await this.waitForLedgerDevices(); + const ledgerDevice = await this.waitForLedgerDevicesToConnect(); logger.debug( '[HW-DEBUG] HWStore::getLedgerTransportDevice::Use ledger device as transport', @@ -3207,9 +3205,9 @@ export default class HardwareWalletsStore extends Store { stopCardanoAdaAppFetchPoller = () => { logger.info('[HW-DEBUG] HWStore - STOP Ada App poller'); - if (this.cardanoAdaAppPollingInterval) { - this.cardanoAdaAppPollingInterval.stop(); - this.cardanoAdaAppPollingInterval = null; + if (this.cardanoAdaAppPoller) { + this.cardanoAdaAppPoller.stop(); + this.cardanoAdaAppPoller = null; } }; } diff --git a/utils/lockfile-checker/index.ts b/utils/lockfile-checker/index.ts index 445a359783..db59899aed 100644 --- a/utils/lockfile-checker/index.ts +++ b/utils/lockfile-checker/index.ts @@ -28,7 +28,6 @@ const dependencyNamesToRemove = [ 'blake2b@https://github.com/BitGo/blake2b', '@types/aria-query', '@types/istanbul-lib-report', - '@types/react-syntax-highlighter' ]; const dependenciesToRemove = Object.keys(json.object).filter((key) => dependencyNamesToRemove.find((name) => key.includes(name)) From 276eaa557d91628ee27fffbd070c37468a03be9e Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Mon, 25 Apr 2022 22:23:07 -0300 Subject: [PATCH 47/51] [DDW-1088] Cover log file not yet created case --- .../utils/handleCheckBlockReplayProgress.ts | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index ae88b83fe6..d232d50265 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -78,16 +78,14 @@ const createHandleNewLogLine = (mainWindow: BrowserWindow) => { }; }; -export const handleCheckBlockReplayProgress = ( - mainWindow: BrowserWindow, - logsDirectoryPath: string -) => { - const filename = 'node.log'; - const logFilePath = `${logsDirectoryPath}/pub/`; - const filePath = path.join(logFilePath, filename); - if (!fs.existsSync(filePath)) return; - - const tail = new Tail(filePath, { +const watchLogFile = ({ + logFilePath, + mainWindow, +}: { + logFilePath: string; + mainWindow: BrowserWindow; +}) => { + const tail = new Tail(logFilePath, { // using fs.watchFile instead of fs.watch on Windows because of Node API issues: // https://github.com/nodejs/node/issues/36888 // https://github.com/lucagrulla/node-tail/issues/137 @@ -98,3 +96,41 @@ export const handleCheckBlockReplayProgress = ( const handleNewLogLine = createHandleNewLogLine(mainWindow); tail.on('line', handleNewLogLine); }; + +const watchLogFileDir = ({ + logFileName, + logFileDirPath, + mainWindow, +}: { + logFileName: string; + logFileDirPath: string; + mainWindow: BrowserWindow; +}) => { + const watcher = fs.watch(logFileDirPath, {}, (eventname, file) => { + if (eventname === 'rename' && logFileName === file) { + watchLogFile({ + logFilePath: path.join(logFileDirPath, logFileName), + mainWindow, + }); + watcher.close(); + } + }); +}; + +export const handleCheckBlockReplayProgress = ( + mainWindow: BrowserWindow, + logsDirectoryPath: string +) => { + const logFileName = 'node.log'; + const logFileDirPath = `${logsDirectoryPath}/pub/`; + const logFilePath = path.join(logFileDirPath, logFileName); + + if (!fs.existsSync(logFilePath)) { + watchLogFileDir({ logFileDirPath, logFileName, mainWindow }); + } else { + watchLogFile({ + logFilePath, + mainWindow, + }); + } +}; From 64673ec4c09e8a78fc3a83a04d62243d2a78a8d9 Mon Sep 17 00:00:00 2001 From: Renan Ferreira Date: Tue, 26 Apr 2022 08:59:51 -0300 Subject: [PATCH 48/51] [DDW-722] Fix test runner --- .../cardano-app-not-started.ts | 22 ++++++++----------- hardware-wallet-tests/utils.ts | 12 +++++----- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/hardware-wallet-tests/cardano-app-not-started.ts b/hardware-wallet-tests/cardano-app-not-started.ts index af6f5ce237..c446d7076e 100644 --- a/hardware-wallet-tests/cardano-app-not-started.ts +++ b/hardware-wallet-tests/cardano-app-not-started.ts @@ -39,20 +39,16 @@ export const run = () => { product: expect.any(String), }); - try { - const cardanoAppChannelResponse = await requestLaunchingCardanoAppOnLedger( - params.path - ); + const cardanoAppChannelResponse = await requestLaunchingCardanoAppOnLedger( + params.path + ); - expect(cardanoAppChannelResponse).toEqual({ - minor: expect.any(Number), - major: expect.any(Number), - patch: expect.any(Number), - deviceId: expect.any(String), - }); - } catch (err) { - return null; - } + expect(cardanoAppChannelResponse).toEqual({ + minor: expect.any(Number), + major: expect.any(Number), + patch: expect.any(Number), + deviceId: expect.any(String), + }); const extendedPublicKey = await publicKeyChannel.request( { diff --git a/hardware-wallet-tests/utils.ts b/hardware-wallet-tests/utils.ts index bab7bd1970..c44aa60a36 100644 --- a/hardware-wallet-tests/utils.ts +++ b/hardware-wallet-tests/utils.ts @@ -55,7 +55,7 @@ export class MockIpcChannel extends IpcChannel< } export const createAndRegisterHardwareWalletChannels = () => - // @ts-expect-error Argument of type 'ipcRenderer' is not assignable to parameter of type 'BrowserWindow'. + // @ts-ignore Argument of type 'ipcRenderer' is not assignable to parameter of type 'BrowserWindow'. handleHardwareWalletRequests(ipcRenderer, createChannels(MockIpcChannel)); export const initLedgerChannel = () => { @@ -78,22 +78,24 @@ export const requestLaunchingCardanoAppOnLedger = (deviceId: string) => new Promise((resolve, reject) => { const cardanoAppChannel = createCardanoAppChannel(); - const interval = setInterval(async () => { + const run = async () => { try { const cardanoAppChannelResponse = await cardanoAppChannel.request( { path: deviceId }, ipcRenderer, ipcRenderer ); - clearInterval(interval); return resolve(cardanoAppChannelResponse); } catch (err) { if (err.code === DEVICE_NOT_CONNECTED) { - clearInterval(interval); return reject(err); } + + setTimeout(run, 1000); } - }, 2000); + }; + + run(); }); interface Result { From 4894005f47222028064a173ce95e4612ca18eb0a Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Tue, 26 Apr 2022 23:52:50 +0200 Subject: [PATCH 49/51] [DDW-1088] Fix race condition - send full proress reports so no messages are lost if the IPC subscription is registered late --- source/common/ipc/api.ts | 6 +-- source/common/types/cardano-node.types.ts | 4 ++ .../utils/handleCheckBlockReplayProgress.ts | 54 ++++++++++++------- .../renderer/app/stores/NetworkStatusStore.ts | 12 ++--- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/source/common/ipc/api.ts b/source/common/ipc/api.ts index bc4e286169..527488baba 100644 --- a/source/common/ipc/api.ts +++ b/source/common/ipc/api.ts @@ -14,6 +14,7 @@ import type { GenerateVotingPDFParams } from '../types/voting-pdf-request.types' import type { GenerateCsvParams } from '../types/csv-request.types'; import type { GenerateQRCodeParams } from '../types/save-qrCode.types'; import type { + BlockSyncProgress, BlockSyncType, CardanoNodeState, CardanoStatus, @@ -455,10 +456,7 @@ export type IntrospectAddressMainResponse = IntrospectAddressResponse; export const GET_BLOCK_SYNC_PROGRESS_CHANNEL = 'GetBlockSyncProgressChannel'; export type GetBlockSyncProgressType = BlockSyncType; export type GetBlockSyncProgressRendererRequest = void; -export type GetBlockSyncProgressMainResponse = { - progress: number; - type: GetBlockSyncProgressType; -}; +export type GetBlockSyncProgressMainResponse = BlockSyncProgress; /** * Channels for connecting / interacting with Hardware Wallet devices diff --git a/source/common/types/cardano-node.types.ts b/source/common/types/cardano-node.types.ts index fcc526b29f..c00fd03832 100644 --- a/source/common/types/cardano-node.types.ts +++ b/source/common/types/cardano-node.types.ts @@ -6,6 +6,7 @@ import { ALONZO_PURPLE, SELFNODE, } from './environment.types'; +import { GetBlockSyncProgressType } from '../ipc/api'; export type TlsConfig = { hostname: string; @@ -159,8 +160,11 @@ export const NetworkMagics: { // Cardano Selfnode network magic [SELFNODE]: [1, null], }; + export enum BlockSyncType { pushingLedger = 'pushingLedger', replayedBlock = 'replayedBlock', validatingChunk = 'validatingChunk', } + +export type BlockSyncProgress = Record; diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index d232d50265..7a14eefc3d 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -4,10 +4,13 @@ import moment from 'moment'; import path from 'path'; import { Tail } from 'tail'; import { getBlockSyncProgressChannel } from '../ipc/get-block-sync-progress'; -import type { GetBlockSyncProgressType } from '../../common/ipc/api'; -import { BlockSyncType } from '../../common/types/cardano-node.types'; +import { + BlockSyncProgress, + BlockSyncType, +} from '../../common/types/cardano-node.types'; import { isItFreshLog } from './blockSyncProgressHelpers'; import { environment } from '../environment'; +import { logger } from './logging'; const blockKeyword = 'Replayed block'; const validatingChunkKeyword = 'Validating chunk'; @@ -21,7 +24,7 @@ const progressKeywords = [ ledgerKeyword, ]; -const keywordTypeMap: Record = { +const keywordTypeMap: Record = { [blockKeyword]: BlockSyncType.replayedBlock, [validatingChunkKeyword]: BlockSyncType.validatingChunk, [validatedChunkKeyword]: BlockSyncType.validatingChunk, @@ -32,7 +35,7 @@ function containProgressKeywords(line: string) { return progressKeywords.some((keyword) => line.includes(keyword)); } -function getProgressType(line: string): GetBlockSyncProgressType | null { +function getProgressType(line: string): BlockSyncType | null { const key = progressKeywords.find((k) => line.includes(k)); if (!key) { @@ -45,10 +48,10 @@ function getProgressType(line: string): GetBlockSyncProgressType | null { const applicationStartDate = moment.utc(); const createHandleNewLogLine = (mainWindow: BrowserWindow) => { - const lastReportedProgressByType: Record = { - [BlockSyncType.pushingLedger]: 0, - [BlockSyncType.replayedBlock]: 0, + const progressReport: BlockSyncProgress = { [BlockSyncType.validatingChunk]: 0, + [BlockSyncType.replayedBlock]: 0, + [BlockSyncType.pushingLedger]: 0, }; return (line: string) => { @@ -65,15 +68,26 @@ const createHandleNewLogLine = (mainWindow: BrowserWindow) => { return; } - const progress = Math.floor(parseFloat(unparsedProgress)); + // In rare cases cardano-node does not log 100%, therefore we need to manually mark the previous step as complete. + if ( + type === BlockSyncType.replayedBlock && + progressReport[BlockSyncType.validatingChunk] !== 100 + ) { + progressReport[BlockSyncType.validatingChunk] = 100; + } - if (lastReportedProgressByType[type] !== progress) { - lastReportedProgressByType[type] = progress; + if ( + type === BlockSyncType.pushingLedger && + progressReport[BlockSyncType.replayedBlock] !== 100 + ) { + progressReport[BlockSyncType.replayedBlock] = 100; + } - getBlockSyncProgressChannel.send( - { progress, type }, - mainWindow.webContents - ); + const progress = Math.floor(parseFloat(unparsedProgress)); + + if (progressReport[type] !== progress) { + progressReport[type] = progress; + getBlockSyncProgressChannel.send(progressReport, mainWindow.webContents); } }; }; @@ -97,7 +111,7 @@ const watchLogFile = ({ tail.on('line', handleNewLogLine); }; -const watchLogFileDir = ({ +const waitForLogFileToBeCreatedAndWatchLogFile = ({ logFileName, logFileDirPath, mainWindow, @@ -106,8 +120,8 @@ const watchLogFileDir = ({ logFileDirPath: string; mainWindow: BrowserWindow; }) => { - const watcher = fs.watch(logFileDirPath, {}, (eventname, file) => { - if (eventname === 'rename' && logFileName === file) { + const watcher = fs.watch(logFileDirPath, {}, (eventName, file) => { + if (eventName === 'rename' && logFileName === file) { watchLogFile({ logFilePath: path.join(logFileDirPath, logFileName), mainWindow, @@ -126,7 +140,11 @@ export const handleCheckBlockReplayProgress = ( const logFilePath = path.join(logFileDirPath, logFileName); if (!fs.existsSync(logFilePath)) { - watchLogFileDir({ logFileDirPath, logFileName, mainWindow }); + waitForLogFileToBeCreatedAndWatchLogFile({ + logFileDirPath, + logFileName, + mainWindow, + }); } else { watchLogFile({ logFilePath, diff --git a/source/renderer/app/stores/NetworkStatusStore.ts b/source/renderer/app/stores/NetworkStatusStore.ts index 87b67f7ed7..e35ca5474b 100644 --- a/source/renderer/app/stores/NetworkStatusStore.ts +++ b/source/renderer/app/stores/NetworkStatusStore.ts @@ -805,14 +805,10 @@ export default class NetworkStatusStore extends Store { return Promise.resolve(); }; - @action _onBlockSyncProgressUpdate = async ({ - progress, - type, - }: GetBlockSyncProgressMainResponse) => { - this.blockSyncProgress = { - ...this.blockSyncProgress, - [type]: progress, - }; + @action _onBlockSyncProgressUpdate = async ( + blockSyncProgress: GetBlockSyncProgressMainResponse + ) => { + this.blockSyncProgress = blockSyncProgress; }; @action From 2da0a21c1836063d50331201a75688f3fdd5b83f Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Wed, 27 Apr 2022 16:46:52 +0200 Subject: [PATCH 50/51] [DDW-1088] Ensure file is read from beginning, remove logic that marked previous sync steps as complete when 100% was not seen in logs --- .../main/utils/handleCheckBlockReplayProgress.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 7a14eefc3d..73e8aa916f 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -68,21 +68,6 @@ const createHandleNewLogLine = (mainWindow: BrowserWindow) => { return; } - // In rare cases cardano-node does not log 100%, therefore we need to manually mark the previous step as complete. - if ( - type === BlockSyncType.replayedBlock && - progressReport[BlockSyncType.validatingChunk] !== 100 - ) { - progressReport[BlockSyncType.validatingChunk] = 100; - } - - if ( - type === BlockSyncType.pushingLedger && - progressReport[BlockSyncType.replayedBlock] !== 100 - ) { - progressReport[BlockSyncType.replayedBlock] = 100; - } - const progress = Math.floor(parseFloat(unparsedProgress)); if (progressReport[type] !== progress) { @@ -105,6 +90,7 @@ const watchLogFile = ({ // https://github.com/lucagrulla/node-tail/issues/137 // https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_caveats useWatchFile: environment.isWindows, + fromBeginning: true, }); const handleNewLogLine = createHandleNewLogLine(mainWindow); From 2af70f8fd452597a735e98619bd09f16433e60be Mon Sep 17 00:00:00 2001 From: Marcin Mazurek Date: Wed, 27 Apr 2022 16:58:36 +0200 Subject: [PATCH 51/51] [DDW-1088] Address PR feedback --- source/main/utils/handleCheckBlockReplayProgress.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/main/utils/handleCheckBlockReplayProgress.ts b/source/main/utils/handleCheckBlockReplayProgress.ts index 73e8aa916f..3b615f4ca2 100644 --- a/source/main/utils/handleCheckBlockReplayProgress.ts +++ b/source/main/utils/handleCheckBlockReplayProgress.ts @@ -97,7 +97,7 @@ const watchLogFile = ({ tail.on('line', handleNewLogLine); }; -const waitForLogFileToBeCreatedAndWatchLogFile = ({ +const waitForLogFileToBeCreatedAndWatchIt = ({ logFileName, logFileDirPath, mainWindow, @@ -126,7 +126,7 @@ export const handleCheckBlockReplayProgress = ( const logFilePath = path.join(logFileDirPath, logFileName); if (!fs.existsSync(logFilePath)) { - waitForLogFileToBeCreatedAndWatchLogFile({ + waitForLogFileToBeCreatedAndWatchIt({ logFileDirPath, logFileName, mainWindow,