From 676ba2f4fdde918de839c1adabaedf520786711e Mon Sep 17 00:00:00 2001 From: Olivier Freyssinet Date: Thu, 17 Oct 2024 14:28:39 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20(core):=20New=20use=20case=20Li?= =?UTF-8?q?stenToKnownDevices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/api/DeviceSdk.ts | 14 + .../core/src/api/transport/model/Transport.ts | 2 + .../discovery/di/discoveryModule.test.ts | 8 + .../internal/discovery/di/discoveryModule.ts | 5 + .../internal/discovery/di/discoveryTypes.ts | 1 + .../ListenToKnownDevicesUseCase.test.ts | 303 +++++++++++++++ .../use-case/ListenToKnownDevicesUseCase.ts | 74 ++++ .../ble/transport/WebBleTransport.ts | 4 + .../transport/__mocks__/WebBleTransport.ts | 7 + .../mockserver/MockserverTransport.ts | 4 + .../usb/transport/WebUsbHidTransport.test.ts | 229 +++++++++-- .../usb/transport/WebUsbHidTransport.ts | 367 ++++++++++-------- .../transport/__mocks__/WebUsbHidTransport.ts | 2 + 13 files changed, 824 insertions(+), 196 deletions(-) create mode 100644 packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.test.ts create mode 100644 packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.ts diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index 1200df3bf..214105898 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -34,6 +34,7 @@ import { discoveryTypes } from "@internal/discovery/di/discoveryTypes"; import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; import { DisconnectUseCase } from "@internal/discovery/use-case/DisconnectUseCase"; import { GetConnectedDeviceUseCase } from "@internal/discovery/use-case/GetConnectedDeviceUseCase"; +import { ListenToKnownDevicesUseCase } from "@internal/discovery/use-case/ListenToKnownDevicesUseCase"; import type { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; import type { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; import { sendTypes } from "@internal/send/di/sendTypes"; @@ -109,6 +110,19 @@ export class DeviceSdk { .execute(); } + /** + * Listen to list of known discovered devices (and later BLE). + * + * @returns {Observable} An observable of known discovered devices. + */ + listenToKnownDevices(): Observable { + return this.container + .get( + discoveryTypes.ListenToKnownDevicesUseCase, + ) + .execute(); + } + /** * Connects to a device previously discovered with `DeviceSdk.startDiscovering`. * Creates a new device session which: diff --git a/packages/core/src/api/transport/model/Transport.ts b/packages/core/src/api/transport/model/Transport.ts index c184c0640..8583c31ce 100644 --- a/packages/core/src/api/transport/model/Transport.ts +++ b/packages/core/src/api/transport/model/Transport.ts @@ -25,6 +25,8 @@ export interface Transport { stopDiscovering(): void; + listenToKnownDevices(): Observable; + /** * Enables communication with the device by connecting to it. * diff --git a/packages/core/src/internal/discovery/di/discoveryModule.test.ts b/packages/core/src/internal/discovery/di/discoveryModule.test.ts index 331c1bea2..7ab180a0a 100644 --- a/packages/core/src/internal/discovery/di/discoveryModule.test.ts +++ b/packages/core/src/internal/discovery/di/discoveryModule.test.ts @@ -4,6 +4,7 @@ import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelM import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; import { DisconnectUseCase } from "@internal/discovery/use-case/DisconnectUseCase"; +import { ListenToKnownDevicesUseCase } from "@internal/discovery/use-case/ListenToKnownDevicesUseCase"; import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; @@ -58,5 +59,12 @@ describe("discoveryModuleFactory", () => { const connectUseCase = container.get(discoveryTypes.ConnectUseCase); expect(connectUseCase).toBeInstanceOf(ConnectUseCase); + + const listenToKnownDevicesUseCase = container.get( + discoveryTypes.ListenToKnownDevicesUseCase, + ); + expect(listenToKnownDevicesUseCase).toBeInstanceOf( + ListenToKnownDevicesUseCase, + ); }); }); diff --git a/packages/core/src/internal/discovery/di/discoveryModule.ts b/packages/core/src/internal/discovery/di/discoveryModule.ts index 2734359fc..599051ac9 100644 --- a/packages/core/src/internal/discovery/di/discoveryModule.ts +++ b/packages/core/src/internal/discovery/di/discoveryModule.ts @@ -3,6 +3,7 @@ import { ContainerModule } from "inversify"; import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; import { DisconnectUseCase } from "@internal/discovery/use-case/DisconnectUseCase"; import { GetConnectedDeviceUseCase } from "@internal/discovery/use-case/GetConnectedDeviceUseCase"; +import { ListenToKnownDevicesUseCase } from "@internal/discovery/use-case/ListenToKnownDevicesUseCase"; import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; import { StubUseCase } from "@root/src/di.stub"; @@ -22,6 +23,9 @@ export const discoveryModuleFactory = ({ stub = false }: FactoryProps) => bind(discoveryTypes.GetConnectedDeviceUseCase).to( GetConnectedDeviceUseCase, ); + bind(discoveryTypes.ListenToKnownDevicesUseCase).to( + ListenToKnownDevicesUseCase, + ); if (stub) { rebind(discoveryTypes.StartDiscoveringUseCase).to(StubUseCase); @@ -29,5 +33,6 @@ export const discoveryModuleFactory = ({ stub = false }: FactoryProps) => rebind(discoveryTypes.ConnectUseCase).to(StubUseCase); rebind(discoveryTypes.DisconnectUseCase).to(StubUseCase); rebind(discoveryTypes.GetConnectedDeviceUseCase).to(StubUseCase); + rebind(discoveryTypes.ListenToKnownDevicesUseCase).to(StubUseCase); } }); diff --git a/packages/core/src/internal/discovery/di/discoveryTypes.ts b/packages/core/src/internal/discovery/di/discoveryTypes.ts index af607dcf4..09bda6b86 100644 --- a/packages/core/src/internal/discovery/di/discoveryTypes.ts +++ b/packages/core/src/internal/discovery/di/discoveryTypes.ts @@ -4,4 +4,5 @@ export const discoveryTypes = { ConnectUseCase: Symbol.for("ConnectUseCase"), DisconnectUseCase: Symbol.for("DisconnectUseCase"), GetConnectedDeviceUseCase: Symbol.for("GetConnectedDeviceUseCase"), + ListenToKnownDevicesUseCase: Symbol.for("ListenToKnownDevicesUseCase"), }; diff --git a/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.test.ts b/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.test.ts new file mode 100644 index 000000000..75fecd479 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.test.ts @@ -0,0 +1,303 @@ +import { Subject } from "rxjs"; + +import { DeviceId, DeviceModel } from "@api/device/DeviceModel"; +import { DiscoveredDevice, Transport } from "@api/types"; +import { deviceModelStubBuilder } from "@internal/device-model/model/DeviceModel.stub"; +import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; + +import { ListenToKnownDevicesUseCase } from "./ListenToKnownDevicesUseCase"; + +function makeMockTransport(props: Partial): Transport { + return { + listenToKnownDevices: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + startDiscovering: jest.fn(), + stopDiscovering: jest.fn(), + getIdentifier: jest.fn(), + isSupported: jest.fn(), + ...props, + }; +} + +const mockInternalDeviceModel = deviceModelStubBuilder(); +function makeMockDeviceModel(id: DeviceId): DeviceModel { + return { + id, + model: mockInternalDeviceModel.id, + name: mockInternalDeviceModel.productName, + }; +} + +function setup2MockTransports() { + const transportAKnownDevicesSubject = new Subject< + InternalDiscoveredDevice[] + >(); + const transportBKnownDevicesSubject = new Subject< + InternalDiscoveredDevice[] + >(); + const transportA = makeMockTransport({ + listenToKnownDevices: () => transportAKnownDevicesSubject.asObservable(), + }); + const transportB = makeMockTransport({ + listenToKnownDevices: () => transportBKnownDevicesSubject.asObservable(), + }); + return { + transportAKnownDevicesSubject, + transportBKnownDevicesSubject, + transportA, + transportB, + }; +} + +function makeMockInternalDiscoveredDevice( + id: string, +): InternalDiscoveredDevice { + return { + id, + deviceModel: mockInternalDeviceModel, + transport: "mock", + }; +} + +describe("ListenToKnownDevicesUseCase", () => { + describe("when no transports are available", () => { + it("should return no discovered devices", (done) => { + const useCase = new ListenToKnownDevicesUseCase([]); + + const observedDiscoveredDevices: DiscoveredDevice[][] = []; + useCase.execute().subscribe({ + next: (devices) => { + observedDiscoveredDevices.push(devices); + }, + complete: () => { + try { + expect(observedDiscoveredDevices).toEqual([[]]); + done(); + } catch (error) { + done(error); + } + }, + error: (error) => { + done(error); + }, + }); + }); + }); + + describe("when one transport is available", () => { + it("should return discovered devices from one transport", () => { + const { transportA, transportAKnownDevicesSubject } = + setup2MockTransports(); + + const observedDiscoveredDevices: DiscoveredDevice[][] = []; + new ListenToKnownDevicesUseCase([transportA]) + .execute() + .subscribe((devices) => { + observedDiscoveredDevices.push(devices); + }); + + // When transportA emits 1 known device + transportAKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportA-device1"), + ]); + + expect(observedDiscoveredDevices[0]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + ]); + + // When transportA emits 2 known devices + transportAKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportA-device1"), + makeMockInternalDiscoveredDevice("transportA-device2"), + ]); + + expect(observedDiscoveredDevices[1]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + { + id: "transportA-device2", + deviceModel: makeMockDeviceModel("transportA-device2"), + transport: "mock", + }, + ]); + + // When transportA emits 1 known device (device1 disconnects) + transportAKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportA-device2"), + ]); + + expect(observedDiscoveredDevices[2]).toEqual([ + { + id: "transportA-device2", + deviceModel: makeMockDeviceModel("transportA-device2"), + transport: "mock", + }, + ]); + + // When transportA emits 0 known devices (device2 disconnects) + transportAKnownDevicesSubject.next([]); + + expect(observedDiscoveredDevices[3]).toEqual([]); + }); + }); + + describe("when multiple transports are available", () => { + it("should return discovered devices from one of the transports as soon as it emits", () => { + const { transportAKnownDevicesSubject, transportA, transportB } = + setup2MockTransports(); + + const observedDiscoveredDevices: DiscoveredDevice[][] = []; + + const onError = jest.fn(); + const onComplete = jest.fn(); + + new ListenToKnownDevicesUseCase([transportA, transportB]) + .execute() + .subscribe({ + next: (devices) => { + observedDiscoveredDevices.push(devices); + }, + error: onError, + complete: onComplete, + }); + + // When transportA emits 1 known device + transportAKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportA-device1"), + ]); + + expect(observedDiscoveredDevices[0]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + ]); + + // When transport A listen observable completes + transportAKnownDevicesSubject.complete(); + + expect(onError).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); // Should not complete yet because transportB has not completed + }); + + it("should combine discovered devices from multiple transports", () => { + const { + transportAKnownDevicesSubject, + transportBKnownDevicesSubject, + transportA, + transportB, + } = setup2MockTransports(); + + const observedDiscoveredDevices: DiscoveredDevice[][] = []; + + const onError = jest.fn(); + const onComplete = jest.fn(); + new ListenToKnownDevicesUseCase([transportA, transportB]) + .execute() + .subscribe({ + next: (devices) => { + observedDiscoveredDevices.push(devices); + }, + error: onError, + complete: onComplete, + }); + + // When transportA emits 1 known device + transportAKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportA-device1"), + ]); + + expect(observedDiscoveredDevices[0]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + ]); + + // When transportB emits 1 known device + transportBKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportB-device1"), + ]); + + expect(observedDiscoveredDevices[1]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + { + id: "transportB-device1", + deviceModel: makeMockDeviceModel("transportB-device1"), + transport: "mock", + }, + ]); + + // When transportB emits 2 known devices + transportBKnownDevicesSubject.next([ + makeMockInternalDiscoveredDevice("transportB-device1"), + makeMockInternalDiscoveredDevice("transportB-device2"), + ]); + + expect(observedDiscoveredDevices[2]).toEqual([ + { + id: "transportA-device1", + deviceModel: makeMockDeviceModel("transportA-device1"), + transport: "mock", + }, + { + id: "transportB-device1", + deviceModel: makeMockDeviceModel("transportB-device1"), + transport: "mock", + }, + { + id: "transportB-device2", + deviceModel: makeMockDeviceModel("transportB-device2"), + transport: "mock", + }, + ]); + + // When transportA emits 0 known devices + transportAKnownDevicesSubject.next([]); + + expect(observedDiscoveredDevices[3]).toEqual([ + { + id: "transportB-device1", + deviceModel: makeMockDeviceModel("transportB-device1"), + transport: "mock", + }, + { + id: "transportB-device2", + deviceModel: makeMockDeviceModel("transportB-device2"), + transport: "mock", + }, + ]); + + // When transport A listen observable completes + transportAKnownDevicesSubject.complete(); + + expect(onError).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); // Should not complete yet because transportB has not completed + + // When transport B emits 0 known devices + transportBKnownDevicesSubject.next([]); + + expect(observedDiscoveredDevices[4]).toEqual([]); + + // When transport B listen observable completes + transportBKnownDevicesSubject.complete(); + + expect(onError).not.toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalled(); // Should complete now because all transports have completed + }); + }); +}); diff --git a/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.ts b/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.ts new file mode 100644 index 000000000..1b0560622 --- /dev/null +++ b/packages/core/src/internal/discovery/use-case/ListenToKnownDevicesUseCase.ts @@ -0,0 +1,74 @@ +import { injectable, multiInject } from "inversify"; +import { from, map, merge, Observable, scan } from "rxjs"; + +import { DeviceModel } from "@api/device/DeviceModel"; +import type { Transport } from "@api/transport/model/Transport"; +import { DiscoveredDevice } from "@api/types"; +import { transportDiTypes } from "@internal/transport/di/transportDiTypes"; +import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; + +/** + * Listen to list of known discovered devices (and later BLE). + */ +@injectable() +export class ListenToKnownDevicesUseCase { + private readonly _transports: Transport[]; + constructor( + @multiInject(transportDiTypes.Transport) + transports: Transport[], + ) { + this._transports = transports; + } + + private mapInternalDiscoveredDeviceToDiscoveredDevice( + discoveredDevice: InternalDiscoveredDevice, + ): DiscoveredDevice { + return { + id: discoveredDevice.id, + deviceModel: new DeviceModel({ + id: discoveredDevice.id, + model: discoveredDevice.deviceModel.id, + name: discoveredDevice.deviceModel.productName, + }), + transport: discoveredDevice.transport, + }; + } + + execute(): Observable { + if (this._transports.length === 0) { + return from([[]]); + } + + /** + * Note: we're not using combineLatest because combineLatest will + * - wait for all observables to emit at least once before emitting. + * - complete as soon as one of the observables completes. + * Some transports will just return an empty array and complete. + * We want to keep listening to all transports until all have completed. + */ + + const observablesWithIndex = this._transports.map((transport, index) => + transport.listenToKnownDevices().pipe( + map((arr) => ({ + index, + arr, + })), + ), + ); + + return merge(...observablesWithIndex).pipe( + scan( + (acc, { index, arr }) => { + acc[index] = arr; + return acc; + }, + {} as { [key: number]: Array }, + ), + map((acc) => + Object.values(acc) + .flat() + .map(this.mapInternalDiscoveredDeviceToDiscoveredDevice), + ), + ); + } +} diff --git a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts index b89366f87..631ce4d37 100644 --- a/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/WebBleTransport.ts @@ -94,6 +94,10 @@ export class WebBleTransport implements Transport { return this.identifier; } + listenToKnownDevices(): Observable { + return from([]); + } + /** * Get Bluetooth GATT Primary service that is used to get writeCharacteristic and notifyCharacteristic * @param bleDevice diff --git a/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts b/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts index ead23b3bd..5418a8e69 100644 --- a/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts +++ b/packages/core/src/internal/transport/ble/transport/__mocks__/WebBleTransport.ts @@ -1,6 +1,12 @@ +import { Observable } from "rxjs"; + import { Transport } from "@api/transport/model/Transport"; +import { InternalDiscoveredDevice } from "@internal/transport/model/InternalDiscoveredDevice"; export class WebBleTransport implements Transport { + listenToKnownDevices(): Observable { + throw new Error("Method not implemented."); + } isSupported = jest.fn(); getIdentifier = jest.fn(); connect = jest.fn(); @@ -20,6 +26,7 @@ export function usbHidTransportMockBuilder( stopDiscovering: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), + listenToKnownDevices: jest.fn(), ...props, }; } diff --git a/packages/core/src/internal/transport/mockserver/MockserverTransport.ts b/packages/core/src/internal/transport/mockserver/MockserverTransport.ts index 4e8ac8319..5bc792f8e 100644 --- a/packages/core/src/internal/transport/mockserver/MockserverTransport.ts +++ b/packages/core/src/internal/transport/mockserver/MockserverTransport.ts @@ -56,6 +56,10 @@ export class MockTransport implements Transport { return this.identifier; } + listenToKnownDevices(): Observable { + return from([]); + } + startDiscovering(): Observable { this.logger.debug("startDiscovering"); return from( diff --git a/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.test.ts b/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.test.ts index d7c80426b..b1c9cb816 100644 --- a/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.test.ts +++ b/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.test.ts @@ -3,6 +3,7 @@ import { Subject } from "rxjs"; import { DeviceModel, DeviceModelId } from "@api/device/DeviceModel"; import { StaticDeviceModelDataSource } from "@internal/device-model/data/StaticDeviceModelDataSource"; +import { InternalDeviceModel } from "@internal/device-model/model/DeviceModel"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { DeviceNotRecognizedError, @@ -27,15 +28,26 @@ const logger = new DefaultLoggerPublisherService([], "web-usb-hid"); const stubDevice: HIDDevice = hidDeviceStubBuilder(); +/** + * Flushes all pending promises + */ +const flushPromises = () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + new Promise(jest.requireActual("timers").setImmediate); + describe("WebUsbHidTransport", () => { let transport: WebUsbHidTransport; - beforeEach(() => { + function initializeTransport() { transport = new WebUsbHidTransport( usbDeviceModelDataSource, () => logger, usbHidDeviceConnectionFactoryStubBuilder(), ); + } + + beforeEach(() => { + initializeTransport(); jest.useFakeTimers(); }); @@ -79,22 +91,18 @@ describe("WebUsbHidTransport", () => { const disconnectionEventsSubject = new Subject(); function emitHIDConnectionEvent(device: HIDDevice) { - if (global.navigator.hid.onconnect) - global.navigator.hid.onconnect({ device } as HIDConnectionEvent); connectionEventsSubject.next({ device, } as HIDConnectionEvent); } function emitHIDDisconnectionEvent(device: HIDDevice) { - if (global.navigator.hid.ondisconnect) - global.navigator.hid.ondisconnect({ device } as HIDConnectionEvent); disconnectionEventsSubject.next({ device, } as HIDConnectionEvent); } - beforeAll(() => { + beforeEach(() => { global.navigator = { hid: { getDevices: mockedGetDevices, @@ -111,9 +119,10 @@ describe("WebUsbHidTransport", () => { }, }, } as unknown as Navigator; + initializeTransport(); }); - afterAll(() => { + afterEach(() => { jest.restoreAllMocks(); global.navigator = undefined as unknown as Navigator; }); @@ -289,6 +298,7 @@ describe("WebUsbHidTransport", () => { it("should emit the same discoveredDevice object if its discovered twice in a row", async () => { mockedRequestDevice.mockResolvedValue([stubDevice]); + mockedGetDevices.mockResolvedValue([stubDevice]); const firstDiscoveredDevice = await new Promise((resolve, reject) => { discoverDevice(resolve, (err) => reject(err)); @@ -300,11 +310,11 @@ describe("WebUsbHidTransport", () => { }); }); - describe("stopDiscovering", () => { + describe("destroy", () => { it("should stop monitoring connections if the discovery process is halted", () => { const abortSpy = jest.spyOn(AbortController.prototype, "abort"); - transport.stopDiscovering(); + transport.destroy(); expect(abortSpy).toHaveBeenCalled(); }); @@ -339,14 +349,14 @@ describe("WebUsbHidTransport", () => { it("should throw OpeningConnectionError if the device cannot be opened", (done) => { const message = "cannot be opened"; - mockedRequestDevice.mockResolvedValueOnce([ - { - ...stubDevice, - open: () => { - throw new Error(message); - }, + const mockedDevice = { + ...stubDevice, + open: () => { + throw new Error(message); }, - ]); + }; + mockedRequestDevice.mockResolvedValueOnce([mockedDevice]); + mockedGetDevices.mockResolvedValue([mockedDevice]); discoverDevice( (discoveredDevice) => { @@ -372,15 +382,17 @@ describe("WebUsbHidTransport", () => { }); it("should return the opened device", (done) => { - mockedRequestDevice.mockResolvedValueOnce([ - { - ...stubDevice, - opened: true, - open: () => { - throw new DOMException("already opened", "InvalidStateError"); - }, + const mockedDevice = { + ...stubDevice, + opened: false, + open: () => { + mockedDevice.opened = true; + return Promise.resolve(); }, - ]); + }; + + mockedRequestDevice.mockResolvedValue([mockedDevice]); + mockedGetDevices.mockResolvedValue([mockedDevice]); discoverDevice( (discoveredDevice) => { @@ -413,6 +425,7 @@ describe("WebUsbHidTransport", () => { it("should return a device if available", (done) => { mockedRequestDevice.mockResolvedValueOnce([stubDevice]); + mockedGetDevices.mockResolvedValue([stubDevice]); discoverDevice( (discoveredDevice) => { @@ -538,6 +551,7 @@ describe("WebUsbHidTransport", () => { const hidDevice2 = hidDeviceStubBuilder(); mockedRequestDevice.mockResolvedValueOnce([hidDevice1]); + mockedGetDevices.mockResolvedValue([hidDevice1, hidDevice2]); discoverDevice(async (discoveredDevice) => { try { @@ -576,6 +590,11 @@ describe("WebUsbHidTransport", () => { const hidDevice3 = hidDeviceStubBuilder(); mockedRequestDevice.mockResolvedValueOnce([hidDevice1]); + mockedGetDevices.mockResolvedValue([ + hidDevice1, + hidDevice2, + hidDevice3, + ]); // when discoverDevice(async (discoveredDevice) => { @@ -641,5 +660,167 @@ describe("WebUsbHidTransport", () => { expect(result).toBe(false); }); }); + + describe("listenToKnownDevices", () => { + it("should emit the devices already connected before listening", async () => { + // given + const hidDevice = hidDeviceStubBuilder(); + mockedGetDevices.mockResolvedValue([hidDevice]); + + const onComplete = jest.fn(); + const onError = jest.fn(); + + let observedDevices: InternalDiscoveredDevice[] = []; + // when + transport.listenToKnownDevices().subscribe({ + next: (knownDevices) => { + observedDevices = knownDevices; + }, + complete: onComplete, + error: onError, + }); + + await flushPromises(); + + expect(observedDevices).toEqual([ + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + }) as InternalDeviceModel, + }), + ]); + expect(onComplete).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it("should emit the new list of devices after connection and disconnection events", async () => { + initializeTransport(); + // given + const hidDevice1 = hidDeviceStubBuilder({ + productId: + usbDeviceModelDataSource.getDeviceModel({ + id: DeviceModelId.NANO_X, + }).usbProductId << 8, + }); + const hidDevice2 = hidDeviceStubBuilder({ + productId: + usbDeviceModelDataSource.getDeviceModel({ id: DeviceModelId.STAX }) + .usbProductId << 8, + }); + mockedGetDevices.mockResolvedValue([hidDevice1]); + + const onComplete = jest.fn(); + const onError = jest.fn(); + + let observedDevices: InternalDiscoveredDevice[] = []; + // when + transport.listenToKnownDevices().subscribe({ + next: (knownDevices) => { + observedDevices = knownDevices; + }, + complete: onComplete, + error: onError, + }); + + await flushPromises(); + + expect(observedDevices).toEqual([ + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + }) as InternalDeviceModel, + }), + ]); + + // When a new device is connected + mockedGetDevices.mockResolvedValue([hidDevice1, hidDevice2]); + emitHIDConnectionEvent(hidDevice2); + await flushPromises(); + + expect(observedDevices).toEqual([ + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + }) as InternalDeviceModel, + }), + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.STAX, + }) as InternalDeviceModel, + }), + ]); + + // When a device is disconnected + mockedGetDevices.mockResolvedValue([hidDevice2]); + emitHIDDisconnectionEvent(hidDevice1); + await flushPromises(); + + expect(observedDevices).toEqual([ + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.STAX, + }) as InternalDeviceModel, + }), + ]); + + expect(onComplete).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it("should preserve DeviceId in case the device has been disconnected and reconnected before the timeout", async () => { + // given + const hidDevice = hidDeviceStubBuilder(); + + mockedGetDevices.mockResolvedValue([hidDevice]); + + const onComplete = jest.fn(); + const onError = jest.fn(); + let observedDevices: InternalDiscoveredDevice[] = []; + // when + transport.listenToKnownDevices().subscribe({ + next: (knownDevices) => { + observedDevices = knownDevices; + }, + complete: onComplete, + error: onError, + }); + + await flushPromises(); + + const firstObservedDeviceId = observedDevices[0]?.id; + expect(firstObservedDeviceId).toBeTruthy(); + expect(observedDevices[0]?.deviceModel?.id).toBe(DeviceModelId.NANO_X); + + // Start a connection with the device + await transport.connect({ + deviceId: observedDevices[0]!.id, + onDisconnect: jest.fn(), + }); + await flushPromises(); + + // When the device is disconnected + mockedGetDevices.mockResolvedValue([]); + emitHIDDisconnectionEvent(hidDevice); + await flushPromises(); + + expect(observedDevices).toEqual([]); + + // When the device is reconnected + mockedGetDevices.mockResolvedValue([hidDevice]); + emitHIDConnectionEvent(hidDevice); + await flushPromises(); + + expect(observedDevices).toEqual([ + expect.objectContaining({ + deviceModel: expect.objectContaining({ + id: DeviceModelId.NANO_X, + }) as InternalDeviceModel, + }), + ]); + + expect(observedDevices[0]?.id).toBeTruthy(); + expect(observedDevices[0]?.id).toBe(firstObservedDeviceId); + }); + }); }); }); diff --git a/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.ts b/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.ts index f80c735be..5b7dca105 100644 --- a/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.ts +++ b/packages/core/src/internal/transport/usb/transport/WebUsbHidTransport.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/minimal"; import { inject, injectable } from "inversify"; import { Either, EitherAsync, Left, Maybe, Right } from "purify-ts"; -import { from, Observable, switchMap } from "rxjs"; +import { BehaviorSubject, from, map, Observable, switchMap } from "rxjs"; import { v4 as uuid } from "uuid"; import { DeviceId } from "@api/device/DeviceModel"; @@ -21,7 +21,6 @@ import { DisconnectHandler } from "@internal/transport/model/DeviceConnection"; import { ConnectError, DeviceNotRecognizedError, - DisconnectError, NoAccessibleDeviceError, OpeningConnectionError, type PromptDeviceAccessError, @@ -35,27 +34,33 @@ import { usbDiTypes } from "@internal/transport/usb/di/usbDiTypes"; import { UsbHidDeviceConnectionFactory } from "@internal/transport/usb/service/UsbHidDeviceConnectionFactory"; import { UsbHidDeviceConnection } from "@internal/transport/usb/transport/UsbHidDeviceConnection"; -// An attempt to manage the state of several devices with one transport. Not final. -type WebHidInternalDevice = { - id: DeviceId; +type WebUsbHidInternalDiscoveredDevice = InternalDiscoveredDevice & { hidDevice: HIDDevice; - discoveredDevice: InternalDiscoveredDevice; }; @injectable() export class WebUsbHidTransport implements Transport { - /** Maps DeviceId to an internal object containing the associated DiscoveredDevice and HIDDevice */ - private _internalDevicesById: Map = new Map(); - /** Maps all *connected* HIDDevice to their UsbHidDeviceConnection */ + /** List of HID devices that have been discovered */ + private _internalDiscoveredDevices: BehaviorSubject< + Array + > = new BehaviorSubject>([]); + + /** Map of *connected* HIDDevice to their UsbHidDeviceConnection */ private _deviceConnectionsByHidDevice: Map< HIDDevice, UsbHidDeviceConnection > = new Map(); - /** Set of all the UsbHidDeviceConnection for which HIDDevice has been disconnected, so they are waiting for a reconnection */ + + /** + * Set of UsbHidDeviceConnection 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; + private _connectionListenersAbortController: AbortController = + new AbortController(); private _logger: LoggerPublisherService; private _usbHidDeviceConnectionFactory: UsbHidDeviceConnectionFactory; private readonly connectionType: ConnectionType = "USB"; @@ -69,17 +74,10 @@ export class WebUsbHidTransport implements Transport { @inject(usbDiTypes.UsbHidDeviceConnectionFactory) usbHidDeviceConnectionFactory: UsbHidDeviceConnectionFactory, ) { - this._connectionListenersAbortController = new AbortController(); this._logger = loggerServiceFactory("WebUsbHidTransport"); this._usbHidDeviceConnectionFactory = usbHidDeviceConnectionFactory; - this.hidApi.map((hidApi) => { - // FIXME: we should not override the global navigator.hid.onconnect and navigator.hid.ondisconnect but instead use addEventListener - // The thing is if we want to do that we need a destroy() method on the SDK to remove the event listeners - hidApi.ondisconnect = (event) => - this.handleDeviceDisconnectionEvent(event); - hidApi.onconnect = (event) => this.handleDeviceConnectionEvent(event); - }); + this.startListeningToConnectionEvents(); } /** @@ -110,13 +108,110 @@ export class WebUsbHidTransport implements Transport { } /** - * 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. + * 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.getDevices(); + 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; + } + }) + .run(); + } + + /** + * Map a HIDDevice to an InternalDiscoveredDevice, either by creating a new one or returning an existing one + */ + private mapHIDDeviceToInternalDiscoveredDevice( + hidDevice: HIDDevice, + ): WebUsbHidInternalDiscoveredDevice { + const existingDiscoveredDevice = this._internalDiscoveredDevices + .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 listenToKnownDevices(): Observable { + this.updateInternalDiscoveredDevices(); + return this._internalDiscoveredDevices.pipe( + map((devices) => devices.map(({ hidDevice, ...device }) => device)), + ); + } + + private async updateInternalDiscoveredDevices(): 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._internalDiscoveredDevices.next( + hidDevices.map((hidDevice) => + this.mapHIDDeviceToInternalDiscoveredDevice(hidDevice), + ), + ); + }, + }); + } + + /** + * Wrapper around navigator.hid.requestDevice() + * In a browser, it will show a native dialog to select a HID device. */ - // private async promptDeviceAccess(): Promise> { private async promptDeviceAccess(): Promise< Either > { @@ -129,6 +224,7 @@ export class WebUsbHidTransport implements Transport { hidDevices = await hidApi.requestDevice({ filters: [{ vendorId: LEDGER_VENDOR_ID }], }); + await this.updateInternalDiscoveredDevices(); } catch (error) { const deviceError = new NoAccessibleDeviceError(error); this._logger.error(`promptDeviceAccess: error requesting device`, { @@ -163,31 +259,9 @@ export class WebUsbHidTransport implements Transport { .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 { this._logger.debug("startDiscovering"); - // Logs the connection and disconnection events - this.startListeningToConnectionEvents(); - return from(this.promptDeviceAccess()).pipe( switchMap((either) => { return either.caseOf({ @@ -202,52 +276,7 @@ export class WebUsbHidTransport implements Transport { this._logger.info(`Got access to ${hidDevices.length} HID devices`); const discoveredDevices = hidDevices.map((hidDevice) => { - const matchingInternalDevice = Array.from( - this._internalDevicesById.values(), - ).find( - (internalDevice) => internalDevice.hidDevice === hidDevice, - ); - - if (matchingInternalDevice) { - this._logger.debug( - `Device already discovered ${matchingInternalDevice.id}`, - ); - return matchingInternalDevice.discoveredDevice; - } - const maybeDeviceModel = this.getDeviceModel(hidDevice); - return maybeDeviceModel.caseOf({ - Just: (deviceModel) => { - const id = uuid(); - - const discoveredDevice = { - id, - deviceModel, - transport: this.identifier, - }; - - const internalDevice: WebHidInternalDevice = { - id, - hidDevice, - discoveredDevice, - }; - - this._logger.debug( - `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, - ); - this._internalDevicesById.set(id, internalDevice); - - 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)}`, - ); - }, - }); + return this.mapHIDDeviceToInternalDiscoveredDevice(hidDevice); }); return from(discoveredDevices); }, @@ -257,30 +286,26 @@ export class WebUsbHidTransport implements Transport { } stopDiscovering(): void { - this._logger.debug("stopDiscovering"); - - this.stopListeningToConnectionEvents(); + /** + * This does nothing because the startDiscovering method is just a + * promise wrapped into an observable. So there is no need to stop it. + */ } - /** - * Logs `connect` and `disconnect` events for already accessible devices - */ private startListeningToConnectionEvents(): void { this._logger.debug("startListeningToConnectionEvents"); this.hidApi.map((hidApi) => { hidApi.addEventListener( "connect", - (event) => { - this._logger.debug("connection event", { data: { event } }); - }, + (event) => this.handleDeviceConnectionEvent(event), { signal: this._connectionListenersAbortController.signal }, ); hidApi.addEventListener( "disconnect", (event) => { - this._logger.debug("disconnect event", { data: { event } }); + this.handleDeviceDisconnectionEvent(event); }, { signal: this._connectionListenersAbortController.signal }, ); @@ -304,15 +329,22 @@ export class WebUsbHidTransport implements Transport { }): Promise> { this._logger.debug("connect", { data: { deviceId } }); - const internalDevice = this._internalDevicesById.get(deviceId); + const matchingInternalDevice = this._internalDiscoveredDevices + .getValue() + .find((internalDevice) => internalDevice.id === deviceId); - if (!internalDevice) { + if (!matchingInternalDevice) { this._logger.error(`Unknown device ${deviceId}`); return Left(new UnknownDeviceError(`Unknown device ${deviceId}`)); } try { - await internalDevice.hidDevice.open(); + if ( + this._deviceConnectionsByHidDevice.get(matchingInternalDevice.hidDevice) + ) { + throw new Error("Device already opened"); + } + await matchingInternalDevice.hidDevice.open(); } catch (error) { if (error instanceof DOMException && error.name === "InvalidStateError") { this._logger.debug(`Device ${deviceId} is already opened`); @@ -326,24 +358,25 @@ export class WebUsbHidTransport implements Transport { } } - const { - discoveredDevice: { deviceModel }, - } = internalDevice; + const { deviceModel } = matchingInternalDevice; const deviceConnection = this._usbHidDeviceConnectionFactory.create( - internalDevice.hidDevice, + matchingInternalDevice.hidDevice, { onConnectionTerminated: () => { onDisconnect(deviceId); this._deviceConnectionsPendingReconnection.delete(deviceConnection); - this.deleteInternalDevice({ internalDevice }); + this._deviceConnectionsByHidDevice.delete( + matchingInternalDevice.hidDevice, + ); + deviceConnection.device.close(); }, deviceId, }, ); this._deviceConnectionsByHidDevice.set( - internalDevice.hidDevice, + matchingInternalDevice.hidDevice, deviceConnection, ); const connectedDevice = new InternalConnectedDevice({ @@ -375,24 +408,6 @@ export class WebUsbHidTransport implements Transport { }); } - private async deleteInternalDevice(params: { - internalDevice: WebHidInternalDevice; - }): Promise> { - this._logger.debug("_internalDisconnect", { - data: { connectedDevice: params }, - }); - const { internalDevice } = params; - - try { - this._internalDevicesById.delete(internalDevice.id); - this._deviceConnectionsByHidDevice.delete(internalDevice.hidDevice); - await internalDevice.hidDevice.close(); - return Right(void 0); - } catch (error) { - return Left(new DisconnectError(error)); - } - } - /** * Disconnect from a HID USB device */ @@ -400,26 +415,27 @@ export class WebUsbHidTransport implements Transport { connectedDevice: InternalConnectedDevice; }): Promise> { this._logger.debug("disconnect", { data: { connectedDevice: params } }); - const internalDevice = this._internalDevicesById.get( - params.connectedDevice.id, + + const matchingDeviceConnection = Array.from( + this._deviceConnectionsByHidDevice.values(), + ).find( + (deviceConnection) => + deviceConnection.deviceId === params.connectedDevice.id, ); - if (!internalDevice) { - this._logger.error(`Unknown device ${params.connectedDevice.id}`); - return Left( - new UnknownDeviceError(`Unknown device ${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}`), + ), ); } - const deviceConnection = this._deviceConnectionsByHidDevice.get( - internalDevice.hidDevice, - ); - - deviceConnection?.disconnect(); - - return this.deleteInternalDevice({ - internalDevice, - }); + matchingDeviceConnection.disconnect(); + return Promise.resolve(Right(undefined)); } /** @@ -451,6 +467,8 @@ export class WebUsbHidTransport implements Transport { data: { event }, }); + this.updateInternalDiscoveredDevices(); + try { await event.device.close(); } catch (error) { @@ -470,6 +488,23 @@ export class WebUsbHidTransport implements Transport { } } + private handleDeviceReconnection( + deviceConnection: UsbHidDeviceConnection, + hidDevice: HIDDevice, + ) { + 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 @@ -491,36 +526,24 @@ export class WebUsbHidTransport implements Transport { this.getHidUsbProductId(deviceConnection.device) === this.getHidUsbProductId(event.device), ); - if (!matchingDeviceConnection) return; - - const matchingInternalDevice = this._internalDevicesById.get( - matchingDeviceConnection.deviceId, - ); - if (!matchingInternalDevice) { - this._logger.error("Internal device not found", { - data: { matchingDeviceConnection }, - }); - return; + if (matchingDeviceConnection) { + this.handleDeviceReconnection(matchingDeviceConnection, event.device); } - this._deviceConnectionsPendingReconnection.delete(matchingDeviceConnection); - this._deviceConnectionsByHidDevice.set( - event.device, - matchingDeviceConnection, - ); - this._internalDevicesById.set(matchingDeviceConnection.deviceId, { - ...matchingInternalDevice, - hidDevice: event.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.updateInternalDiscoveredDevices(); + } - try { - matchingDeviceConnection.reconnectHidDevice(event.device); - } catch (error) { - this._logger.error("Error while reconnecting to device", { - data: { event, error }, - }); - matchingDeviceConnection.disconnect(); - } + public destroy() { + this.stopListeningToConnectionEvents(); + this._deviceConnectionsByHidDevice.forEach((connection) => { + connection.disconnect(); + }); + this._deviceConnectionsPendingReconnection.clear(); } } diff --git a/packages/core/src/internal/transport/usb/transport/__mocks__/WebUsbHidTransport.ts b/packages/core/src/internal/transport/usb/transport/__mocks__/WebUsbHidTransport.ts index 320f18922..b73408cbf 100644 --- a/packages/core/src/internal/transport/usb/transport/__mocks__/WebUsbHidTransport.ts +++ b/packages/core/src/internal/transport/usb/transport/__mocks__/WebUsbHidTransport.ts @@ -8,6 +8,7 @@ export class WebUsbHidTransport implements Transport { stopDiscovering = jest.fn(); disconnect = jest.fn(); + listenToKnownDevices = jest.fn(); } export function usbHidTransportMockBuilder( @@ -20,6 +21,7 @@ export function usbHidTransportMockBuilder( stopDiscovering: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), + listenToKnownDevices: jest.fn(), ...props, }; } From 6404b05841343d39cba1ba06c194c4eff92fd57e Mon Sep 17 00:00:00 2001 From: Olivier Freyssinet Date: Thu, 17 Oct 2024 14:28:57 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20(sample):=20Display=20known=20d?= =?UTF-8?q?evices=20as=20"available=20devices"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sample/package.json | 1 + .../src/components/AvailableDevices/index.tsx | 94 +++++++++++++++ apps/sample/src/components/Device/index.tsx | 50 +++++++- .../MainView/ConnectDeviceActions.tsx | 15 ++- apps/sample/src/components/MainView/index.tsx | 1 - apps/sample/src/components/Sidebar/index.tsx | 14 +-- apps/sample/src/hooks/useAvailableDevices.tsx | 45 ++++++++ apps/sample/src/hooks/useHasChanged.tsx | 13 +++ apps/sample/src/hooks/usePrevious.ts | 14 ++- .../src/providers/DeviceSdkProvider/index.tsx | 108 +++++++++++------- .../DeviceSessionsProvider/index.tsx | 32 +++++- apps/sample/src/reducers/deviceSessions.ts | 14 ++- apps/sample/src/styles/globalstyles.tsx | 3 + pnpm-lock.yaml | 3 + 14 files changed, 341 insertions(+), 66 deletions(-) create mode 100644 apps/sample/src/components/AvailableDevices/index.tsx create mode 100644 apps/sample/src/hooks/useAvailableDevices.tsx create mode 100644 apps/sample/src/hooks/useHasChanged.tsx diff --git a/apps/sample/package.json b/apps/sample/package.json index 71a12387f..ad61b8bd2 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -28,6 +28,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-lottie": "^1.2.4", + "rxjs": "^7.8.1", "styled-components": "^5.3.11" }, "devDependencies": { diff --git a/apps/sample/src/components/AvailableDevices/index.tsx b/apps/sample/src/components/AvailableDevices/index.tsx new file mode 100644 index 000000000..24ea8ebad --- /dev/null +++ b/apps/sample/src/components/AvailableDevices/index.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useState } from "react"; +import { DiscoveredDevice } from "@ledgerhq/device-management-kit"; +import { Flex, Icons, Text } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +import { AvailableDevice } from "@/components/Device"; +import { useAvailableDevices } from "@/hooks/useAvailableDevices"; +import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; + +const Title = styled(Text)<{ disabled: boolean }>` + :hover { + user-select: none; + text-decoration: ${(p) => (p.disabled ? "none" : "underline")}; + cursor: ${(p) => (p.disabled ? "default" : "pointer")}; + } +`; + +export const AvailableDevices: React.FC> = () => { + const discoveredDevices = useAvailableDevices(); + const noDevice = discoveredDevices.length === 0; + + const [unfolded, setUnfolded] = useState(false); + + const toggleUnfolded = useCallback(() => { + setUnfolded((prev) => !prev); + }, []); + + return ( + <> + + + Available devices ({discoveredDevices.length}) + + + {unfolded ? ( + + ) : ( + + )} + + + + {unfolded + ? discoveredDevices.map((device) => ( + + )) + : null} + + + ); +}; + +const KnownDevice: React.FC = ( + device, +) => { + const { deviceModel, connected } = device; + const sdk = useSdk(); + const { dispatch } = useDeviceSessionsContext(); + const connectToDevice = useCallback(() => { + sdk.connect({ device }).then((sessionId) => { + dispatch({ + type: "add_session", + payload: { + sessionId, + connectedDevice: sdk.getConnectedDevice({ sessionId }), + }, + }); + }); + }, [sdk, device, dispatch]); + + return ( + + + + ); +}; diff --git a/apps/sample/src/components/Device/index.tsx b/apps/sample/src/components/Device/index.tsx index 53862ae12..d3f529f66 100644 --- a/apps/sample/src/components/Device/index.tsx +++ b/apps/sample/src/components/Device/index.tsx @@ -4,7 +4,14 @@ import { DeviceModelId, DeviceSessionId, } from "@ledgerhq/device-management-kit"; -import { Box, DropdownGeneric, Flex, Icons, Text } from "@ledgerhq/react-ui"; +import { + Box, + Button, + DropdownGeneric, + Flex, + Icons, + Text, +} from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; import { useDeviceSessionState } from "@/hooks/useDeviceSessionState"; @@ -115,3 +122,44 @@ export const Device: React.FC = ({ ); }; + +type AvailableDeviceProps = { + model: DeviceModelId; + name: string; + type: ConnectionType; + connected: boolean; + onConnect: () => void; +}; + +export const AvailableDevice: React.FC = ({ + model, + name, + type, + onConnect, + connected, +}) => { + const IconComponent = getIconComponent(model); + return ( + + + + + + {name} + + + {type} + + + + + + ); +}; diff --git a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx index 948e1f60e..72dad3b02 100644 --- a/apps/sample/src/components/MainView/ConnectDeviceActions.tsx +++ b/apps/sample/src/components/MainView/ConnectDeviceActions.tsx @@ -17,7 +17,6 @@ export const ConnectDeviceActions = ({ onError, }: ConnectDeviceActionsProps) => { const { - dispatch: dispatchSdkConfig, state: { transport }, } = useSdkConfigContext(); const { dispatch: dispatchDeviceSession } = useDeviceSessionsContext(); @@ -26,10 +25,6 @@ export const ConnectDeviceActions = ({ const onSelectDeviceClicked = useCallback( (selectedTransport: BuiltinTransports) => { onError(null); - dispatchSdkConfig({ - type: "set_transport", - payload: { transport: selectedTransport }, - }); sdk.startDiscovering({ transport: selectedTransport }).subscribe({ next: (device) => { sdk @@ -56,9 +51,17 @@ export const ConnectDeviceActions = ({ }, }); }, - [sdk, transport], + [dispatchDeviceSession, onError, sdk], ); + // This implementation gives the impression that working with the mock server + // is a special case, when in fact it's just a transport like the others + // TODO: instead of toggling between mock & regular config, we should + // just have a menu to select the active transports (where the active menu) + // and this here should be a list of one buttons for each active transport + // also we should not have a different appearance when the mock server is enabled + // we should just display the list of active transports somewhere in the sidebar, discreetly + return transport === BuiltinTransports.MOCK_SERVER ? ( onSelectDeviceClicked(BuiltinTransports.MOCK_SERVER)} diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index bce6cd792..1397f75d0 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -42,7 +42,6 @@ export const MainView: React.FC = () => { } }; }, [connectionError]); - return ( { Ledger Device Management Kit {transport === BuiltinTransports.MOCK_SERVER && (MOCKED)} - - SDK Version: {version ? version : "Loading..."} - - Device + + Device sessions ({Object.values(deviceById).length}) +
{Object.entries(deviceById).map(([sessionId, device]) => ( { /> ))}
+ Menu @@ -124,10 +125,9 @@ export const Sidebar: React.FC = () => { > Share logs - - Ledger Device Management Kit version {version} + + Ledger Device Management Kit{"\n"}version {version} - App version 0.1 ); diff --git a/apps/sample/src/hooks/useAvailableDevices.tsx b/apps/sample/src/hooks/useAvailableDevices.tsx new file mode 100644 index 000000000..36ebcddf0 --- /dev/null +++ b/apps/sample/src/hooks/useAvailableDevices.tsx @@ -0,0 +1,45 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { DiscoveredDevice } from "@ledgerhq/device-management-kit"; +import { Subscription } from "rxjs"; + +import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; + +type AvailableDevice = DiscoveredDevice & { connected: boolean }; + +export function useAvailableDevices(): AvailableDevice[] { + const sdk = useSdk(); + const [discoveredDevices, setDiscoveredDevices] = useState< + DiscoveredDevice[] + >([]); + const { state: deviceSessionsState } = useDeviceSessionsContext(); + + const subscription = useRef(null); + useEffect(() => { + if (!subscription.current) { + subscription.current = sdk.listenToKnownDevices().subscribe((devices) => { + setDiscoveredDevices(devices); + }); + } + return () => { + if (subscription.current) { + setDiscoveredDevices([]); + subscription.current.unsubscribe(); + subscription.current = null; + } + }; + }, [sdk]); + + const result = useMemo( + () => + discoveredDevices.map((device) => ({ + ...device, + connected: Object.values(deviceSessionsState.deviceById).some( + (connectedDevice) => connectedDevice.id === device.id, + ), + })), + [discoveredDevices, deviceSessionsState], + ); + + return result; +} diff --git a/apps/sample/src/hooks/useHasChanged.tsx b/apps/sample/src/hooks/useHasChanged.tsx new file mode 100644 index 000000000..e3f90aaf4 --- /dev/null +++ b/apps/sample/src/hooks/useHasChanged.tsx @@ -0,0 +1,13 @@ +import { useRef } from "react"; + +/** + * A custom hook that returns whether the value has changed since the last render. + * @param value The value to compare against the previous render. + * @returns A boolean indicating whether the value has changed since the last render. + */ +export function useHasChanged(value: T): boolean { + const ref = useRef(value); + const hasChanged = ref.current !== value; + ref.current = value; + return hasChanged; +} diff --git a/apps/sample/src/hooks/usePrevious.ts b/apps/sample/src/hooks/usePrevious.ts index d6d1f2155..6b37c7f6a 100644 --- a/apps/sample/src/hooks/usePrevious.ts +++ b/apps/sample/src/hooks/usePrevious.ts @@ -1,9 +1,13 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; +/** + * A custom hook that returns the previous value of the provided value. + * @param value The value to compare against the previous render. + * @returns The previous value of the provided value. + */ export function usePrevious(value: T) { const ref = useRef(); - useEffect(() => { - ref.current = value; //assign the value of ref to the argument - }, [value]); //this code will run when the value of 'value' changes - return ref.current; //in the end, return the current ref value. + const previousValue = ref.current; + ref.current = value; + return previousValue; } diff --git a/apps/sample/src/providers/DeviceSdkProvider/index.tsx b/apps/sample/src/providers/DeviceSdkProvider/index.tsx index cbbf4c7b9..dd3a417cd 100644 --- a/apps/sample/src/providers/DeviceSdkProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSdkProvider/index.tsx @@ -9,62 +9,90 @@ import { } from "@ledgerhq/device-management-kit"; import { FlipperSdkLogger } from "@ledgerhq/device-management-kit-flipper-plugin-client"; -import { usePrevious } from "@/hooks/usePrevious"; +import { useHasChanged } from "@/hooks/useHasChanged"; import { useSdkConfigContext } from "@/providers/SdkConfig"; -const webLogsExporterLogger = new WebLogsExporterLogger(); +const SdkContext = createContext(null); +const LogsExporterContext = createContext(null); -const defaultSdk = new DeviceSdkBuilder() - .addLogger(new ConsoleLogger()) - .addTransport(BuiltinTransports.BLE) - .addTransport(BuiltinTransports.USB) - .addLogger(webLogsExporterLogger) - .addLogger(new FlipperSdkLogger()) - .build(); +function buildDefaultSdk(logsExporter: WebLogsExporterLogger) { + return new DeviceSdkBuilder() + .addTransport(BuiltinTransports.USB) + .addTransport(BuiltinTransports.BLE) + .addLogger(new ConsoleLogger()) + .addLogger(logsExporter) + .addLogger(new FlipperSdkLogger()) + .build(); +} -const SdkContext = createContext(defaultSdk); +function buildMockSdk(url: string, logsExporter: WebLogsExporterLogger) { + return new DeviceSdkBuilder() + .addTransport(BuiltinTransports.MOCK_SERVER) + .addLogger(new ConsoleLogger()) + .addLogger(logsExporter) + .addLogger(new FlipperSdkLogger()) + .addConfig({ mockUrl: url }) + .build(); +} export const SdkProvider: React.FC = ({ children }) => { const { state: { transport, mockServerUrl }, } = useSdkConfigContext(); - const previousTransport = usePrevious(transport); - const [sdk, setSdk] = useState(defaultSdk); + + const mockServerEnabled = transport === BuiltinTransports.MOCK_SERVER; + const [state, setState] = useState(() => { + const logsExporter = new WebLogsExporterLogger(); + const sdk = mockServerEnabled + ? buildMockSdk(mockServerUrl, logsExporter) + : buildDefaultSdk(logsExporter); + return { sdk, logsExporter }; + }); + + const mockServerEnabledChanged = useHasChanged(mockServerEnabled); + const mockServerUrlChanged = useHasChanged(mockServerUrl); + + if (mockServerEnabledChanged || mockServerUrlChanged) { + setState(({ logsExporter }) => { + return { + sdk: mockServerEnabled + ? buildMockSdk(mockServerUrl, logsExporter) + : buildDefaultSdk(logsExporter), + logsExporter, + }; + }); + } + useEffect(() => { - if (transport === BuiltinTransports.MOCK_SERVER) { - sdk.close(); - setSdk( - new DeviceSdkBuilder() - .addLogger(new ConsoleLogger()) - .addTransport(BuiltinTransports.MOCK_SERVER) - .addConfig({ mockUrl: mockServerUrl }) - .addLogger(webLogsExporterLogger) - .addLogger(new FlipperSdkLogger()) - .build(), - ); - } else if (previousTransport === BuiltinTransports.MOCK_SERVER) { - sdk.close(); - setSdk( - new DeviceSdkBuilder() - .addLogger(new ConsoleLogger()) - .addTransport(BuiltinTransports.BLE) - .addTransport(BuiltinTransports.USB) - .addLogger(webLogsExporterLogger) - .addLogger(new FlipperSdkLogger()) - .build(), - ); - } - }, [transport, mockServerUrl, previousTransport]); + return () => { + state.sdk.close(); + }; + }, [state.sdk]); - return {children}; + return ( + + + {children} + + + ); }; export const useSdk = (): DeviceSdk => { - return useContext(SdkContext); + const sdk = useContext(SdkContext); + if (sdk === null) + throw new Error("useSdk must be used within a SdkContext.Provider"); + return sdk; }; export function useExportLogsCallback() { + const logsExporter = useContext(LogsExporterContext); + if (logsExporter === null) { + throw new Error( + "useExportLogsCallback must be used within LogsExporterContext.Provider", + ); + } return useCallback(() => { - webLogsExporterLogger.exportLogsToJSON(); - }, []); + logsExporter.exportLogsToJSON(); + }, [logsExporter]); } diff --git a/apps/sample/src/providers/DeviceSessionsProvider/index.tsx b/apps/sample/src/providers/DeviceSessionsProvider/index.tsx index f87a84c91..441e01b0a 100644 --- a/apps/sample/src/providers/DeviceSessionsProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSessionsProvider/index.tsx @@ -1,15 +1,23 @@ -import React, { Context, createContext, useContext, useReducer } from "react"; +import React, { + Context, + createContext, + useContext, + useEffect, + useReducer, +} from "react"; +import { useHasChanged } from "@/hooks/useHasChanged"; +import { useSdk } from "@/providers/DeviceSdkProvider"; import { + DeviceSessionsAction, DeviceSessionsInitialState, deviceSessionsReducer, DeviceSessionsState, - DeviseSessionsAction, } from "@/reducers/deviceSessions"; type DeviceSessionsContextType = { state: DeviceSessionsState; - dispatch: (value: DeviseSessionsAction) => void; + dispatch: (value: DeviceSessionsAction) => void; }; const DeviceSessionsContext: Context = @@ -21,11 +29,29 @@ const DeviceSessionsContext: Context = export const DeviceSessionsProvider: React.FC = ({ children, }) => { + const sdk = useSdk(); const [state, dispatch] = useReducer( deviceSessionsReducer, DeviceSessionsInitialState, ); + const sdkHasChanged = useHasChanged(sdk); + if (sdkHasChanged) { + dispatch({ type: "remove_all_sessions" }); + } + + useEffect(() => { + sdk.listDeviceSessions().map((session) => { + dispatch({ + type: "add_session", + payload: { + sessionId: session.id, + connectedDevice: sdk.getConnectedDevice({ sessionId: session.id }), + }, + }); + }); + }, [sdk]); + return ( {children} diff --git a/apps/sample/src/reducers/deviceSessions.ts b/apps/sample/src/reducers/deviceSessions.ts index dd969e9de..4e5584ac1 100644 --- a/apps/sample/src/reducers/deviceSessions.ts +++ b/apps/sample/src/reducers/deviceSessions.ts @@ -19,10 +19,15 @@ type RemoveSessionAction = { payload: { sessionId: DeviceSessionId }; }; -export type DeviseSessionsAction = +type RemoveAllSessionsAction = { + type: "remove_all_sessions"; +}; + +export type DeviceSessionsAction = | AddSessionAction | RemoveSessionAction - | SelectSessionAction; + | SelectSessionAction + | RemoveAllSessionsAction; export type SelectSessionAction = { type: "select_session"; @@ -36,7 +41,7 @@ export const DeviceSessionsInitialState: DeviceSessionsState = { export const deviceSessionsReducer: Reducer< DeviceSessionsState, - DeviseSessionsAction + DeviceSessionsAction > = (state, action) => { const sessionsCount = Object.keys(state.deviceById).length; @@ -60,6 +65,9 @@ export const deviceSessionsReducer: Reducer< ? Object.keys(state.deviceById)[sessionsCount - 1] : undefined, }; + case "remove_all_sessions": + return DeviceSessionsInitialState; + case "select_session": return { ...state, diff --git a/apps/sample/src/styles/globalstyles.tsx b/apps/sample/src/styles/globalstyles.tsx index 487e64123..efa81d313 100644 --- a/apps/sample/src/styles/globalstyles.tsx +++ b/apps/sample/src/styles/globalstyles.tsx @@ -11,4 +11,7 @@ export const GlobalStyle = createGlobalStyle` margin: 0; background-color: #000000; } + body { + user-select: none; + } `; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6f0e3c89..025c48ed6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: react-lottie: specifier: ^1.2.4 version: 1.2.4(react@18.3.1) + rxjs: + specifier: ^7.8.1 + version: 7.8.1 styled-components: specifier: ^5.3.11 version: 5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1) From bd19f5c27f5a74dc9d58bd25fb021a260ff5e602 Mon Sep 17 00:00:00 2001 From: Olivier Freyssinet Date: Thu, 17 Oct 2024 14:30:34 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=96=20(repo):=20Changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/twelve-snakes-agree.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/twelve-snakes-agree.md diff --git a/.changeset/twelve-snakes-agree.md b/.changeset/twelve-snakes-agree.md new file mode 100644 index 000000000..06a1cefc3 --- /dev/null +++ b/.changeset/twelve-snakes-agree.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/device-management-kit": patch +"@ledgerhq/device-sdk-sample": patch +--- + +New use case listenToKnownDevices