diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index d99223db5..641333eb3 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -1,8 +1,11 @@ -import React from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import type { DiscoveredDevice } from "@ledgerhq/device-sdk-core"; import { Button, Flex, Icons, Text } from "@ledgerhq/react-ui"; import Image from "next/image"; import styled, { DefaultTheme } from "styled-components"; +import { useSdk } from "@/providers/DeviceSdkProvider"; + const Root = styled(Flex)` flex-direction: column; flex: 1; @@ -41,6 +44,45 @@ const NanoLogo = styled(Image).attrs({ mb: 8 })` `; export const MainView: React.FC = () => { + const sdk = useSdk(); + const [discoveredDevice, setDiscoveredDevice] = + useState(null); + + // Example starting the discovery on a user action + const onSelectDeviceClicked = useCallback(() => { + sdk.startDiscovering().subscribe({ + next: (device) => { + console.log(`🦖 Discovered device: `, device); + setDiscoveredDevice(device); + }, + error: (error) => { + console.error(error); + }, + }); + }, [sdk]); + + useEffect(() => { + return () => { + // Example cleaning up the discovery + sdk.stopDiscovering(); + }; + }, [sdk]); + + useEffect(() => { + if (discoveredDevice) { + sdk.connect({ deviceId: discoveredDevice.id }).subscribe({ + next: (connectedDevice) => { + console.log( + `🦖 Response from connect: ${JSON.stringify(connectedDevice)} 🎉`, + ); + }, + error: (error) => { + console.error(`Error from connection or get-version`, error); + }, + }); + } + }, [sdk, discoveredDevice]); + return (
@@ -72,7 +114,12 @@ export const MainView: React.FC = () => { Use this application to test Ledger hardware device features. - diff --git a/packages/config/eslint/index.js b/packages/config/eslint/index.js index 5032fc334..38fc037f9 100644 --- a/packages/config/eslint/index.js +++ b/packages/config/eslint/index.js @@ -81,42 +81,5 @@ module.exports = { ], }, }, - { - files: ["**/*.mjs"], - env: { - es6: true, - node: true, - }, - globals: { - log: true, - $: true, - argv: true, - cd: true, - chalk: true, - echo: true, - expBackoff: true, - fs: true, - glob: true, - globby: true, - nothrow: true, - os: true, - path: true, - question: true, - quiet: true, - quote: true, - quotePowerShell: true, - retry: true, - sleep: true, - spinner: true, - ssh: true, - stdin: true, - which: true, - within: true, - }, - parserOptions: { - ecmaVersion: 2018, - sourceType: "module", - }, - }, ], }; diff --git a/packages/config/typescript/sdk.json b/packages/config/typescript/sdk.json index 0ae0a817d..8cad79134 100644 --- a/packages/config/typescript/sdk.json +++ b/packages/config/typescript/sdk.json @@ -3,7 +3,6 @@ "compilerOptions": { "target": "esnext", "lib": ["esnext", "dom"], - "types": ["reflect-metadata", "jest", "node"], "sourceMap": true, "declaration": true, "declarationMap": true, diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 134e640d6..681118d22 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -13,5 +13,42 @@ module.exports = { "@typescript-eslint/unbound-method": 0, }, }, + { + files: ["**/*.mjs"], + env: { + es6: true, + node: true, + }, + globals: { + log: true, + $: true, + argv: true, + cd: true, + chalk: true, + echo: true, + expBackoff: true, + fs: true, + glob: true, + globby: true, + nothrow: true, + os: true, + path: true, + question: true, + quiet: true, + quote: true, + quotePowerShell: true, + retry: true, + sleep: true, + spinner: true, + ssh: true, + stdin: true, + which: true, + within: true, + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: "module", + }, + }, ], }; diff --git a/packages/core/package.json b/packages/core/package.json index bc30386db..dd3806d0a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,7 +14,7 @@ "lint:fix": "eslint --cache --fix --ext .ts \"src\"", "prettier": "prettier . --check", "prettier:fix": "prettier . --write", - "test": "jest src", + "test": "jest", "test:watch": "pnpm test -- --watch", "test:coverage": "pnpm test -- --coverage", "typecheck": "tsc --noEmit", @@ -24,9 +24,15 @@ "inversify": "^6.0.2", "inversify-logger-middleware": "^3.1.0", "purify-ts": "^2.0.3", - "reflect-metadata": "^0.2.1" + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1", + "semver": "^7.5.4", + "uuid": "^9.0.1" }, "devDependencies": { + "@types/semver": "^7.5.6", + "@types/uuid": "^9.0.8", + "@types/w3c-web-hid": "^1.0.6", "@ledgerhq/eslint-config-dsdk": "workspace:*", "@ledgerhq/jest-config-dsdk": "workspace:*", "@ledgerhq/tsconfig-dsdk": "workspace:*", diff --git a/packages/core/scripts/add-module.mjs b/packages/core/scripts/add-module.mjs index 377da64ad..d683a7590 100644 --- a/packages/core/scripts/add-module.mjs +++ b/packages/core/scripts/add-module.mjs @@ -4,7 +4,7 @@ import { basename } from "node:path"; const modules = argv._; if (!modules.length) { - console.error(`Usage: ${basename(__filename)} [ ...]`); + console.error(`Usage: ${basename(__filename)} [ ...]`); process.exit(1); } @@ -12,20 +12,20 @@ within(async () => { cd("src/internal"); for (const mod of modules) { const rootFolderName = `${mod}`; - const featureUppercased = mod.charAt(0).toUpperCase() + mod.slice(1); + const moduleUppercased = mod.charAt(0).toUpperCase() + mod.slice(1); await $`mkdir ${rootFolderName}`; within(async () => { cd(rootFolderName); await $`mkdir data di model service usecase`; const files = [ - `data/${featureUppercased}DataSource.ts`, + `data/${moduleUppercased}DataSource.ts`, `di/${mod}Module.test.ts`, `di/${mod}Module.ts`, `di/${mod}Types.ts`, `model/.gitkeep`, - `service/${featureUppercased}Service.ts`, - `service/Default${featureUppercased}Service.test.ts`, - `service/Default${featureUppercased}Service.ts`, + `service/${moduleUppercased}Service.ts`, + `service/Default${moduleUppercased}Service.test.ts`, + `service/Default${moduleUppercased}Service.ts`, `usecase/.gitkeep`, ]; for (const file of files) { diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index 457b3e2e0..7171addd5 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -1,7 +1,16 @@ import { Container } from "inversify"; +import { Observable } from "rxjs"; import { types as ConfigTypes } from "@internal/config/di/configTypes"; import { GetSdkVersionUseCase } from "@internal/config/usecase/GetSdkVersionUseCase"; +import { discoveryDiTypes } from "@internal/discovery/di/discoveryDiTypes"; +import { + ConnectUseCase, + ConnectUseCaseArgs, +} from "@internal/discovery/use-case/ConnectUseCase"; +import type { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; +import type { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; +import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; import { makeContainer, MakeContainerProps } from "@root/src/di"; export class DeviceSdk { @@ -27,4 +36,22 @@ export class DeviceSdk { .get(ConfigTypes.GetSdkVersionUseCase) .getSdkVersion(); } + + startDiscovering(): Observable { + return this.container + .get(discoveryDiTypes.StartDiscoveringUseCase) + .execute(); + } + + stopDiscovering() { + return this.container + .get(discoveryDiTypes.StopDiscoveringUseCase) + .execute(); + } + + connect(args: ConnectUseCaseArgs) { + return this.container + .get(discoveryDiTypes.ConnectUseCase) + .execute(args); + } } diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 06698237f..d20bedce4 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -10,3 +10,6 @@ export type { export { ConsoleLogger, Log } from "./ConsoleLogger"; export { DeviceSdk } from "./DeviceSdk"; export { LedgerDeviceSdkBuilder as DeviceSdkBuilder } from "./DeviceSdkBuilder"; + +// [SHOULD] be exported from another file +export type { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; diff --git a/packages/core/src/di.ts b/packages/core/src/di.ts index bca563c75..3fd1a0e31 100644 --- a/packages/core/src/di.ts +++ b/packages/core/src/di.ts @@ -3,8 +3,11 @@ import { Container } from "inversify"; // Uncomment this line to enable the logger middleware // import { makeLoggerMiddleware } from "inversify-logger-middleware"; import { configModuleFactory } from "@internal/config/di/configModule"; +import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { discoveryModuleFactory } from "@internal/discovery/di/discoveryModule"; import { loggerModuleFactory } from "@internal/logger/di/loggerModule"; import { LoggerSubscriber } from "@internal/logger/service/Log"; +import { usbModuleFactory } from "@internal/usb/di/usbModule"; // Uncomment this line to enable the logger middleware // const logger = makeLoggerMiddleware(); @@ -25,6 +28,9 @@ export const makeContainer = ({ container.load( configModuleFactory({ stub }), + deviceModelModuleFactory({ stub }), + usbModuleFactory({ stub }), + discoveryModuleFactory({ stub }), loggerModuleFactory({ subscribers: loggers }), // modules go here ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72b13356e..8102d6ec4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ import "reflect-metadata"; export * from "./api"; -export * from "./transport"; diff --git a/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts new file mode 100644 index 000000000..40bb0f044 --- /dev/null +++ b/packages/core/src/internal/device-model/data/DeviceModelDataSource.ts @@ -0,0 +1,15 @@ +import { + DeviceModel, + DeviceModelId, +} from "@internal/device-model/model/DeviceModel"; + +/** + * Source of truth for the device models + */ +export interface DeviceModelDataSource { + getAllDeviceModels(): DeviceModel[]; + + getDeviceModel(params: { id: DeviceModelId }): DeviceModel; + + filterDeviceModels(params: Partial): DeviceModel[]; +} diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts new file mode 100644 index 000000000..7912ec4f7 --- /dev/null +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts @@ -0,0 +1,142 @@ +import { DeviceModelId } from "@internal/device-model/model/DeviceModel"; + +import { StaticDeviceModelDataSource } from "./StaticDeviceModelDataSource"; + +describe("StaticDeviceModelDataSource", () => { + describe("getAllDeviceModels", () => { + it("should return all device models", () => { + const dataSource = new StaticDeviceModelDataSource(); + const deviceModels = dataSource.getAllDeviceModels(); + + // Currently supporting 4 device models + expect(deviceModels.length).toEqual(4); + expect(deviceModels).toContainEqual( + expect.objectContaining({ id: DeviceModelId.NANO_S }), + ); + expect(deviceModels).toContainEqual( + expect.objectContaining({ id: DeviceModelId.NANO_SP }), + ); + expect(deviceModels).toContainEqual( + expect.objectContaining({ id: DeviceModelId.NANO_X }), + ); + expect(deviceModels).toContainEqual( + expect.objectContaining({ id: DeviceModelId.STAX }), + ); + }); + }); + + describe("getDeviceModel", () => { + it("should return the associated device model", () => { + const dataSource = new StaticDeviceModelDataSource(); + + const deviceModel1 = dataSource.getDeviceModel({ + id: DeviceModelId.NANO_S, + }); + expect(deviceModel1).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_S }), + ); + + const deviceModel2 = dataSource.getDeviceModel({ + id: DeviceModelId.NANO_SP, + }); + expect(deviceModel2).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_SP }), + ); + + const deviceModel3 = dataSource.getDeviceModel({ + id: DeviceModelId.NANO_X, + }); + expect(deviceModel3).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_X }), + ); + + const deviceModel4 = dataSource.getDeviceModel({ + id: DeviceModelId.STAX, + }); + expect(deviceModel4).toEqual( + expect.objectContaining({ id: DeviceModelId.STAX }), + ); + }); + }); + + describe("filterDeviceModels", () => { + it("should return the device models that match the given single parameter", () => { + const dataSource = new StaticDeviceModelDataSource(); + + const deviceModels1 = dataSource.filterDeviceModels({ usbOnly: true }); + expect(deviceModels1.length).toEqual(2); + expect(deviceModels1).toContainEqual( + expect.objectContaining({ id: DeviceModelId.NANO_SP }), + ); + expect(deviceModels1).toContainEqual( + expect.objectContaining({ id: DeviceModelId.NANO_S }), + ); + + const deviceModels2 = dataSource.filterDeviceModels({ + usbProductId: 0x10, + }); + expect(deviceModels2.length).toEqual(1); + expect(deviceModels2[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_S }), + ); + + const deviceModels3 = dataSource.filterDeviceModels({ + usbProductId: 0x40, + }); + expect(deviceModels3.length).toEqual(1); + expect(deviceModels3[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_X }), + ); + + const deviceModels4 = dataSource.filterDeviceModels({ + usbProductId: 0x50, + }); + expect(deviceModels4.length).toEqual(1); + expect(deviceModels4[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_SP }), + ); + + const deviceModels5 = dataSource.filterDeviceModels({ + usbProductId: 0x60, + }); + expect(deviceModels5.length).toEqual(1); + expect(deviceModels5[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.STAX }), + ); + + const deviceModels6 = dataSource.filterDeviceModels({ + usbProductId: 0x00, + }); + expect(deviceModels6.length).toEqual(0); + }); + + it("should return the device models that match the given multiple parameters", () => { + const dataSource = new StaticDeviceModelDataSource(); + + const deviceModels1 = dataSource.filterDeviceModels({ + usbOnly: false, + usbProductId: 0x40, + }); + expect(deviceModels1.length).toEqual(1); + expect(deviceModels1[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_X }), + ); + + const deviceModels2 = dataSource.filterDeviceModels({ + usbOnly: true, + usbProductId: 0x50, + }); + expect(deviceModels2.length).toEqual(1); + expect(deviceModels2[0]).toEqual( + expect.objectContaining({ id: DeviceModelId.NANO_SP }), + ); + + // Nano S is usbOnly + const deviceModels3 = dataSource.filterDeviceModels({ + usbOnly: false, + usbProductId: 0x10, + }); + expect(deviceModels3.length).toEqual(0); + }); + }); +}); diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts new file mode 100644 index 000000000..359438577 --- /dev/null +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts @@ -0,0 +1,88 @@ +import { injectable } from "inversify"; + +import { + DeviceModel, + DeviceModelId, +} from "@internal/device-model/model/DeviceModel"; + +import { DeviceModelDataSource } from "./DeviceModelDataSource"; + +/** + * Static/in memory implementation of the device model data source + */ +@injectable() +export class StaticDeviceModelDataSource implements DeviceModelDataSource { + private static deviceModelByIds: { [key in DeviceModelId]: DeviceModel } = { + [DeviceModelId.NANO_S]: new DeviceModel({ + id: DeviceModelId.NANO_S, + productName: "Ledger Nano S", + usbProductId: 0x10, + legacyUsbProductId: 0x0001, + usbOnly: true, + memorySize: 320 * 1024, + masks: [0x31100000], + }), + [DeviceModelId.NANO_SP]: new DeviceModel({ + id: DeviceModelId.NANO_SP, + productName: "Ledger Nano S Plus", + usbProductId: 0x50, + legacyUsbProductId: 0x0005, + usbOnly: true, + memorySize: 1533 * 1024, + masks: [0x33100000], + }), + [DeviceModelId.NANO_X]: new DeviceModel({ + id: DeviceModelId.NANO_X, + productName: "Ledger Nano X", + usbProductId: 0x40, + legacyUsbProductId: 0x0004, + usbOnly: false, + memorySize: 2 * 1024 * 1024, + masks: [0x33000000], + bluetoothSpec: [ + { + serviceUuid: "13d63400-2c97-0004-0000-4c6564676572", + notifyUuid: "13d63400-2c97-0004-0001-4c6564676572", + writeUuid: "13d63400-2c97-0004-0002-4c6564676572", + writeCmdUuid: "13d63400-2c97-0004-0003-4c6564676572", + }, + ], + }), + [DeviceModelId.STAX]: new DeviceModel({ + id: DeviceModelId.STAX, + productName: "Ledger Stax", + usbProductId: 0x60, + legacyUsbProductId: 0x0006, + usbOnly: false, + memorySize: 1533 * 1024, + masks: [0x33200000], + bluetoothSpec: [ + { + serviceUuid: "13d63400-2c97-6004-0000-4c6564676572", + notifyUuid: "13d63400-2c97-6004-0001-4c6564676572", + writeUuid: "13d63400-2c97-6004-0002-4c6564676572", + writeCmdUuid: "13d63400-2c97-6004-0003-4c6564676572", + }, + ], + }), + }; + + getAllDeviceModels(): DeviceModel[] { + return Object.values(StaticDeviceModelDataSource.deviceModelByIds); + } + + getDeviceModel(params: { id: DeviceModelId }): DeviceModel { + return StaticDeviceModelDataSource.deviceModelByIds[params.id]; + } + + /** + * Returns the list of device models that match all the given parameters + */ + filterDeviceModels(params: Partial): DeviceModel[] { + return this.getAllDeviceModels().filter((deviceModel) => { + return Object.entries(params).every(([key, value]) => { + return deviceModel[key as keyof DeviceModel] === value; + }); + }); + } +} diff --git a/packages/core/src/internal/device-model/di/deviceModelDiTypes.ts b/packages/core/src/internal/device-model/di/deviceModelDiTypes.ts new file mode 100644 index 000000000..2f1392b99 --- /dev/null +++ b/packages/core/src/internal/device-model/di/deviceModelDiTypes.ts @@ -0,0 +1,3 @@ +export const deviceModelDiTypes = { + DeviceModelDataSource: Symbol.for("DeviceModelDataSource"), +}; diff --git a/packages/core/src/internal/device-model/di/deviceModelModule.test.ts b/packages/core/src/internal/device-model/di/deviceModelModule.test.ts new file mode 100644 index 000000000..9e876f14b --- /dev/null +++ b/packages/core/src/internal/device-model/di/deviceModelModule.test.ts @@ -0,0 +1,27 @@ +import { Container } from "inversify"; + +import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; + +import { deviceModelDiTypes } from "./deviceModelDiTypes"; +import { deviceModelModuleFactory } from "./deviceModelModule"; + +describe("deviceModelModuleFactory", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = deviceModelModuleFactory(); + container = new Container(); + container.load(mod); + }); + + it("should return the device module", () => { + expect(mod).toBeDefined(); + }); + + it("should return none mocked services and data sources", () => { + const deviceModelDataSource = container.get( + deviceModelDiTypes.DeviceModelDataSource, + ); + expect(deviceModelDataSource).toBeInstanceOf(StaticDeviceModelDataSource); + }); +}); diff --git a/packages/core/src/internal/device-model/di/deviceModelModule.ts b/packages/core/src/internal/device-model/di/deviceModelModule.ts new file mode 100644 index 000000000..df34df645 --- /dev/null +++ b/packages/core/src/internal/device-model/di/deviceModelModule.ts @@ -0,0 +1,23 @@ +import { ContainerModule } from "inversify"; + +import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; + +import { deviceModelDiTypes } from "./deviceModelDiTypes"; + +type FactoryProps = { + stub: boolean; +}; + +export const deviceModelModuleFactory = ({ + stub = false, +}: Partial = {}) => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + bind(deviceModelDiTypes.DeviceModelDataSource).to( + StaticDeviceModelDataSource, + ); + + if (stub) { + // We can rebind our interfaces to their mock implementations + // rebind(...).to(....); + } + }); diff --git a/packages/core/src/internal/device-model/model/DeviceModel.stub.ts b/packages/core/src/internal/device-model/model/DeviceModel.stub.ts new file mode 100644 index 000000000..ca3501e7b --- /dev/null +++ b/packages/core/src/internal/device-model/model/DeviceModel.stub.ts @@ -0,0 +1,25 @@ +import { DeviceModel, DeviceModelId } from "./DeviceModel"; + +export function deviceModelStubBuilder( + props: Partial = {}, +): DeviceModel { + return { + id: DeviceModelId.NANO_X, + productName: "Ledger Nano X", + usbProductId: 0x40, + legacyUsbProductId: 0x0004, + usbOnly: false, + memorySize: 2 * 1024 * 1024, + masks: [0x33000000], + bluetoothSpec: [ + { + serviceUuid: "13d63400-2c97-0004-0000-4c6564676572", + notifyUuid: "13d63400-2c97-0004-0001-4c6564676572", + writeUuid: "13d63400-2c97-0004-0002-4c6564676572", + writeCmdUuid: "13d63400-2c97-0004-0003-4c6564676572", + }, + ], + getBlockSize: () => 4 * 1024, + ...props, + }; +} diff --git a/packages/core/src/internal/device-model/model/DeviceModel.test.ts b/packages/core/src/internal/device-model/model/DeviceModel.test.ts new file mode 100644 index 000000000..0fa7f5b06 --- /dev/null +++ b/packages/core/src/internal/device-model/model/DeviceModel.test.ts @@ -0,0 +1,57 @@ +import { DeviceModel, DeviceModelId } from "./DeviceModel"; +import { deviceModelStubBuilder } from "./DeviceModel.stub"; + +describe("DeviceModel", () => { + let stubDeviceModel: DeviceModel; + + beforeAll(() => { + stubDeviceModel = deviceModelStubBuilder(); + }); + + test("should return the correct block size for Nano X", () => { + const deviceModel = new DeviceModel(stubDeviceModel); + const firmwareVersion = "2.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(4 * 1024); + }); + + test("should return the correct block size for Stax", () => { + const deviceModel = new DeviceModel({ + ...stubDeviceModel, + id: DeviceModelId.STAX, + }); + const firmwareVersion = "2.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(32); + }); + + test("should return the correct block size for Nano SP", () => { + const deviceModel = new DeviceModel({ + ...stubDeviceModel, + id: DeviceModelId.NANO_SP, + }); + const firmwareVersion = "2.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(32); + }); + + test("should return the correct block size for Nano S with version lower than 2.0.0", () => { + const deviceModel = new DeviceModel({ + ...stubDeviceModel, + id: DeviceModelId.NANO_S, + }); + const firmwareVersion = "1.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(4 * 1024); + }); + + test("should return the correct block size for Nano S with version 2.0.0", () => { + const deviceModel = new DeviceModel({ + ...stubDeviceModel, + id: DeviceModelId.NANO_S, + }); + const firmwareVersion = "2.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(2 * 1024); + }); +}); diff --git a/packages/core/src/internal/device-model/model/DeviceModel.ts b/packages/core/src/internal/device-model/model/DeviceModel.ts new file mode 100644 index 000000000..11f365d90 --- /dev/null +++ b/packages/core/src/internal/device-model/model/DeviceModel.ts @@ -0,0 +1,70 @@ +import semver from "semver"; + +export type DeviceId = string; + +export enum DeviceModelId { + NANO_S = "nanoS", + NANO_SP = "nanoSP", + NANO_X = "nanoX", + STAX = "stax", +} + +/** + * Represents the info of a device model + */ +export class DeviceModel { + id: DeviceModelId; + productName: string; + usbProductId: number; + legacyUsbProductId: number; + usbOnly: boolean; + memorySize: number; + masks: number[]; + bluetoothSpec?: { + serviceUuid: string; + writeUuid: string; + writeCmdUuid: string; + notifyUuid: string; + }[]; + + constructor(props: { + id: DeviceModelId; + productName: string; + usbProductId: number; + legacyUsbProductId: number; + usbOnly: boolean; + memorySize: number; + masks: number[]; + + bluetoothSpec?: { + serviceUuid: string; + writeUuid: string; + writeCmdUuid: string; + notifyUuid: string; + }[]; + }) { + this.id = props.id; + this.productName = props.productName; + this.usbProductId = props.usbProductId; + this.legacyUsbProductId = props.legacyUsbProductId; + this.usbOnly = props.usbOnly; + this.memorySize = props.memorySize; + this.masks = props.masks; + this.bluetoothSpec = props.bluetoothSpec; + } + + getBlockSize(firmwareVersion: string): number { + switch (this.id) { + case DeviceModelId.NANO_S: + return semver.lt(semver.coerce(firmwareVersion) ?? "", "2.0.0") + ? 4 * 1024 + : 2 * 1024; + case DeviceModelId.NANO_SP: + return 32; + case DeviceModelId.NANO_X: + return 4 * 1024; + case DeviceModelId.STAX: + return 32; + } + } +} diff --git a/packages/core/src/internal/discovery/di/discoveryDiTypes.ts b/packages/core/src/internal/discovery/di/discoveryDiTypes.ts new file mode 100644 index 000000000..2168a339f --- /dev/null +++ b/packages/core/src/internal/discovery/di/discoveryDiTypes.ts @@ -0,0 +1,5 @@ +export const discoveryDiTypes = { + StartDiscoveringUseCase: Symbol.for("StartDiscoveringUseCase"), + StopDiscoveringUseCase: Symbol.for("StopDiscoveringUseCase"), + ConnectUseCase: Symbol.for("ConnectUseCase"), +}; diff --git a/packages/core/src/internal/discovery/di/discoveryModule.test.ts b/packages/core/src/internal/discovery/di/discoveryModule.test.ts new file mode 100644 index 000000000..016a9530a --- /dev/null +++ b/packages/core/src/internal/discovery/di/discoveryModule.test.ts @@ -0,0 +1,44 @@ +import { Container } from "inversify"; + +import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; +import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; +import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; +import { usbModuleFactory } from "@internal/usb/di/usbModule"; + +import { discoveryDiTypes } from "./discoveryDiTypes"; +import { discoveryModuleFactory } from "./discoveryModule"; + +describe("discoveryModuleFactory", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = discoveryModuleFactory(); + container = new Container(); + container.load( + mod, + // The following modules are injected into discovery module + usbModuleFactory(), + deviceModelModuleFactory(), + ); + }); + + it("should return the device module", () => { + expect(mod).toBeDefined(); + }); + + it("should return none mocked use cases", () => { + const startDiscoveringUseCase = container.get( + discoveryDiTypes.StartDiscoveringUseCase, + ); + expect(startDiscoveringUseCase).toBeInstanceOf(StartDiscoveringUseCase); + + const stopDiscoveringUseCase = container.get( + discoveryDiTypes.StopDiscoveringUseCase, + ); + expect(stopDiscoveringUseCase).toBeInstanceOf(StopDiscoveringUseCase); + + const connectUseCase = container.get(discoveryDiTypes.ConnectUseCase); + expect(connectUseCase).toBeInstanceOf(ConnectUseCase); + }); +}); diff --git a/packages/core/src/internal/discovery/di/discoveryModule.ts b/packages/core/src/internal/discovery/di/discoveryModule.ts new file mode 100644 index 000000000..841eaf61c --- /dev/null +++ b/packages/core/src/internal/discovery/di/discoveryModule.ts @@ -0,0 +1,25 @@ +import { ContainerModule } from "inversify"; + +import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; +import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; +import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; + +import { discoveryDiTypes } from "./discoveryDiTypes"; + +type FactoryProps = { + stub: boolean; +}; + +export const discoveryModuleFactory = ({ + stub = false, +}: Partial = {}) => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + bind(discoveryDiTypes.StartDiscoveringUseCase).to(StartDiscoveringUseCase); + bind(discoveryDiTypes.StopDiscoveringUseCase).to(StopDiscoveringUseCase); + bind(discoveryDiTypes.ConnectUseCase).to(ConnectUseCase); + + if (stub) { + // We can rebind our interfaces to their mock implementations + // rebind(...).to(....); + } + }); diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts new file mode 100644 index 000000000..6a040aadb --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts @@ -0,0 +1,64 @@ +import { Left, Right } from "purify-ts"; + +import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; +import { DeviceModel } from "@internal/device-model/model/DeviceModel"; +import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; +import { UnknownDeviceError } from "@internal/usb/model/Errors"; +import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; + +import { ConnectUseCase } from "./ConnectUseCase"; + +let transport: WebUsbHidTransport; + +describe("ConnectUseCase", () => { + const stubConnectedDevice: ConnectedDevice = { + id: "", + deviceModel: {} as DeviceModel, + }; + + beforeAll(() => { + transport = new WebUsbHidTransport({} as DeviceModelDataSource); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("If connect use case encounter an error, return it", (done) => { + jest.spyOn(transport, "connect").mockImplementation(() => { + return Promise.resolve(Left(new UnknownDeviceError())); + }); + const usecase = new ConnectUseCase(transport); + + const connect = usecase.execute({ deviceId: "" }); + + connect.subscribe({ + next: (connectedDevice) => { + done(connectedDevice); + }, + error: (error) => { + expect(error).toBeInstanceOf(UnknownDeviceError); + done(); + }, + }); + }); + + test("If connect is in success, return an observable connected device object", (done) => { + jest.spyOn(transport, "connect").mockImplementation(() => { + return Promise.resolve(Right(stubConnectedDevice)); + }); + const usecase = new ConnectUseCase(transport); + + const connect = usecase.execute({ deviceId: "" }); + + connect.subscribe({ + next: (connectedDevice) => { + expect(connectedDevice).toBe(stubConnectedDevice); + done(); + }, + error: (error) => { + done(error); + }, + }); + }); +}); diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts new file mode 100644 index 000000000..64cc98f62 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts @@ -0,0 +1,38 @@ +import { inject, injectable } from "inversify"; +import { from, Observable, of, switchMap } from "rxjs"; + +import { DeviceId } from "@internal/device-model/model/DeviceModel"; +import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; +import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; +import type { UsbHidTransport } from "@internal/usb/transport/UsbHidTransport"; + +export type ConnectUseCaseArgs = { + deviceId: DeviceId; +}; + +/** + * Connects to a discovered device via USB HID (and later BLE). + */ +@injectable() +export class ConnectUseCase { + constructor( + @inject(usbDiTypes.UsbHidTransport) + private usbHidTransport: UsbHidTransport, + // Later: @inject(usbDiTypes.BleTransport) private bleTransport: BleTransport, + ) {} + + execute({ deviceId }: ConnectUseCaseArgs): Observable { + return from(this.usbHidTransport.connect({ deviceId })).pipe( + switchMap((either) => { + return either.caseOf({ + Left: (error) => { + throw error; + }, + Right: (connectedDevice) => { + return of(connectedDevice); + }, + }); + }), + ); + } +} diff --git a/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts new file mode 100644 index 000000000..030363c12 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts @@ -0,0 +1,48 @@ +import { of } from "rxjs"; + +import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; +import { DeviceModel } from "@internal/device-model/model/DeviceModel"; +import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; +import { DiscoveredDevice } from "@root/src"; + +import { StartDiscoveringUseCase } from "./StartDiscoveringUseCase"; + +let transport: WebUsbHidTransport; + +describe("StartDiscoveringUseCase", () => { + const stubDiscoveredDevice: DiscoveredDevice = { + id: "", + deviceModel: {} as DeviceModel, + }; + + beforeEach(() => { + transport = new WebUsbHidTransport({} as DeviceModelDataSource); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("If connect use case encounter an error, return it", (done) => { + const mockedStartDiscovering = jest.fn(() => { + return of(stubDiscoveredDevice); + }); + jest + .spyOn(transport, "startDiscovering") + .mockImplementation(mockedStartDiscovering); + const usecase = new StartDiscoveringUseCase(transport); + + const discover = usecase.execute(); + + expect(mockedStartDiscovering).toHaveBeenCalled(); + discover.subscribe({ + next: (discoveredDevice) => { + expect(discoveredDevice).toBe(stubDiscoveredDevice); + done(); + }, + error: (error) => { + done(error); + }, + }); + }); +}); diff --git a/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.ts b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.ts new file mode 100644 index 000000000..85c2e9bd8 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.ts @@ -0,0 +1,24 @@ +import { inject, injectable } from "inversify"; +import { Observable } from "rxjs"; + +import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; +import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; +import type { UsbHidTransport } from "@internal/usb/transport/UsbHidTransport"; + +/** + * Starts discovering devices connected via USB HID (and later BLE). + * + * For the WebHID implementation, this use-case needs to be called as a result of an user interaction (button "click" event for ex). + */ +@injectable() +export class StartDiscoveringUseCase { + constructor( + @inject(usbDiTypes.UsbHidTransport) + private usbHidTransport: UsbHidTransport, + // Later: @inject(usbDiTypes.BleTransport) private bleTransport: BleTransport, + ) {} + + execute(): Observable { + return this.usbHidTransport.startDiscovering(); + } +} diff --git a/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts new file mode 100644 index 000000000..9cfc058e3 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts @@ -0,0 +1,28 @@ +import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; +import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; + +import { StopDiscoveringUseCase } from "./StopDiscoveringUseCase"; + +let transport: WebUsbHidTransport; + +describe("StopDiscoveringUseCase", () => { + beforeEach(() => { + transport = new WebUsbHidTransport({} as DeviceModelDataSource); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("should call stop discovering", () => { + const mockedStopDiscovering = jest.fn(); + jest + .spyOn(transport, "stopDiscovering") + .mockImplementation(mockedStopDiscovering); + const usecase = new StopDiscoveringUseCase(transport); + + usecase.execute(); + + expect(mockedStopDiscovering).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.ts b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.ts new file mode 100644 index 000000000..56e30c29a --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.ts @@ -0,0 +1,20 @@ +import { inject, injectable } from "inversify"; + +import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; +import type { UsbHidTransport } from "@internal/usb/transport/UsbHidTransport"; + +/** + * Stops discovering devices connected via USB HID (and later BLE). + */ +@injectable() +export class StopDiscoveringUseCase { + constructor( + @inject(usbDiTypes.UsbHidTransport) + private usbHidTransport: UsbHidTransport, + // Later: @inject(usbDiTypes.BleTransport) private bleTransport: BleTransport, + ) {} + + execute(): void { + return this.usbHidTransport.stopDiscovering(); + } +} diff --git a/packages/core/src/internal/usb/data/UsbHidConfig.ts b/packages/core/src/internal/usb/data/UsbHidConfig.ts new file mode 100644 index 000000000..bd56c7d3b --- /dev/null +++ b/packages/core/src/internal/usb/data/UsbHidConfig.ts @@ -0,0 +1,2 @@ +// [SHOULD] Move it to device-model module +export const ledgerVendorId = 0x2c97; diff --git a/packages/core/src/internal/usb/di/usbDiTypes.ts b/packages/core/src/internal/usb/di/usbDiTypes.ts new file mode 100644 index 000000000..aea94a6d1 --- /dev/null +++ b/packages/core/src/internal/usb/di/usbDiTypes.ts @@ -0,0 +1,3 @@ +export const usbDiTypes = { + UsbHidTransport: Symbol.for("UsbHidTransport"), +}; diff --git a/packages/core/src/internal/usb/di/usbModule.test.ts b/packages/core/src/internal/usb/di/usbModule.test.ts new file mode 100644 index 000000000..a26e2433c --- /dev/null +++ b/packages/core/src/internal/usb/di/usbModule.test.ts @@ -0,0 +1,26 @@ +import { Container } from "inversify"; + +import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; + +import { usbDiTypes } from "./usbDiTypes"; +import { usbModuleFactory } from "./usbModule"; + +describe("usbModuleFactory", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = usbModuleFactory(); + container = new Container(); + container.load(mod, deviceModelModuleFactory()); + }); + + it("should return the usb module", () => { + expect(mod).toBeDefined(); + }); + + it("should return none mocked transports", () => { + const usbHidTransport = container.get(usbDiTypes.UsbHidTransport); + expect(usbHidTransport).toBeInstanceOf(WebUsbHidTransport); + }); +}); diff --git a/packages/core/src/internal/usb/di/usbModule.ts b/packages/core/src/internal/usb/di/usbModule.ts new file mode 100644 index 000000000..498a18075 --- /dev/null +++ b/packages/core/src/internal/usb/di/usbModule.ts @@ -0,0 +1,22 @@ +import { ContainerModule } from "inversify"; + +import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; + +import { usbDiTypes } from "./usbDiTypes"; + +type FactoryProps = { + stub: boolean; +}; + +export const usbModuleFactory = ({ + stub = false, +}: Partial = {}) => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + // The transport needs to be a singleton to keep the internal states of the devices + bind(usbDiTypes.UsbHidTransport).to(WebUsbHidTransport).inSingletonScope(); + + if (stub) { + // We can rebind our interfaces to their mock implementations + // rebind(...).to(....); + } + }); diff --git a/packages/core/src/internal/usb/model/ConnectedDevice.ts b/packages/core/src/internal/usb/model/ConnectedDevice.ts new file mode 100644 index 000000000..76673023c --- /dev/null +++ b/packages/core/src/internal/usb/model/ConnectedDevice.ts @@ -0,0 +1,12 @@ +import { + DeviceId, + DeviceModel, +} from "@internal/device-model/model/DeviceModel"; + +/** + * Represents a connected device. + */ +export type ConnectedDevice = { + id: DeviceId; // UUID to map with the associated transport device + deviceModel: DeviceModel; +}; diff --git a/packages/core/src/internal/usb/model/DiscoveredDevice.ts b/packages/core/src/internal/usb/model/DiscoveredDevice.ts new file mode 100644 index 000000000..b17a970c9 --- /dev/null +++ b/packages/core/src/internal/usb/model/DiscoveredDevice.ts @@ -0,0 +1,13 @@ +import { + DeviceId, + DeviceModel, +} from "@internal/device-model/model/DeviceModel"; + +/** + * Represents a discovered/scanned (not yet connected to) device. + */ +export type DiscoveredDevice = { + // type: "web-hid", // "node-hid" in the future -> no need as we will only have 1 USB transport implementation running + id: DeviceId; // UUID to map with the associated transport device + deviceModel: DeviceModel; +}; diff --git a/packages/core/src/internal/usb/model/Errors.ts b/packages/core/src/internal/usb/model/Errors.ts new file mode 100644 index 000000000..ee0c0958f --- /dev/null +++ b/packages/core/src/internal/usb/model/Errors.ts @@ -0,0 +1,45 @@ +export type PromptDeviceAccessError = + | UsbHidTransportNotSupportedError + | NoAccessibleDeviceError; + +export type ConnectError = UnknownDeviceError | OpeningConnectionError; + +export class DeviceNotRecognizedError { + readonly _tag = "DeviceNotRecognizedError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} + +export class NoAccessibleDeviceError { + readonly _tag = "NoAccessibleDeviceError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} + +export class OpeningConnectionError { + readonly _tag = "ConnectionOpeningError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} + +export class UnknownDeviceError { + readonly _tag = "UnknownDeviceError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} + +export class UsbHidTransportNotSupportedError { + readonly _tag = "UsbHidTransportNotSupportedError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} diff --git a/packages/core/src/internal/usb/transport/UsbHidTransport.ts b/packages/core/src/internal/usb/transport/UsbHidTransport.ts new file mode 100644 index 000000000..e89e8e5ab --- /dev/null +++ b/packages/core/src/internal/usb/transport/UsbHidTransport.ts @@ -0,0 +1,28 @@ +import { Either } from "purify-ts"; +import { Observable } from "rxjs"; + +import { DeviceId } from "@internal/device-model/model/DeviceModel"; +import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; +import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; +import { ConnectError } from "@internal/usb/model/Errors"; + +/** + * Transport interface representing a USB HID communication + */ +export interface UsbHidTransport { + isSupported(): boolean; + + startDiscovering(): Observable; + + stopDiscovering(): void; + + /** + * Enables communication with the device by connecting to it. + * + * @param params containing + * - id: the device id from the DTO discovered device + */ + connect(params: { + deviceId: DeviceId; + }): Promise>; +} diff --git a/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts b/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts new file mode 100644 index 000000000..b94467f30 --- /dev/null +++ b/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts @@ -0,0 +1,365 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Left } from "purify-ts"; + +import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; +import { DeviceModelId } from "@internal/device-model/model/DeviceModel"; +import { + DeviceNotRecognizedError, + NoAccessibleDeviceError, + OpeningConnectionError, + UnknownDeviceError, + UsbHidTransportNotSupportedError, +} from "@internal/usb/model/Errors"; + +import { WebUsbHidTransport } from "./WebUsbHidTransport"; + +// Our StaticDeviceModelDataSource can directly be used in our unit tests +const usbDeviceModelDataSource = new StaticDeviceModelDataSource(); +const stubDevice = { + opened: false, + productId: 0x4011, + vendorId: 0x2c97, + productName: "Ledger Nano X", + collections: [], + open: jest.fn(), +}; + +describe("WebUsbHidTransport", () => { + describe("When WebHID API is not supported", () => { + test("isSupported should return false", () => { + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + expect(transport.isSupported()).toBe(false); + }); + + test("startDiscovering should emit an error", (done) => { + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: () => { + done("Should not emit any value"); + }, + error: (error) => { + expect(error).toBeInstanceOf(UsbHidTransportNotSupportedError); + done(); + }, + }); + }); + }); + + describe("When WebHID API is supported", () => { + const mockedGetDevices = jest.fn(); + const mockedRequestDevice = jest.fn(); + + beforeAll(() => { + global.navigator = { + hid: { + getDevices: mockedGetDevices, + requestDevice: mockedRequestDevice, + addEventListener: jest.fn(), + }, + } as any; + }); + + afterAll(() => { + jest.restoreAllMocks(); + global.navigator = undefined as any; + }); + + it("isSupported should return true", () => { + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + expect(transport.isSupported()).toBe(true); + }); + + describe("startDiscovering", () => { + test("If the user grant us access to a device, we should emit it", (done) => { + mockedRequestDevice.mockResolvedValueOnce([stubDevice]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + try { + expect(discoveredDevice).toEqual( + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + productName: "Ledger Nano X", + usbProductId: 0x40, + }), + }), + ); + + done(); + } catch (expectError) { + done(expectError); + } + }, + error: (error) => { + done(error); + }, + }); + }); + + // It does not seem possible for a user to select several devices on the browser popup. + // But if it was possible, we should emit them + test("If the user grant us access to several devices, we should emit them", (done) => { + mockedRequestDevice.mockResolvedValueOnce([ + stubDevice, + { + ...stubDevice, + productId: 0x5011, + productName: "Ledger Nano S Plus", + }, + ]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + + let count = 0; + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + console.log("🦄 discoveredDevice", discoveredDevice); + try { + switch (count) { + case 0: + expect(discoveredDevice).toEqual( + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + productName: "Ledger Nano X", + usbProductId: 0x40, + }), + }), + ); + break; + case 1: + expect(discoveredDevice).toEqual( + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_SP, + productName: "Ledger Nano S Plus", + usbProductId: 0x50, + }), + }), + ); + + done(); + break; + } + + count++; + } catch (expectError) { + done(expectError); + } + }, + error: (error) => { + done(error); + }, + }); + }); + + test("If the device is not recognized, we should throw a DeviceNotRecognizedError", (done) => { + mockedRequestDevice.mockResolvedValueOnce([ + { + ...stubDevice, + productId: 0x4242, + }, + ]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: () => { + done("should not return a device"); + }, + error: (error) => { + expect(error).toBeInstanceOf(DeviceNotRecognizedError); + done(); + }, + }); + }); + + test("If the request device is in error, we should return it", (done) => { + const message = "request device error"; + mockedRequestDevice.mockImplementationOnce(() => { + throw new Error(message); + }); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: () => { + done("should not return a device"); + }, + error: (error) => { + expect(error).toBeInstanceOf(NoAccessibleDeviceError); + expect(error).toStrictEqual( + new NoAccessibleDeviceError(new Error(message)), + ); + done(); + }, + }); + }); + + // [ASK] Is this the behavior we want when the user does not select any device ? + test("If the user did not grant us access to a device (clicking on cancel on the browser popup for ex), we should emit an error", (done) => { + // When the user does not select any device, the `requestDevice` will return an empty array + mockedRequestDevice.mockResolvedValueOnce([]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + done( + `Should not emit any value, but emitted ${JSON.stringify( + discoveredDevice, + )}`, + ); + }, + error: (error) => { + try { + expect(error).toBeInstanceOf(NoAccessibleDeviceError); + done(); + } catch (expectError) { + done(expectError); + } + }, + }); + }); + }); + + describe("stopDiscovering", () => { + test("If the discovery process is halted, we should stop monitoring connections.", () => { + const abortSpy = jest.spyOn(AbortController.prototype, "abort"); + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + + transport.stopDiscovering(); + + expect(abortSpy).toHaveBeenCalled(); + }); + }); + + // [SHOULD] Unit tests connect + // eslint-disable-next-line @typescript-eslint/no-empty-function + describe("connect", () => { + test("If no internal device, should throw UnknownDeviceError", async () => { + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + const device = { deviceId: "fake" }; + + const connect = await transport.connect(device); + + expect(connect).toStrictEqual( + Left(new UnknownDeviceError(new Error("Unknown device fake"))), + ); + }); + + test("If the device is already opened, should throw OpeningConnectionError", async () => { + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + const device = { deviceId: "fake" }; + + const connect = await transport.connect(device); + + expect(connect).toStrictEqual( + Left(new UnknownDeviceError(new Error("Unknown device fake"))), + ); + }); + + test("If the device cannot be opened, should throw OpeningConnexionError", (done) => { + const message = "cannot be opened"; + mockedRequestDevice.mockResolvedValueOnce([ + { + ...stubDevice, + open: () => { + throw new Error(message); + }, + }, + ]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + transport + .connect({ deviceId: discoveredDevice.id }) + .then((value) => { + expect(value).toStrictEqual( + Left(new OpeningConnectionError(new Error(message))), + ); + done(); + }) + .catch((error) => { + done(error); + }); + }, + error: (error) => { + done(error); + }, + }); + }); + + test("If the device is already opened, return it", (done) => { + mockedRequestDevice.mockResolvedValueOnce([ + { + ...stubDevice, + opened: true, + open: () => { + throw new DOMException("already opened", "InvalidStateError"); + }, + }, + ]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + transport + .connect({ deviceId: discoveredDevice.id }) + .then((connectedDevice) => { + connectedDevice + .ifRight((device) => { + expect(device).toEqual( + expect.objectContaining({ id: discoveredDevice.id }), + ); + done(); + }) + .ifLeft(() => { + done(connectedDevice); + }); + }) + .catch((error) => { + done(error); + }); + }, + error: (error) => { + done(error); + }, + }); + }); + + test("If the device is available, return it", (done) => { + mockedRequestDevice.mockResolvedValueOnce([stubDevice]); + + const transport = new WebUsbHidTransport(usbDeviceModelDataSource); + + transport.startDiscovering().subscribe({ + next: (discoveredDevice) => { + transport + .connect({ deviceId: discoveredDevice.id }) + .then((connectedDevice) => { + connectedDevice + .ifRight((device) => { + expect(device).toEqual( + expect.objectContaining({ id: discoveredDevice.id }), + ); + done(); + }) + .ifLeft(() => { + done(connectedDevice); + }); + }) + .catch((error) => { + done(error); + }); + }, + error: (error) => { + done(error); + }, + }); + }); + }); + }); +}); diff --git a/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts new file mode 100644 index 000000000..1bb9ef2c8 --- /dev/null +++ b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts @@ -0,0 +1,281 @@ +import { inject, injectable } from "inversify"; +import { Either, EitherAsync, Left, Right } from "purify-ts"; +import { from, Observable, switchMap } from "rxjs"; +import { v4 as uuid } from "uuid"; + +import type { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; +import { deviceModelDiTypes } from "@internal/device-model/di/deviceModelDiTypes"; +import { DeviceId } from "@internal/device-model/model/DeviceModel"; +import { ledgerVendorId } from "@internal/usb/data/UsbHidConfig"; +import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; +import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; +import { + ConnectError, + DeviceNotRecognizedError, + NoAccessibleDeviceError, + OpeningConnectionError, + type PromptDeviceAccessError, + UnknownDeviceError, + UsbHidTransportNotSupportedError, +} from "@internal/usb/model/Errors"; + +import { UsbHidTransport } from "./UsbHidTransport"; + +// An attempt to manage the state of several devices with one transport. Not final. +type WebHidInternalDevice = { + id: DeviceId; + hidDevice: HIDDevice; + discoveredDevice: DiscoveredDevice; + connectedDevice?: ConnectedDevice; +}; + +@injectable() +export class WebUsbHidTransport implements UsbHidTransport { + // Maps uncoupled DiscoveredDevice and WebHID's HIDDevice WebHID + private internalDevicesById: Map; + + private connectionListenersAbortController: AbortController; + + constructor( + @inject(deviceModelDiTypes.DeviceModelDataSource) + private deviceModelDataSource: DeviceModelDataSource, + ) { + this.internalDevicesById = new Map(); + this.connectionListenersAbortController = new AbortController(); + } + + /** + * @returns `Either` an error if the WebHID API is not supported, or the WebHID API itself + */ + private hidApi = (): Either => { + if (this.isSupported()) { + return Right(navigator.hid); + } + + return Left( + new UsbHidTransportNotSupportedError(new Error("WebHID not supported")), + ); + }; + + isSupported(): boolean { + try { + const result = !!navigator?.hid; + console.log(`isSupported: ${result}`); + return result; + } catch (error) { + console.error(`isSupported: error`, error); + return false; + } + } + + /** + * Currently: as there is no way to uniquely identify a device, we might need to always update the internal mapping + * of devices when prompting for device access. + * + * Also, we cannot trust hidApi.getDevices() as 2 devices of the same models (even on the same USB port) will be recognized + * as the same devices. + */ + // private async promptDeviceAccess(): Promise> { + 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: HIDDevice[] = []; + + try { + hidDevices = await hidApi.requestDevice({ + filters: [{ vendorId: ledgerVendorId }], + }); + } catch (error) { + console.error(`promptDeviceAccess: error requesting device`, error); + throw new NoAccessibleDeviceError(error as Error); + } + + console.log("promptDeviceAccess: hidDevices len", hidDevices.length); + + // Granted access to 0 device (by clicking on cancel for ex) results in an error + if (hidDevices.length === 0) { + console.warn("No device was selected"); + throw new NoAccessibleDeviceError(new Error("No selected device")); + } + + const discoveredHidDevices: HIDDevice[] = []; + + for (const hidDevice of hidDevices) { + discoveredHidDevices.push(hidDevice); + + console.log(`promptDeviceAccess: selected device`, hidDevice); + } + + return discoveredHidDevices; + }) + .run(); + } + + /** + * For WebHID, the client can only discover devices for which the user granted access to. + * + * The issue is that once a user grant access to a device of a model/productId A, any other model/productId A device will be accessible. + * Even if plugged on another USB port. + * So we cannot rely on the `hid.getDevices` to get the list of accessible devices, because it is not possible to differentiate + * between 2 devices of the same model. + * Neither on `connect` and `disconnect` events. + * We can only rely on the `hid.requestDevice` because it is the user who will select the device that we can access. + * + * 2 possible implementations: + * - only `hid.requestDevice` and return the one selected device + * - `hid.getDevices` first to get the previously accessible devices, then a `hid.requestDevice` to get any new one + * + * [ASK] Should we also subscribe to hid events `connect` and `disconnect` ? + * + * [ASK] For the 2nd option: the DiscoveredDevice could have a `isSelected` property ? + * So the consumer can directly select this device. + */ + startDiscovering(): Observable { + console.log("startDiscovering"); + + // Logs the connection and disconnection events + this.startListeningToConnectionEvents(); + + // There is no unique identifier for the device from the USB/HID connection, + // so the previously known accessible devices list cannot be trusted. + this.internalDevicesById.clear(); + + return from(this.promptDeviceAccess()).pipe( + switchMap((either) => { + return either.caseOf({ + Left: (error) => { + console.error("Error while getting accessible device", error); + throw error; + }, + Right: (hidDevices) => { + console.log(`Got access to ${hidDevices.length} HID devices:`); + + const discoveredDevices = hidDevices.map((hidDevice) => { + const usbProductId = this.getHidUsbProductId(hidDevice.productId); + const deviceModels = + this.deviceModelDataSource.filterDeviceModels({ usbProductId }); + + if (deviceModels.length === 1 && deviceModels[0]) { + const id = uuid(); + + const discoveredDevice = { + id, + deviceModel: deviceModels[0], + }; + + const internalDevice: WebHidInternalDevice = { + id, + hidDevice, + discoveredDevice, + }; + + console.log( + `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, + ); + this.internalDevicesById.set(id, internalDevice); + + return discoveredDevice; + } else { + // [ASK] Or we just ignore the not recognized device ? And log them + console.warn( + `Device not recognized: 0x${usbProductId.toString(16)}`, + ); + throw new DeviceNotRecognizedError( + new Error( + `Device not recognized: 0x${usbProductId.toString(16)}`, + ), + ); + } + }); + return from(discoveredDevices); + }, + }); + }), + ); + } + + stopDiscovering(): void { + console.log("stopDiscovering"); + + this.stopListeningToConnectionEvents(); + } + + /** + * Logs `connect` and `disconnect` events for already accessible devices + */ + private startListeningToConnectionEvents(): void { + console.log("startListeningToConnectionEvents"); + + this.hidApi().map((hidApi) => { + hidApi.addEventListener( + "connect", + (event) => { + console.log("connection event", event); + }, + { signal: this.connectionListenersAbortController.signal }, + ); + + hidApi.addEventListener( + "disconnect", + (event) => { + console.log("disconnect event", event); + }, + { signal: this.connectionListenersAbortController.signal }, + ); + }); + } + + private stopListeningToConnectionEvents(): void { + console.log("stopListeningToConnectionEvents"); + this.connectionListenersAbortController.abort(); + } + + /** + * Connect to a HID USB device and update the internal state of the associated device + */ + async connect({ + deviceId, + }: { + deviceId: DeviceId; + }): Promise> { + console.log("connect", { deviceId }); + + const internalDevice = this.internalDevicesById.get(deviceId); + + if (!internalDevice) { + console.error(`Unknown device ${deviceId}`); + return Left( + new UnknownDeviceError(new Error(`Unknown device ${deviceId}`)), + ); + } + + try { + await internalDevice.hidDevice.open(); + } catch (error) { + if (error instanceof DOMException && error.name === "InvalidStateError") { + console.info(`Device ${deviceId} is already opened`); + } else { + console.error(`Error while opening device: ${deviceId}`, error); + return Left(new OpeningConnectionError(error as Error)); + } + } + + internalDevice.connectedDevice = { + id: deviceId, + deviceModel: internalDevice.discoveredDevice.deviceModel, + }; + + // TODO: return a device session USB + return Right(internalDevice.connectedDevice); + } + + /** + * The USB/HID product id is represented by only the 2nd byte + */ + private getHidUsbProductId(productId: number): number { + return productId >> 8; + } +} diff --git a/packages/core/src/internal/usb/transport/__mocks__/WebUsbHidTransport.ts b/packages/core/src/internal/usb/transport/__mocks__/WebUsbHidTransport.ts new file mode 100644 index 000000000..74b3391de --- /dev/null +++ b/packages/core/src/internal/usb/transport/__mocks__/WebUsbHidTransport.ts @@ -0,0 +1,20 @@ +import { UsbHidTransport } from "@internal/usb/transport/UsbHidTransport"; + +export class WebUsbHidTransport implements UsbHidTransport { + isSupported = jest.fn(); + connect = jest.fn(); + startDiscovering = jest.fn(); + stopDiscovering = jest.fn(); +} + +export function usbHidTransportMockBuilder( + props: Partial = {}, +): UsbHidTransport { + return { + isSupported: jest.fn(), + startDiscovering: jest.fn(), + stopDiscovering: jest.fn(), + connect: jest.fn(), + ...props, + }; +} diff --git a/packages/core/src/transport/Transport.ts b/packages/core/src/transport/Transport.ts deleted file mode 100644 index 071f1dc40..000000000 --- a/packages/core/src/transport/Transport.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Response } from "./model"; - -export interface Transport { - send(apduHex: string): Response | undefined; -} diff --git a/packages/core/src/transport/ble/BleTransport.ts b/packages/core/src/transport/ble/BleTransport.ts deleted file mode 100644 index 7512788a4..000000000 --- a/packages/core/src/transport/ble/BleTransport.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Response } from "@root/src/transport/model"; -import { Transport } from "@root/src/transport/Transport"; - -export class BleTransport implements Transport { - send(_apduHex: string): Response | undefined { - return undefined; - } -} diff --git a/packages/core/src/transport/ble/index.ts b/packages/core/src/transport/ble/index.ts deleted file mode 100644 index 78fc74e22..000000000 --- a/packages/core/src/transport/ble/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BleTransport } from "./BleTransport"; diff --git a/packages/core/src/transport/index.ts b/packages/core/src/transport/index.ts deleted file mode 100644 index bbefb1fe2..000000000 --- a/packages/core/src/transport/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./ble"; -export * from "./model"; -export type { Transport } from "./Transport"; diff --git a/packages/core/src/transport/model/Response.ts b/packages/core/src/transport/model/Response.ts deleted file mode 100644 index 4c0b2b92a..000000000 --- a/packages/core/src/transport/model/Response.ts +++ /dev/null @@ -1 +0,0 @@ -export class Response {} diff --git a/packages/core/src/transport/model/index.ts b/packages/core/src/transport/model/index.ts deleted file mode 100644 index 2f4d82263..000000000 --- a/packages/core/src/transport/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { Response } from "./Response"; diff --git a/packages/signer/.prettierignore b/packages/signer/.prettierignore new file mode 100644 index 000000000..4eb56dc37 --- /dev/null +++ b/packages/signer/.prettierignore @@ -0,0 +1 @@ +lib/* \ No newline at end of file diff --git a/packages/trusted-apps/.prettierignore b/packages/trusted-apps/.prettierignore new file mode 100644 index 000000000..4eb56dc37 --- /dev/null +++ b/packages/trusted-apps/.prettierignore @@ -0,0 +1 @@ +lib/* \ No newline at end of file diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore new file mode 100644 index 000000000..4eb56dc37 --- /dev/null +++ b/packages/ui/.prettierignore @@ -0,0 +1 @@ +lib/* \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c38440832..0d37bfefc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,15 @@ importers: reflect-metadata: specifier: ^0.2.1 version: 0.2.1 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + semver: + specifier: ^7.5.4 + version: 7.5.4 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@ledgerhq/eslint-config-dsdk': specifier: workspace:* @@ -181,6 +190,15 @@ importers: '@ledgerhq/tsconfig-dsdk': specifier: workspace:* version: link:../config/typescript + '@types/semver': + specifier: ^7.5.6 + version: 7.5.6 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + '@types/w3c-web-hid': + specifier: ^1.0.6 + version: 1.0.6 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -1992,6 +2010,14 @@ packages: csstype: 3.1.3 dev: true + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + + /@types/w3c-web-hid@1.0.6: + resolution: {integrity: sha512-IWyssXmRDo6K7s31dxf+U+x/XUWuVsl9qUIYbJmpUHPcTv/COfBCKw/F0smI45+gPV34brjyP30BFcIsHgYWLA==} + dev: true + /@types/which@3.0.3: resolution: {integrity: sha512-2C1+XoY0huExTbs8MQv1DuS5FS86+SEjdM9F/+GS61gg5Hqbtj8ZiDSx8MfWcyei907fIPbfPGCOrNUTnVHY1g==} dev: true @@ -6283,7 +6309,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} @@ -7455,7 +7480,6 @@ packages: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: tslib: 2.6.2 - dev: true /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} @@ -7511,7 +7535,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /sentence-case@2.1.1: resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} @@ -8506,6 +8529,11 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true @@ -8746,7 +8774,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} diff --git a/pocs/web-hid-api/README.md b/pocs/web-hid-api/README.md index 319234e7a..734f7eb89 100644 --- a/pocs/web-hid-api/README.md +++ b/pocs/web-hid-api/README.md @@ -6,6 +6,10 @@ See the following documentation: [WebHID API Overview](https://ledgerhq.atlassia ## Run POC To run, just open `index.html` on your browser ✨, and click on the `start` button +You can also serve the page with: +``` +pnpm dlx serve . +``` Some actions (described in comments in `index.js`) can be done to try different error scenarios. diff --git a/pocs/web-hid-api/index.html b/pocs/web-hid-api/index.html index e020432a7..bf1fb5d5a 100644 --- a/pocs/web-hid-api/index.html +++ b/pocs/web-hid-api/index.html @@ -5,6 +5,7 @@ + diff --git a/pocs/web-hid-api/index.js b/pocs/web-hid-api/index.js index 3bffc2b76..da2a5ee41 100644 --- a/pocs/web-hid-api/index.js +++ b/pocs/web-hid-api/index.js @@ -4,7 +4,8 @@ console.info('WebHID API tester'); // A `requestDevice` can only be called in the context of a user interaction. // startWebHid(); -document.getElementById('startButton').addEventListener('click', function() { +document.getElementById('startButton').addEventListener('click', function () { + console.info("--- startButton button clicked ---"); startWebHid(); }); @@ -28,7 +29,7 @@ async function startWebHid() { // and remove any permission access to `file:///` (this website) and see what happens // -> on my MacOS with Chrome no error is thrown and the `devices` array is empty try { - devices = await navigator.hid.requestDevice({ filters: [{ vendorId: 0x2c97 }] }); + devices = await navigator.hid.requestDevice({ filters: [{ vendorId: 0x2c97 }] }); } catch (error) { console.error(`Error requesting device: ${error}`, error); return; @@ -40,19 +41,20 @@ async function startWebHid() { } const device = devices[0]; - console.info(`User selected: ${device.productName}. Collections attributes: ${device.collections.length}`); + console.info(`User selected: ${device.productName}. Product id: 0x${(device.productId >> 8).toString(16)}.`); + console.info(`Collections attributes: ${device.collections.length}`) for (let collection of device.collections) { console.log(`Collection:`); // An HID collection includes usage, usage page, reports, and subcollections. console.log(`--- Usage: ${collection.usage}`); console.log(`--- Usage page: ${collection.usagePage}`); - + console.log(`--- Input reports: ${collection.inputReports.length}`); for (let inputReport of collection.inputReports) { console.log(`--- Input report id: ${inputReport.reportId}`); } - + console.log(`--- Output reports: ${collection.outputReports.length}`); for (let outputReport of collection.outputReports) { console.log(`--- Output report id: ${outputReport.reportId}`); @@ -76,15 +78,15 @@ async function startWebHid() { } } - device.addEventListener("inputreport", event => { - const { data, device, reportId } = event; + device.addEventListener("inputreport", event => { + const { data, device, reportId } = event; const response = (new Uint8Array(data.buffer)).map(x => x.toString(16)).join(' '); console.log(`Received an input report on ${reportId}: ${response}`); }); console.log(`Sending getVersion to device ${device.productName} ...`); try { - await device.sendReport(0, new Uint8Array([0xAA, 0xAA, 0x05, 0x00, 0x00,0x00,0x05,0xE0,0x01,0x00,0x00,0x00])); + await device.sendReport(0, new Uint8Array([0xAA, 0xAA, 0x05, 0x00, 0x00, 0x00, 0x05, 0xE0, 0x01, 0x00, 0x00, 0x00])); console.log("getVersion sent"); } catch (error) { console.error(`Error while sending getVersion: ${error}`, error); @@ -104,3 +106,63 @@ if ("hid" in navigator) { console.log(`📡 Received a disconnect event on ${device.productName}`); }); } + + +/** + * Example to demonstrate that once a device of model/productId A is granted access, any other model/productId A device is also granted access, + * even if plugged in another USB port. + * + * You can test this by: + * - plugging a device 1 (model A) and grant access via the `startButton` button + * - unplug the device 1 + * - plug a device 2 (model A) in another USB port + * - click the `alreadyAccessibleDevice` button + * And you should see the device 2 being opened and the getVersion sent successfully. + * + * You can also test with a device from another model and see what happens. + */ +document.getElementById('alreadyAccessibleDevice').addEventListener('click', async function () { + console.info("--- alreadyAccessibleDevice button clicked ---"); + + if (!("hid" in navigator)) { + console.warn("WebHID API not supported :'("); + return; + } + + console.info("WebHID API supported !"); + + // Get all devices the user has previously granted the website access to. + const prevGrantedDevices = await navigator.hid.getDevices(); + console.info(`Devices previously granted access: ${prevGrantedDevices.length} devices\n: ${prevGrantedDevices.map(d => d.productName).join(', ')}`); + + if (prevGrantedDevices.length > 0) { + const [device] = prevGrantedDevices; + console.log(`Opening the first device: ${device.productName}, product id: 0x${(device.productId >> 8).toString(16)} ...`); + + try { + await device.open(); + } catch (error) { + if (error instanceof DOMException && error.name === "InvalidStateError") { + console.info(`Device ${device.productName} is already open`); + } else { + console.error(`Error while opening device: ${error}`, error); + return + } + } + + device.addEventListener("inputreport", event => { + const { data, reportId } = event; + const response = (new Uint8Array(data.buffer)).map(x => x.toString(16)).join(' '); + console.log(`Received an input report on ${reportId}: ${response}`); + }); + + console.log(`Sending getVersion to device ${device.productName} ...`); + try { + await device.sendReport(0, new Uint8Array([0xAA, 0xAA, 0x05, 0x00, 0x00, 0x00, 0x05, 0xE0, 0x01, 0x00, 0x00, 0x00])); + console.log("getVersion sent"); + } catch (error) { + console.error(`Error while sending getVersion: ${error}`, error); + return; + } + } +}); diff --git a/turbo.json b/turbo.json index 8269c3c03..c279a56ff 100644 --- a/turbo.json +++ b/turbo.json @@ -7,7 +7,7 @@ "inputs": ["src/**/*.ts"] }, "lint": { - "dependsOn": ["^lint"] + "dependsOn": ["build", "^lint"] }, "lint:fix": { "dependsOn": ["^lint:fix"]