diff --git a/packages/transport/node-hid/.prettierignore b/packages/transport/node-hid/.prettierignore new file mode 100644 index 000000000..af229265b --- /dev/null +++ b/packages/transport/node-hid/.prettierignore @@ -0,0 +1,2 @@ +lib/* +coverage/* diff --git a/packages/transport/node-hid/.prettierrc.js b/packages/transport/node-hid/.prettierrc.js new file mode 100644 index 000000000..9601e1776 --- /dev/null +++ b/packages/transport/node-hid/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require("@ledgerhq/prettier-config-dsdk"), +}; diff --git a/packages/transport/node-hid/CHANGELOG.md b/packages/transport/node-hid/CHANGELOG.md new file mode 100644 index 000000000..9a1ac5a02 --- /dev/null +++ b/packages/transport/node-hid/CHANGELOG.md @@ -0,0 +1,32 @@ +# @ledgerhq/device-transport-kit-web-hid + +## 1.1.0 + +### Minor Changes + +- [#697](https://github.com/LedgerHQ/device-sdk-ts/pull/697) [`6b821aa`](https://github.com/LedgerHQ/device-sdk-ts/commit/6b821aa84936472fd74c32dd226323db005f39aa) Thanks [@valpinkman](https://github.com/valpinkman)! - Rename listenToKnownDevices to listenToAvailableDevices + +## 1.0.1 + +### Patch Changes + +- [#643](https://github.com/LedgerHQ/device-sdk-ts/pull/643) [`d9ec133`](https://github.com/LedgerHQ/device-sdk-ts/commit/d9ec13318fb7288e12820e871d49df70099da6fa) Thanks [@valpinkman](https://github.com/valpinkman)! - Fix reconnection on WebHid Transport + +## 1.0.0 + +### Major Changes + +- [#640](https://github.com/LedgerHQ/device-sdk-ts/pull/640) [`4df35a8`](https://github.com/LedgerHQ/device-sdk-ts/commit/4df35a8392872eb401d81d80a335ffac77ccf895) Thanks [@valpinkman](https://github.com/valpinkman)! - 1.0.0 release + +### Minor Changes + +- [#559](https://github.com/LedgerHQ/device-sdk-ts/pull/559) [`a56740a`](https://github.com/LedgerHQ/device-sdk-ts/commit/a56740a608dc95ab3545d90666c71aeff2f67212) Thanks [@valpinkman](https://github.com/valpinkman)! - Extract Transports to their own module + +### Patch Changes + +- [#631](https://github.com/LedgerHQ/device-sdk-ts/pull/631) [`760f6e5`](https://github.com/LedgerHQ/device-sdk-ts/commit/760f6e584a700729bbee9eea6ff87aeb43c3dcf4) Thanks [@valpinkman](https://github.com/valpinkman)! - Update reconnection event to trigger error only after a specific time + +- [#559](https://github.com/LedgerHQ/device-sdk-ts/pull/559) [`cc342e5`](https://github.com/LedgerHQ/device-sdk-ts/commit/cc342e5335ef1bc91b82967f6f59808796f88b36) Thanks [@valpinkman](https://github.com/valpinkman)! - Update WebHidDeviceConnection to throw an error in case of a reconnect + +- Updated dependencies [[`a7984cd`](https://github.com/LedgerHQ/device-sdk-ts/commit/a7984cdcbd8e18aec614d6f07fda293971bd61eb), [`a56740a`](https://github.com/LedgerHQ/device-sdk-ts/commit/a56740a608dc95ab3545d90666c71aeff2f67212), [`1bf2166`](https://github.com/LedgerHQ/device-sdk-ts/commit/1bf2166776ed16c2adf8a4d9d796a567629f983b), [`8f6907a`](https://github.com/LedgerHQ/device-sdk-ts/commit/8f6907a9fd99546d88520f2d167485ef59f8ca2e), [`df4ef37`](https://github.com/LedgerHQ/device-sdk-ts/commit/df4ef37d39a2e214a06930b7ff3c09cf22befb7f), [`1153a78`](https://github.com/LedgerHQ/device-sdk-ts/commit/1153a78b1b56f1767dae380466a8bc7fd86fec73), [`eafad9e`](https://github.com/LedgerHQ/device-sdk-ts/commit/eafad9e1b39573ad3321413b7adaa0814245da96), [`cc342e5`](https://github.com/LedgerHQ/device-sdk-ts/commit/cc342e5335ef1bc91b82967f6f59808796f88b36), [`8799e83`](https://github.com/LedgerHQ/device-sdk-ts/commit/8799e83c92baeb5ccba53546a3d59867d3d6185c)]: + - @ledgerhq/device-management-kit@0.6.0 diff --git a/packages/transport/node-hid/README.md b/packages/transport/node-hid/README.md new file mode 100644 index 000000000..83be898a4 --- /dev/null +++ b/packages/transport/node-hid/README.md @@ -0,0 +1,77 @@ +# Transport Device Kit Web HID + +> [!CAUTION] +> This is still under development and we are free to make new interfaces which may lead to breaking changes. + +- [Transport Device Kit Web HID Documentation](#transport-device-kit-web-hid) + - [Description](#description) + - [Installation](#installation) + - [Usage](#usage) + - [Compatibility](#compatibility) + - [Pre-requisites](#pre-requisites) + - [Main Features](#main-features) + - [How To](#how-to) + +## Description + +This transport is used to interact with a Ledger device through the Web HID (usb) implementation by the Device Management Kit. + +## Installation + +To install the core package, run the following command: + +```sh +npm install @ledgerhq/device-transport-kit-node-hid +``` + +## Usage + +### Compatibility + +This library works in [any browser supporting the WebHID API](https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API#browser_compatibility). + +### Pre-requisites + +To use this transport, ensure you have the Device Management Kit installed in your project. + +### Main Features + +- Exposing a transport factory to be injected into the DeviceManagementKit +- Exposing the transport directly for a custom configuration + +### How To + +To use the transport, you need to inject it in the DeviceManagementKitBuilder before the build. This will allow the Devivce Management Kit to find and interact with devices on the Web HID protocol. + +```typescript +import { DeviceManagementKitBuilder } from "@ledgerhq/device-management-kit" +import { webHidTransportFactory, WebHidTransport } from "@ledgerhq/device-transport-kit-node-hid" + +// Easy setup with the factory +const dmk = new DeviceManagementKitBuilder() + .addTransport(webHidTransportFactory) + .build(); + + +// With custom config +const dmk = new DeviceManagementKitBuilder() + .addTransport(({ + deviceModelDataSource: DeviceModelDataSource; + loggerServiceFactory: (tag: string) => LoggerPublisherService; + config: DmkConfig; + apduSenderServiceFactory: ApduSenderServiceFactory; + apduReceiverServiceFactory: ApduReceiverServiceFactory; + }) => { + // custom code + return new WebHidTransport( + deviceModelDataSource, + loggerServiceFactory, + config, + apduSenderServiceFactory, + apduReceiverServiceFactory, + ); + }) + .build(); + + // You can then make use of the Device Management Kit +``` diff --git a/packages/transport/node-hid/eslint.config.mjs b/packages/transport/node-hid/eslint.config.mjs new file mode 100644 index 000000000..d18d4480c --- /dev/null +++ b/packages/transport/node-hid/eslint.config.mjs @@ -0,0 +1,13 @@ +import config from "@ledgerhq/eslint-config-dsdk"; + +export default [ + ...config, + { + ignores: ["eslint.config.mjs", "lib/*", "vitest.*.mjs"], + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + }, + }, + }, +]; diff --git a/packages/transport/node-hid/package.json b/packages/transport/node-hid/package.json new file mode 100644 index 000000000..c4fd1b77c --- /dev/null +++ b/packages/transport/node-hid/package.json @@ -0,0 +1,54 @@ +{ + "name": "@ledgerhq/device-transport-kit-node-hid", + "version": "1.1.0", + "license": "Apache-2.0", + "private": false, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "import": "./lib/esm/index.js" + } + }, + "files": [ + "./lib", + "package.json" + ], + "scripts": { + "prebuild": "rimraf lib", + "build": "pnpm lmdk-build --entryPoints src/index.ts,src/**/*.ts --tsconfig tsconfig.prod.json --platform web", + "dev": "concurrently \"pnpm watch:builds\" \"pnpm watch:types\"", + "watch:builds": "pnpm lmdk-watch --entryPoints src/index.ts,src/**/*.ts --tsconfig tsconfig.prod.json --platform web", + "watch:types": "concurrently \"tsc --watch -p tsconfig.prod.json\" \"tsc-alias --watch -p tsconfig.prod.json\"", + "lint": "eslint", + "lint:fix": "pnpm lint --fix", + "postpack": "find . -name '*.tgz' -exec cp {} ../../../dist/ \\; ", + "prettier": "prettier . --check", + "prettier:fix": "prettier . --write", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@sentry/minimal": "^6.19.7", + "node-hid": "^3.1.2", + "purify-ts": "^2.1.0", + "uuid": "^11.0.3" + }, + "devDependencies": { + "@ledgerhq/device-management-kit": "workspace:*", + "@ledgerhq/esbuild-tools": "workspace:*", + "@ledgerhq/eslint-config-dsdk": "workspace:*", + "@ledgerhq/prettier-config-dsdk": "workspace:*", + "@ledgerhq/tsconfig-dsdk": "workspace:*", + "@ledgerhq/vitest-config-dmk": "workspace:*", + "@types/uuid": "^10.0.0", + "@types/w3c-web-hid": "^1.0.6", + "rxjs": "^7.8.2", + "ts-node": "^10.9.2" + }, + "peerDependencies": { + "@ledgerhq/device-management-kit": "workspace:*", + "rxjs": "^7.8.2" + } +} diff --git a/packages/transport/node-hid/src/api/data/WebHidConfig.ts b/packages/transport/node-hid/src/api/data/WebHidConfig.ts new file mode 100644 index 000000000..d6bbe8e17 --- /dev/null +++ b/packages/transport/node-hid/src/api/data/WebHidConfig.ts @@ -0,0 +1,2 @@ +export const FRAME_SIZE = 64; +export const RECONNECT_DEVICE_TIMEOUT = 6000; // in some cases, when opening/closing an app, it takes up to 6s between the HID "disconnect" and "connect" events diff --git a/packages/transport/node-hid/src/api/index.ts b/packages/transport/node-hid/src/api/index.ts new file mode 100644 index 000000000..85ad12d13 --- /dev/null +++ b/packages/transport/node-hid/src/api/index.ts @@ -0,0 +1,6 @@ +export * from "@api/model/Errors"; +export { + nodeHidIdentifier, + NodeHidTransport, + nodeHidTransportFactory, +} from "@api/transport/NodeHidTransport"; diff --git a/packages/transport/node-hid/src/api/model/Errors.ts b/packages/transport/node-hid/src/api/model/Errors.ts new file mode 100644 index 000000000..756316df0 --- /dev/null +++ b/packages/transport/node-hid/src/api/model/Errors.ts @@ -0,0 +1,14 @@ +import { GeneralDmkError } from "@ledgerhq/device-management-kit"; + +export class WebHidTransportNotSupportedError extends GeneralDmkError { + override readonly _tag = "WebHidTransportNotSupportedError"; + constructor(readonly err?: unknown) { + super(err); + } +} +export class WebHidSendReportError extends GeneralDmkError { + override readonly _tag = "WebHidSendReportError"; + constructor(readonly err?: unknown) { + super(err); + } +} diff --git a/packages/transport/node-hid/src/api/model/HIDDevice.stub.ts b/packages/transport/node-hid/src/api/model/HIDDevice.stub.ts new file mode 100644 index 000000000..be46fbd4e --- /dev/null +++ b/packages/transport/node-hid/src/api/model/HIDDevice.stub.ts @@ -0,0 +1,22 @@ +const oninputreport = vi.fn().mockResolvedValue(void 0); + +export const hidDeviceStubBuilder = ( + props: Partial = {}, +): HIDDevice => ({ + opened: false, + productId: 0x4011, + vendorId: 0x2c97, + productName: "Ledger Nano X", + collections: [], + open: vi.fn().mockResolvedValue(undefined), + oninputreport, + close: vi.fn().mockResolvedValue(undefined), + sendReport: vi.fn().mockResolvedValue(oninputreport()), + sendFeatureReport: vi.fn(), + forget: vi.fn(), + receiveFeatureReport: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + ...props, +}); diff --git a/packages/transport/node-hid/src/api/transport/NodeHidDeviceConnection.ts b/packages/transport/node-hid/src/api/transport/NodeHidDeviceConnection.ts new file mode 100644 index 000000000..e66052c8b --- /dev/null +++ b/packages/transport/node-hid/src/api/transport/NodeHidDeviceConnection.ts @@ -0,0 +1,277 @@ +import { + type ApduReceiverService, + type ApduResponse, + type ApduSenderService, + CommandUtils, + type DeviceConnection, + type DeviceId, + type DmkError, + type LoggerPublisherService, + ReconnectionFailedError, +} from "@ledgerhq/device-management-kit"; +import { type Device as NodeHIDDevice, HIDAsync } from "node-hid"; +import { type Either, Left, Maybe, Nothing, Right } from "purify-ts"; +import { firstValueFrom, from, retry, Subject } from "rxjs"; + +import { RECONNECT_DEVICE_TIMEOUT } from "@api/data/WebHidConfig"; +import { WebHidSendReportError } from "@api/model/Errors"; + +type WebHidDeviceConnectionConstructorArgs = { + device: NodeHIDDevice; + deviceId: DeviceId; + apduSender: ApduSenderService; + apduReceiver: ApduReceiverService; + onConnectionTerminated: () => void; +}; + +type Timer = ReturnType; + +/** + * Class to manage the connection with a USB HID device. + * It sends APDU commands to the device and receives the responses. + * It handles temporary disconnections and reconnections. + */ +export class NodeHidDeviceConnection implements DeviceConnection { + private _device: NodeHIDDevice; + private _connection: Promise; + private _isConnected = false; + private _deviceId: DeviceId; + private readonly _apduSender: ApduSenderService; + private readonly _apduReceiver: ApduReceiverService; + private _sendApduSubject: Subject = new Subject(); + private readonly _logger: LoggerPublisherService; + private _pendingApdu: Maybe = Nothing; + + /** Callback to notify the connection termination */ + private _onConnectionTerminated: () => void; + /** Subject to notify the reconnection status */ + private reconnectionSubject: Subject<"success" | DmkError> = new Subject(); + /** Flag to indicate if the connection is waiting for a reconnection */ + private waitingForReconnection = false; + /** Timeout to wait for the device to reconnect */ + private lostConnectionTimeout: Timer | null = null; + /** Flag to indicate if the connection is terminated */ + private terminated = false; + + constructor( + { + device, + deviceId, + apduSender, + apduReceiver, + onConnectionTerminated, + }: WebHidDeviceConnectionConstructorArgs, + loggerServiceFactory: (tag: string) => LoggerPublisherService, + ) { + this._apduSender = apduSender; + this._apduReceiver = apduReceiver; + this._onConnectionTerminated = async () => { + onConnectionTerminated(); + (await this._connection).close(); + this._isConnected = false; + }; + this._logger = loggerServiceFactory("WebHidDeviceConnection"); + this._device = device; + this._connection = this.openConnection(); + this.watchData(); + this._deviceId = deviceId; + this._logger.info("🔌 Connected to device"); + } + + public get device() { + return this._device; + } + + public get deviceId() { + return this._deviceId; + } + + async sendApdu( + apdu: Uint8Array, + triggersDisconnection?: boolean, + ): Promise> { + this._sendApduSubject = new Subject(); + this._pendingApdu = Maybe.of(apdu); + this._logger.debug("Sending APDU", { + data: { apdu }, + tag: "apdu-sender", + }); + + const resultPromise = new Promise>( + (resolve) => { + this._sendApduSubject.subscribe({ + next: async (r) => { + this._pendingApdu = Nothing; + if (triggersDisconnection && CommandUtils.isSuccessResponse(r)) { + // Anticipate the disconnection and wait for the reconnection before resolving + const reconnectionRes = await this.waitForReconnection(); + reconnectionRes.caseOf({ + Left: (err) => resolve(Left(err)), + Right: () => resolve(Right(r)), + }); + } else { + resolve(Right(r)); + } + }, + error: (err) => { + this._pendingApdu = Nothing; + resolve(Left(err)); + }, + }); + }, + ); + + if (this.waitingForReconnection || !this._isConnected) { + const waitingForDeviceResponse = + this._isConnected && this._pendingApdu.isJust(); + const reconnectionRes = await this.waitForReconnection( + waitingForDeviceResponse, + ); + if (reconnectionRes.isLeft()) { + return reconnectionRes; + } + } + + const frames = this._apduSender.getFrames(apdu); + for (const frame of frames) { + this._logger.debug("Sending Frame", { + data: { frame: frame.getRawData() }, + }); + + try { + const report = Buffer.from([0, ...frame.getRawData()]); + await firstValueFrom( + from((await this._connection).write(report)).pipe( + retry({ + count: 3, + delay: 500, + }), + ), + ); + } catch (error) { + this._logger.error("Error sending frame", { data: { error } }); + return Promise.resolve(Left(new WebHidSendReportError(error))); + } + } + + return resultPromise; + } + + private openConnection() { + this._isConnected = false; + if (!this._device.path) throw new Error("Missing device path"); + return HIDAsync.open(this._device.path).then((connection) => { + this._isConnected = true; + return connection; + }); + } + + private async watchData() { + const connection = await this._connection; + connection.on("data", (data: Buffer) => this.receiveHidInputReport(data)); + } + + private receiveHidInputReport(buffer: Buffer) { + const data = new Uint8Array(buffer); + this._logger.debug("Received Frame", { + data: { frame: data }, + tag: "apdu-receiver", + }); + const response = this._apduReceiver.handleFrame(data); + response.caseOf({ + Right: (maybeApduResponse) => { + maybeApduResponse.map((apduResponse) => { + this._logger.debug("Received APDU Response", { + data: { response: apduResponse }, + }); + this._sendApduSubject.next(apduResponse); + this._sendApduSubject.complete(); + }); + }, + Left: (err) => { + this._sendApduSubject.error(err); + }, + }); + } + + private waitForReconnection( + waitingForDeviceResponse: boolean = false, + ): Promise> { + if (this.terminated) { + return Promise.resolve(Left(new ReconnectionFailedError())); + } + + return new Promise>((resolve) => { + const sub = this.reconnectionSubject.subscribe({ + next: (res) => { + if (waitingForDeviceResponse) { + this._sendApduSubject.error( + new WebHidSendReportError( + new Error( + "Device disconnected while waiting for device response", + ), + ), + ); + } + + if (res === "success") { + resolve(Right(undefined)); + } else { + resolve(Left(res)); + } + + sub.unsubscribe(); + }, + }); + }); + } + + /** + * Method called when the Device gets disconnected. + * It starts a timeout to wait for the device to reconnect. + * */ + public lostConnection() { + this._logger.info("⏱️ Lost connection, starting timer"); + this.waitingForReconnection = true; + this.lostConnectionTimeout = setTimeout(() => { + this._logger.info("❌ Disconnection timeout, terminating connection"); + this.disconnect(); + }, RECONNECT_DEVICE_TIMEOUT); + } + + /** Reconnect the device after a disconnection */ + public async reconnectHidDevice(device: NodeHIDDevice) { + this._device = device; + this.watchData(); + + if (this.lostConnectionTimeout) { + clearTimeout(this.lostConnectionTimeout); + } + + if (this._pendingApdu.isJust()) { + this._sendApduSubject.error(new WebHidSendReportError()); + } + + this._connection = this.openConnection(); + await this._connection; + + this._logger.info("⏱️🔌 Device reconnected"); + this.waitingForReconnection = false; + this.reconnectionSubject.next("success"); + } + + public async disconnect() { + if (this._pendingApdu.isJust()) { + this._sendApduSubject.error(new WebHidSendReportError()); + } + + (await this._connection).close(); + this._isConnected = false; + + this._logger.info("🔚 Disconnect"); + if (this.lostConnectionTimeout) clearTimeout(this.lostConnectionTimeout); + this.terminated = true; + this._onConnectionTerminated(); + this.reconnectionSubject.next(new ReconnectionFailedError()); + } +} diff --git a/packages/transport/node-hid/src/api/transport/NodeHidTransport.ts b/packages/transport/node-hid/src/api/transport/NodeHidTransport.ts new file mode 100644 index 000000000..0d0719d18 --- /dev/null +++ b/packages/transport/node-hid/src/api/transport/NodeHidTransport.ts @@ -0,0 +1,619 @@ +import { + type ApduReceiverServiceFactory, + type ApduSenderServiceFactory, + type ConnectError, + type ConnectionType, + type DeviceId, + type DeviceModelDataSource, + DeviceNotRecognizedError, + type DisconnectHandler, + type DmkError, + FramerUtils, + LEDGER_VENDOR_ID, + type LoggerPublisherService, + NoAccessibleDeviceError, + OpeningConnectionError, + type Transport, + TransportConnectedDevice, + type TransportDeviceModel, + type TransportDiscoveredDevice, + type TransportFactory, + type TransportIdentifier, + UnknownDeviceError, +} from "@ledgerhq/device-management-kit"; +import * as Sentry from "@sentry/minimal"; +import { type Device as NodeHIDDevice, devicesAsync, HIDAsync } from "node-hid"; +import { type Either, EitherAsync, Left, Maybe, Right } from "purify-ts"; +import { BehaviorSubject, from, map, type Observable, switchMap } from "rxjs"; +import { v4 as uuid } from "uuid"; + +import { FRAME_SIZE } from "@api/data/WebHidConfig"; +import { WebHidTransportNotSupportedError } from "@api/model/Errors"; +import { NodeHidDeviceConnection } from "@api/transport/NodeHidDeviceConnection"; + +type NodeHIDAPI = typeof NodeHIDAPI; +const NodeHIDAPI = { devicesAsync, HIDAsync } as const; + +type PromptDeviceAccessError = + | NoAccessibleDeviceError + | WebHidTransportNotSupportedError; + +type NodeHidTransportDiscoveredDevice = TransportDiscoveredDevice & { + hidDevice: NodeHIDDevice; +}; + +export const nodeHidIdentifier: TransportIdentifier = "NODE-HID"; + +export class NodeHidTransport implements Transport { + /** List of HID devices that have been discovered */ + private _transportDiscoveredDevices: BehaviorSubject< + Array + > = new BehaviorSubject>([]); + + /** Map of *connected* HIDDevice to their NodeHidDeviceConnection */ + private _deviceConnectionsByHidDevice: Map< + NodeHIDDevice, + NodeHidDeviceConnection + > = new Map(); + + /** + * Set of NodeHidDeviceConnection for which the HIDDevice has been + * disconnected, so they are waiting for a reconnection + */ + private _deviceConnectionsPendingReconnection: Set = + new Set(); + + /** AbortController to stop listening to HID connection events */ + private _connectionListenersAbortController: AbortController = + new AbortController(); + private _logger: LoggerPublisherService; + private readonly connectionType: ConnectionType = "USB"; + private readonly identifier: TransportIdentifier = nodeHidIdentifier; + + constructor( + private readonly _deviceModelDataSource: DeviceModelDataSource, + private readonly _loggerServiceFactory: ( + tag: string, + ) => LoggerPublisherService, + private readonly _apduSenderFactory: ApduSenderServiceFactory, + private readonly _apduReceiverFactory: ApduReceiverServiceFactory, + ) { + this._logger = _loggerServiceFactory("NodNodeHidTransport"); + + this.startListeningToConnectionEvents(); + } + + /** + * Get the NodeHID API if supported or error + * @returns `Either` + */ + private get hidApi(): Either { + if (this.isSupported()) { + return Right(NodeHIDAPI); + //return Right({ + // getDevices() { + // return devicesAsync() + // }, + // async requestDevice(options) { + // if (!options) return [] + // const { filters } = options; + // const vid = filters.find(filter => filter.vendorId)?.vendorId + // if (typeof vid === "undefined") return [] + // const devices = await devicesAsync() + // const path = devices.find(device => device.vendorId === vid)?.path + // if (typeof path === "undefined") return [] + // return HIDAsync.open(path) + // } + //}); + } + + return Left(new WebHidTransportNotSupportedError("NodeHID not supported")); + } + + isSupported() { + try { + const result = true; // Node hid should be supported ! + this._logger.debug(`isSupported: ${result}`); + return result; + } catch (error) { + this._logger.error(`isSupported: error`, { data: { error } }); + return false; + } + } + + getIdentifier(): TransportIdentifier { + return this.identifier; + } + + /** + * Wrapper around `navigator.hid.getDevices()`. + * It will return the list of plugged in HID devices to which the user has + * previously granted access through `navigator.hid.requestDevice()`. + */ + private async getDevices(): Promise> { + return EitherAsync.liftEither(this.hidApi).map(async (hidApi) => { + try { + const allDevices = await hidApi.devicesAsync(); + + return allDevices.filter( + (hidDevice) => hidDevice.vendorId === LEDGER_VENDOR_ID, + ); + } catch (error) { + const deviceError = new NoAccessibleDeviceError(error); + this._logger.error(`getDevices: error getting devices`, { + data: { error }, + }); + Sentry.captureException(deviceError); + throw deviceError; + } + }); + } + + /** + * Map a HIDDevice to an TransportDiscoveredDevice, either by creating a new one or returning an existing one + */ + private mapHIDDeviceToTransportDiscoveredDevice( + hidDevice: NodeHIDDevice, + ): NodeHidTransportDiscoveredDevice { + const existingDiscoveredDevice = this._transportDiscoveredDevices + .getValue() + .find((internalDevice) => internalDevice.hidDevice === hidDevice); + + if (existingDiscoveredDevice) { + return existingDiscoveredDevice; + } + + const existingDeviceConnection = + this._deviceConnectionsByHidDevice.get(hidDevice); + + const maybeDeviceModel = this.getDeviceModel(hidDevice); + return maybeDeviceModel.caseOf({ + Just: (deviceModel) => { + const id = existingDeviceConnection?.deviceId ?? uuid(); + + const discoveredDevice = { + id, + deviceModel, + hidDevice, + transport: this.identifier, + }; + + this._logger.debug( + `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, + ); + + return discoveredDevice; + }, + Nothing: () => { + // [ASK] Or we just ignore the not recognized device ? And log them + this._logger.warn( + `Device not recognized: hidDevice.productId: 0x${hidDevice.productId.toString(16)}`, + ); + throw new DeviceNotRecognizedError( + `Device not recognized: hidDevice.productId: 0x${hidDevice.productId.toString(16)}`, + ); + }, + }); + } + + /** + * Listen to known devices (devices to which the user has granted access) + */ + public listenToAvailableDevices(): Observable { + this.updateTransportDiscoveredDevices(); + return this._transportDiscoveredDevices.pipe( + map((devices) => devices.map(({ hidDevice, ...device }) => device)), + ); + } + + private async updateTransportDiscoveredDevices(): Promise { + const eitherDevices = await this.getDevices(); + + eitherDevices.caseOf({ + Left: (error) => { + this._logger.error("Error while getting accessible device", { + data: { error }, + }); + Sentry.captureException(error); + }, + Right: (hidDevices) => { + this._transportDiscoveredDevices.next( + hidDevices.map((hidDevice) => + this.mapHIDDeviceToTransportDiscoveredDevice(hidDevice), + ), + ); + }, + }); + } + + /** + * Wrapper around navigator.hid.requestDevice() + * In a browser, it will show a native dialog to select a HID device. + */ + private async promptDeviceAccess(): Promise< + Either + > { + return EitherAsync.liftEither(this.hidApi) + .map(async (hidApi) => { + // `requestDevice` returns an array. but normally the user can select only one device at a time. + let hidDevices: NodeHIDDevice[] = []; + + try { + const allDevices = await hidApi.devicesAsync(); + hidDevices = allDevices.filter( + (d) => d.vendorId === LEDGER_VENDOR_ID, + ); + await this.updateTransportDiscoveredDevices(); + } catch (error) { + const deviceError = new NoAccessibleDeviceError(error); + this._logger.error(`promptDeviceAccess: error requesting device`, { + data: { error }, + }); + Sentry.captureException(deviceError); + throw deviceError; + } + + this._logger.debug( + `promptDeviceAccess: hidDevices len ${hidDevices.length}`, + ); + + // Granted access to 0 device (by clicking on cancel for ex) results in an error + if (hidDevices.length === 0) { + this._logger.warn("No device was selected"); + throw new NoAccessibleDeviceError("No selected device"); + } + + const discoveredHidDevices: NodeHIDDevice[] = []; + + for (const hidDevice of hidDevices) { + discoveredHidDevices.push(hidDevice); + + this._logger.debug(`promptDeviceAccess: selected device`, { + data: { hidDevice }, + }); + } + + return discoveredHidDevices; + }) + .run(); + } + + startDiscovering(): Observable { + this._logger.debug("startDiscovering"); + + return from(this.promptDeviceAccess()).pipe( + switchMap((either) => { + return either.caseOf({ + Left: (error) => { + this._logger.error("Error while getting accessible device", { + data: { error }, + }); + Sentry.captureException(error); + throw error; + }, + Right: (hidDevices) => { + this._logger.info(`Got access to ${hidDevices.length} HID devices`); + + const discoveredDevices = hidDevices.map((hidDevice) => { + return this.mapHIDDeviceToTransportDiscoveredDevice(hidDevice); + }); + + return from(discoveredDevices); + }, + }); + }), + ); + } + + stopDiscovering(): void { + /** + * This does nothing because the startDiscovering method is just a + * promise wrapped into an observable. So there is no need to stop it. + */ + } + + private startListeningToConnectionEvents(): void { + this._logger.debug("startListeningToConnectionEvents"); + + this.hidApi.map(async (hidApi) => { + // hidApi.addEventListener( + // "connect", + // (event) => this.handleDeviceConnectionEvent(event), + // { signal: this._connectionListenersAbortController.signal }, + // ); + + // hidApi.addEventListener( + // "disconnect", + // (event) => this.handleDeviceDisconnectionEvent(event), + // { signal: this._connectionListenersAbortController.signal }, + // ); + + const knownDevices = await hidApi.devicesAsync(); + const pollDevices = async (interval = 2000) => { + const devices = await hidApi.devicesAsync(); + const connectedDevices = devices.filter( + (d) => !knownDevices.some((knownD) => knownD.path === d.path), + ); + const disconnectedDevices = knownDevices.filter((knownD) => + devices.some((d) => d.path === knownD.path), + ); + connectedDevices.forEach((device) => + this.handleDeviceConnectionEvent( + new CustomEvent("HIDConnectionEvent", { detail: { device } }), + ), + ); + disconnectedDevices.forEach((device) => + this.handleDeviceDisconnectionEvent( + new CustomEvent("HIDConnectionEvent", { detail: { device } }), + ), + ); + setTimeout(() => pollDevices(interval), interval); + }; + pollDevices(); + }); + } + + private stopListeningToConnectionEvents(): void { + this._logger.debug("stopListeningToConnectionEvents"); + this._connectionListenersAbortController.abort(); + } + + /** + * Connect to a HID USB device and update the internal state of the associated device + */ + async connect({ + deviceId, + onDisconnect, + }: { + deviceId: DeviceId; + onDisconnect: DisconnectHandler; + }): Promise> { + this._logger.debug("connect", { data: { deviceId } }); + + const matchingInternalDevice = this._transportDiscoveredDevices + .getValue() + .find((internalDevice) => internalDevice.id === deviceId); + + if (!matchingInternalDevice) { + this._logger.error(`Unknown device ${deviceId}`); + return Left(new UnknownDeviceError(`Unknown device ${deviceId}`)); + } + + try { + if ( + this._deviceConnectionsByHidDevice.get(matchingInternalDevice.hidDevice) + ) { + throw new Error("Device already opened"); + } + //await matchingInternalDevice.hidDevice.open(); + const path = matchingInternalDevice.hidDevice.path; + if (!path) throw new Error("Missing device path"); + this.hidApi.map((hidApi) => { + hidApi.HIDAsync.open(path); + }); + } catch (error) { + if (error instanceof DOMException && error.name === "InvalidStateError") { + this._logger.debug(`Device ${deviceId} is already opened`); + } else { + const connectionError = new OpeningConnectionError(error); + this._logger.debug(`Error while opening device: ${deviceId}`, { + data: { error }, + }); + Sentry.captureException(connectionError); + return Left(connectionError); + } + } + + const { deviceModel } = matchingInternalDevice; + + const channel = Maybe.of( + FramerUtils.numberToByteArray(Math.floor(Math.random() * 0xffff), 2), + ); + const deviceConnection = new NodeHidDeviceConnection( + { + device: matchingInternalDevice.hidDevice, + deviceId, + apduSender: this._apduSenderFactory({ + frameSize: FRAME_SIZE, + channel, + padding: true, + }), + apduReceiver: this._apduReceiverFactory({ channel }), + onConnectionTerminated: () => { + onDisconnect(deviceId); + this._deviceConnectionsPendingReconnection.delete(deviceConnection); + this._deviceConnectionsByHidDevice.delete( + matchingInternalDevice.hidDevice, + ); + }, + }, + this._loggerServiceFactory, + ); + + this._deviceConnectionsByHidDevice.set( + matchingInternalDevice.hidDevice, + deviceConnection, + ); + + const connectedDevice = new TransportConnectedDevice({ + sendApdu: (apdu, triggersDisconnection) => + deviceConnection.sendApdu(apdu, triggersDisconnection), + deviceModel, + id: deviceId, + type: this.connectionType, + transport: this.identifier, + }); + + return Right(connectedDevice); + } + + private getDeviceModel( + hidDevice: NodeHIDDevice, + ): Maybe { + const { productId } = hidDevice; + const matchingModel = this._deviceModelDataSource.getAllDeviceModels().find( + (deviceModel) => + // outside of bootloader mode, the value that we need to identify a device model is the first byte of the actual hidDevice.productId + deviceModel.usbProductId === productId >> 8 || + deviceModel.bootloaderUsbProductId === productId, + ); + return matchingModel ? Maybe.of(matchingModel) : Maybe.zero(); + } + + private getHidUsbProductId(hidDevice: NodeHIDDevice): number { + return this.getDeviceModel(hidDevice).caseOf({ + Just: (deviceModel) => deviceModel.usbProductId, + Nothing: () => hidDevice.productId >> 8, + }); + } + + /** + * Disconnect from a HID USB device + */ + async disconnect(params: { + connectedDevice: TransportConnectedDevice; + }): Promise> { + this._logger.debug("disconnect", { data: { connectedDevice: params } }); + + const matchingDeviceConnection = Array.from( + this._deviceConnectionsByHidDevice.values(), + ).find( + (deviceConnection) => + deviceConnection.deviceId === params.connectedDevice.id, + ); + + if (!matchingDeviceConnection) { + this._logger.error("No matching device connection found", { + data: { connectedDevice: params }, + }); + return Promise.resolve( + Left( + new UnknownDeviceError(`Unknown device ${params.connectedDevice.id}`), + ), + ); + } + + matchingDeviceConnection.disconnect(); + return Promise.resolve(Right(undefined)); + } + + // /** + // * Type guard to check if the event is a HID connection event + // * @param event + // * @private + // */ + // private isHIDConnectionEvent(event: Event): event is HIDConnectionEvent { + // return ( + // "device" in event && + // typeof event.device === "object" && + // event.device !== null && + // "productId" in event.device && + // typeof event.device.productId === "number" + // ); + // } + + /** + * Handle the disconnection event of a HID device + * @param event + */ + private async handleDeviceDisconnectionEvent( + event: CustomEvent<{ device: NodeHIDDevice }>, + ) { + this._logger.info("[handleDeviceDisconnectionEvent] Device disconnected", { + data: { event }, + }); + + this.updateTransportDiscoveredDevices(); + + try { + const c = this._deviceConnectionsByHidDevice.get(event.detail.device); + if (this) await c?.disconnect(); + } catch (error) { + this._logger.error("Error while closing device ", { + data: { event, error }, + }); + } + + const matchingDeviceConnection = this._deviceConnectionsByHidDevice.get( + event.detail.device, + ); + + if (matchingDeviceConnection) { + matchingDeviceConnection.lostConnection(); + this._deviceConnectionsPendingReconnection.add(matchingDeviceConnection); + this._deviceConnectionsByHidDevice.delete(event.detail.device); + } + } + + private handleDeviceReconnection( + deviceConnection: NodeHidDeviceConnection, + hidDevice: NodeHIDDevice, + ) { + this._deviceConnectionsPendingReconnection.delete(deviceConnection); + this._deviceConnectionsByHidDevice.set(hidDevice, deviceConnection); + + try { + deviceConnection.reconnectHidDevice(hidDevice); + } catch (error) { + this._logger.error("Error while reconnecting to device", { + data: { event, error }, + }); + deviceConnection.disconnect(); + } + } + + /** + * Handle the connection event of a HID device + * @param event + */ + private handleDeviceConnectionEvent( + event: CustomEvent<{ device: NodeHIDDevice }>, + ) { + this._logger.info("[handleDeviceConnectionEvent] Device connected", { + data: { event }, + }); + + const matchingDeviceConnection = Array.from( + this._deviceConnectionsPendingReconnection, + ).find( + (deviceConnection) => + this.getHidUsbProductId(deviceConnection.device) === + this.getHidUsbProductId(event.detail.device), + ); + + if (matchingDeviceConnection) { + this.handleDeviceReconnection( + matchingDeviceConnection, + event.detail.device, + ); + } + + /** + * Note: we do this after handling the reconnection to allow the newly + * discovered device to keep the same DeviceId as the previous one in case + * of a reconnection. + */ + this.updateTransportDiscoveredDevices(); + } + + public destroy() { + this.stopListeningToConnectionEvents(); + this._deviceConnectionsByHidDevice.forEach((connection) => { + connection.disconnect(); + }); + this._deviceConnectionsPendingReconnection.clear(); + } +} + +export const nodeHidTransportFactory: TransportFactory = ({ + deviceModelDataSource, + loggerServiceFactory, + apduSenderServiceFactory, + apduReceiverServiceFactory, +}) => + new NodeHidTransport( + deviceModelDataSource, + loggerServiceFactory, + apduSenderServiceFactory, + apduReceiverServiceFactory, + ); diff --git a/packages/transport/node-hid/src/index.ts b/packages/transport/node-hid/src/index.ts new file mode 100644 index 000000000..d158c5764 --- /dev/null +++ b/packages/transport/node-hid/src/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/packages/transport/node-hid/tsconfig.json b/packages/transport/node-hid/tsconfig.json new file mode 100644 index 000000000..730232967 --- /dev/null +++ b/packages/transport/node-hid/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@ledgerhq/tsconfig-dsdk/tsconfig.sdk", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./lib/types", + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "emitDeclarationOnly": true, + "paths": { + "@api/*": ["./src/api/*"] + }, + "resolveJsonModule": true, + "types": ["node", "vitest/globals", "w3c-web-hid", "uuid"] + }, + "include": ["src", "vitest.*.mjs"] +} diff --git a/packages/transport/node-hid/tsconfig.prod.json b/packages/transport/node-hid/tsconfig.prod.json new file mode 100644 index 000000000..b90fc83e0 --- /dev/null +++ b/packages/transport/node-hid/tsconfig.prod.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"] +} diff --git a/packages/transport/node-hid/vitest.config.mjs b/packages/transport/node-hid/vitest.config.mjs new file mode 100644 index 000000000..512e9fab6 --- /dev/null +++ b/packages/transport/node-hid/vitest.config.mjs @@ -0,0 +1,23 @@ +import baseConfig from "@ledgerhq/vitest-config-dmk"; +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + environment: "jsdom", + include: ["src/**/*.test.ts"], + coverage: { + reporter: ["lcov", "text"], + provider: "istanbul", + include: ["src/**/*.ts"], + exclude: ["src/**/*.stub.ts", "src/index.ts", "src/api/index.ts"], + }, + }, + resolve: { + alias: { + "@api": path.resolve(__dirname, "src/api"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faca73b1f..40757a715 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13027,7 +13027,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.76.6': {}