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.