diff --git a/apps/sample/src/app/client-layout.tsx b/apps/sample/src/app/client-layout.tsx index 57c527e5c..2b20fb6f0 100644 --- a/apps/sample/src/app/client-layout.tsx +++ b/apps/sample/src/app/client-layout.tsx @@ -10,12 +10,13 @@ "use client"; import React from "react"; -import { Flex, Icons, StyleProvider } from "@ledgerhq/react-ui"; +import { Flex, StyleProvider } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; import { Header } from "@/components/Header"; import { Sidebar } from "@/components/Sidebar"; import { SdkProvider } from "@/providers/DeviceSdkProvider"; +import { SessionProvider } from "@/providers/SessionsProvider"; import { GlobalStyle } from "@/styles/globalstyles"; type ClientRootLayoutProps = { @@ -44,11 +45,13 @@ const ClientRootLayout: React.FC = ({ children }) => { - - -
- {children} - + + + +
+ {children} + + diff --git a/apps/sample/src/components/ApduView/index.tsx b/apps/sample/src/components/ApduView/index.tsx index 02fd33e70..11f19a4fc 100644 --- a/apps/sample/src/components/ApduView/index.tsx +++ b/apps/sample/src/components/ApduView/index.tsx @@ -1,7 +1,10 @@ +import React, { useCallback, useState } from "react"; import { Button, Divider, Flex, Grid, Input, Text } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; import { useApduForm } from "@/hooks/useApduForm"; +import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useSessionContext } from "@/providers/SessionsProvider"; const Root = styled(Flex).attrs({ mx: 15, mt: 10, mb: 5 })` flex-direction: column; @@ -54,7 +57,27 @@ const InputContainer = styled(Flex).attrs({ mx: 8, mb: 4 })` const inputContainerProps = { style: { borderRadius: 4 } }; export const ApduView: React.FC = () => { - const { apduFormValues, setApduFormValue, apdu } = useApduForm(); + const { apduFormValues, setApduFormValue, getRawApdu } = useApduForm(); + const [loading, setLoading] = useState(false); + const sdk = useSdk(); + const { + state: { selectedId: selectedSessionId }, + } = useSessionContext(); + const onSubmit = useCallback( + async (values: typeof apduFormValues) => { + setLoading(true); + try { + await sdk.sendApdu({ + sessionId: selectedSessionId!, + apdu: getRawApdu(values), + }); + setLoading(false); + } catch (error) { + setLoading(false); + } + }, + [getRawApdu, sdk, selectedSessionId], + ); return ( @@ -69,11 +92,11 @@ export const ApduView: React.FC = () => { Class instruction - setApduFormValue("classInstruction", value) + setApduFormValue("instructionClass", value) } /> @@ -118,6 +141,7 @@ export const ApduView: React.FC = () => { setApduFormValue("data", value)} @@ -129,6 +153,7 @@ export const ApduView: React.FC = () => { setApduFormValue("dataLength", value)} @@ -138,7 +163,10 @@ export const ApduView: React.FC = () => { - console.log(apdu)}> + onSubmit(apduFormValues)} + disabled={loading} + > Send APDU diff --git a/apps/sample/src/components/Device/index.tsx b/apps/sample/src/components/Device/index.tsx index 300e65e43..a0fa4ab1b 100644 --- a/apps/sample/src/components/Device/index.tsx +++ b/apps/sample/src/components/Device/index.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { ConnectionType, DeviceModelId } from "@ledgerhq/device-sdk-core"; import { Box, Flex, Icons, Text } from "@ledgerhq/react-ui"; import styled, { DefaultTheme } from "styled-components"; @@ -25,38 +26,24 @@ export enum DeviceStatus { NOT_CONNECTED = "Not Connected", } -export enum DeviceType { - USB = "USB", - BLE = "BLE", -} - -export enum DeviceModel { - STAX = "Stax", - LNX = "LNX", -} - // These props are subject to change. type DeviceProps = { name: string; - type: DeviceType; - model: DeviceModel; + type: ConnectionType; + model: DeviceModelId; status?: DeviceStatus; }; export const Device: React.FC = ({ name, - status, + status = DeviceStatus.AVAILABLE, type, model, }) => { return ( - {model === DeviceModel.STAX ? ( - - ) : ( - - )} + {model === "stax" ? : } {name} diff --git a/apps/sample/src/components/MainView/index.tsx b/apps/sample/src/components/MainView/index.tsx index b804eafef..572f9b3d0 100644 --- a/apps/sample/src/components/MainView/index.tsx +++ b/apps/sample/src/components/MainView/index.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import styled, { DefaultTheme } from "styled-components"; import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useSessionContext } from "@/providers/SessionsProvider"; const Root = styled(Flex)` flex: 1; @@ -23,6 +24,7 @@ const NanoLogo = styled(Image).attrs({ mb: 8 })` export const MainView: React.FC = () => { const sdk = useSdk(); + const { dispatch } = useSessionContext(); const [discoveredDevice, setDiscoveredDevice] = useState(null); @@ -50,10 +52,17 @@ export const MainView: React.FC = () => { if (discoveredDevice) { sdk .connect({ deviceId: discoveredDevice.id }) - .then((connectedDevice) => { + .then((sessionId) => { console.log( - `🦖 Response from connect: ${JSON.stringify(connectedDevice)} 🎉`, + `🦖 Response from connect: ${JSON.stringify(sessionId)} 🎉`, ); + dispatch({ + type: "add_session", + payload: { + sessionId, + connectedDevice: sdk.getConnectedDevice({ sessionId }), + }, + }); }) .catch((error) => { console.error(`Error from connection or get-version`, error); diff --git a/apps/sample/src/components/Sidebar/index.tsx b/apps/sample/src/components/Sidebar/index.tsx index ab8568b10..f8d9edf33 100644 --- a/apps/sample/src/components/Sidebar/index.tsx +++ b/apps/sample/src/components/Sidebar/index.tsx @@ -4,14 +4,10 @@ import { Box, Flex, Icons, Link, Text } from "@ledgerhq/react-ui"; import { useRouter } from "next/navigation"; import styled, { DefaultTheme } from "styled-components"; -import { - Device, - DeviceModel, - DeviceStatus, - DeviceType, -} from "@/components/Device"; +import { Device } from "@/components/Device"; import { Menu } from "@/components/Menu"; import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useSessionContext } from "@/providers/SessionsProvider"; const Root = styled(Flex).attrs({ py: 8, px: 6 })` flex-direction: column; @@ -47,6 +43,9 @@ const VersionText = styled(Text)` export const Sidebar: React.FC = () => { const [version, setVersion] = useState(""); const sdk = useSdk(); + const { + state: { deviceById }, + } = useSessionContext(); useEffect(() => { sdk @@ -76,12 +75,15 @@ export const Sidebar: React.FC = () => { Device - + + {Object.entries(deviceById).map(([sessionId, device]) => ( + + ))} Menu diff --git a/apps/sample/src/hooks/useApduForm.ts b/apps/sample/src/hooks/useApduForm.ts index deee35fc2..116cc1fd1 100644 --- a/apps/sample/src/hooks/useApduForm.ts +++ b/apps/sample/src/hooks/useApduForm.ts @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; type ApduFormValues = { - classInstruction: string; + instructionClass: string; instructionMethod: string; firstParameter: string; secondParameter: string; @@ -11,36 +11,42 @@ type ApduFormValues = { export function useApduForm() { const [values, setValues] = useState({ - classInstruction: "", - instructionMethod: "", - firstParameter: "", - secondParameter: "", + instructionClass: "e0", + instructionMethod: "01", + firstParameter: "00", + secondParameter: "00", + dataLength: "00", data: "", - dataLength: "", }); - const [apdu, setApdu] = useState(Uint8Array.from([])); const setValue = useCallback((field: keyof ApduFormValues, value: string) => { - setValues((prev) => ({ ...prev, [field]: value })); + const newValues = { [field]: value }; + if (field === "data") { + newValues.dataLength = Math.floor(value.length / 2).toString(16); + } + setValues((prev) => ({ ...prev, ...newValues })); }, []); - useEffect(() => { - const newApdu = Object.values(values).reduce( - (acc, curr) => [ - ...acc, - ...chunkString(curr.replace(/\s/g, "")) - .map((char) => Number(`0x${char}`)) - .filter((nbr) => !Number.isNaN(nbr)), - ], - [] as number[], - ); - setApdu(Uint8Array.from(newApdu)); - }, [values]); + const getRawApdu = useCallback( + (formValues: ApduFormValues): Uint8Array => + new Uint8Array( + Object.values(formValues).reduce( + (acc, curr) => [ + ...acc, + ...chunkString(curr.replace(/\s/g, "")) + .map((char) => Number(`0x${char}`)) + .filter((nbr) => !Number.isNaN(nbr)), + ], + [] as number[], + ), + ), + [], + ); return { apduFormValues: values, setApduFormValue: setValue, - apdu, + getRawApdu, }; } diff --git a/apps/sample/src/providers/DeviceSdkProvider/index.tsx b/apps/sample/src/providers/DeviceSdkProvider/index.tsx index 3c3d4728c..6f93b833c 100644 --- a/apps/sample/src/providers/DeviceSdkProvider/index.tsx +++ b/apps/sample/src/providers/DeviceSdkProvider/index.tsx @@ -19,6 +19,6 @@ export const SdkProvider: React.FC = ({ children }) => { return {children}; }; -export const useSdk = () => { +export const useSdk = (): DeviceSdk => { return useContext(SdkContext); }; diff --git a/apps/sample/src/providers/SessionsProvider/index.tsx b/apps/sample/src/providers/SessionsProvider/index.tsx new file mode 100644 index 000000000..7caeca890 --- /dev/null +++ b/apps/sample/src/providers/SessionsProvider/index.tsx @@ -0,0 +1,34 @@ +import { Context, createContext, useContext, useReducer } from "react"; + +import { + AddSessionAction, + SessionsInitialState, + sessionsReducer, + SessionsState, +} from "@/reducers/sessions"; + +type SessionContextType = { + state: SessionsState; + dispatch: (value: AddSessionAction) => void; +}; + +const SessionContext: Context = + createContext({ + state: SessionsInitialState, + dispatch: (_value: AddSessionAction) => {}, + }); + +export const SessionProvider: React.FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(sessionsReducer, SessionsInitialState); + + return ( + + {children} + + ); +}; + +export const useSessionContext = () => + useContext(SessionContext); diff --git a/apps/sample/src/reducers/sessions.ts b/apps/sample/src/reducers/sessions.ts new file mode 100644 index 000000000..6ade0ec22 --- /dev/null +++ b/apps/sample/src/reducers/sessions.ts @@ -0,0 +1,36 @@ +import { Reducer } from "react"; +import { ConnectedDevice, SessionId } from "@ledgerhq/device-sdk-core"; + +export type SessionsState = { + selectedId: SessionId | null; + deviceById: Record; +}; + +export type AddSessionAction = { + type: "add_session"; + payload: { sessionId: SessionId; connectedDevice: ConnectedDevice }; +}; + +export const SessionsInitialState: SessionsState = { + selectedId: null, + deviceById: {}, +}; + +export const sessionsReducer: Reducer = ( + state, + action, +) => { + switch (action.type) { + case "add_session": + return { + ...state, + selectedId: action.payload.sessionId, + deviceById: { + ...state.deviceById, + [action.payload.sessionId]: action.payload.connectedDevice, + }, + }; + default: + return state; + } +}; diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index b0a9e8ea5..a7ace6ba3 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -1,8 +1,11 @@ import { Container } from "inversify"; import { Observable } from "rxjs"; +import { ConnectedDevice } from "@api/usb/model/ConnectedDevice"; import { configTypes } from "@internal/config/di/configTypes"; import { GetSdkVersionUseCase } from "@internal/config/use-case/GetSdkVersionUseCase"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; +import { SessionId } from "@internal/device-session/model/Session"; import { discoveryTypes } from "@internal/discovery/di/discoveryTypes"; import { ConnectUseCase, @@ -14,8 +17,13 @@ import { sendTypes } from "@internal/send/di/sendTypes"; import { SendApduUseCase, SendApduUseCaseArgs, -} from "@internal/send/usecase/SendApduUseCase"; +} from "@internal/send/use-case/SendApduUseCase"; +import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; +import { + GetConnectedDeviceUseCase, + GetConnectedDeviceUseCaseArgs, +} from "@internal/usb/use-case/GetConnectedDeviceUseCase"; import { makeContainer, MakeContainerProps } from "@root/src/di"; export class DeviceSdk { @@ -54,15 +62,21 @@ export class DeviceSdk { .execute(); } - connect(args: ConnectUseCaseArgs) { + connect(args: ConnectUseCaseArgs): Promise { return this.container .get(discoveryTypes.ConnectUseCase) .execute(args); } - sendApdu(args: SendApduUseCaseArgs) { + sendApdu(args: SendApduUseCaseArgs): Promise { + return this.container + .get(sendTypes.SendApduUseCase) + .execute(args); + } + + getConnectedDevice(args: GetConnectedDeviceUseCaseArgs): ConnectedDevice { return this.container - .get(sendTypes.SendService) + .get(usbDiTypes.GetConnectedDeviceUseCase) .execute(args); } } diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 8e28b19dc..63ac9f71b 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -3,8 +3,6 @@ export { DeviceSdk } from "./DeviceSdk"; export { LedgerDeviceSdkBuilder as DeviceSdkBuilder } from "./DeviceSdkBuilder"; export { LogLevel } from "./logger-subscriber/model/LogLevel"; -export type { LogSubscriberOptions } from "./logger-subscriber/model/LogSubscriberOptions"; export { ConsoleLogger } from "./logger-subscriber/service/ConsoleLogger"; - -// [SHOULD] be exported from another file -export type { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; +export * from "./types"; +export { ConnectedDevice } from "./usb/model/ConnectedDevice"; diff --git a/packages/core/src/api/logger-subscriber/service/ConsoleLogger.test.ts b/packages/core/src/api/logger-subscriber/service/ConsoleLogger.test.ts index 8cd7f5fcc..cfb1a38b0 100644 --- a/packages/core/src/api/logger-subscriber/service/ConsoleLogger.test.ts +++ b/packages/core/src/api/logger-subscriber/service/ConsoleLogger.test.ts @@ -30,33 +30,53 @@ describe("ConsoleLogger", () => { it("should log Info level", () => { logger.log(LogLevel.Info, message, options); - expect(info).toHaveBeenCalledWith(`[${options.tag}]`, message); + expect(info).toHaveBeenCalledWith( + `[${options.tag}]`, + message, + options.data, + ); }); it("should log Info level with a custom tag", () => { const tag = "custom-tag"; logger.log(LogLevel.Info, message, { ...options, tag }); - expect(info).toHaveBeenCalledWith(`[${tag}]`, message); + expect(info).toHaveBeenCalledWith(`[${tag}]`, message, options.data); }); it("should log Warn level", () => { logger.log(LogLevel.Warning, message, options); - expect(warn).toHaveBeenCalledWith(`[${options.tag}]`, message); + expect(warn).toHaveBeenCalledWith( + `[${options.tag}]`, + message, + options.data, + ); }); it("should log Debug level", () => { logger.log(LogLevel.Debug, message, options); - expect(debug).toHaveBeenCalledWith(`[${options.tag}]`, message); + expect(debug).toHaveBeenCalledWith( + `[${options.tag}]`, + message, + options.data, + ); }); it("should default to Log level if none present", () => { logger.log(LogLevel.Fatal, message, options); - expect(log).toHaveBeenCalledWith(`[${options.tag}]`, message); + expect(log).toHaveBeenCalledWith( + `[${options.tag}]`, + message, + options.data, + ); }); it("should log Error level", () => { logger.log(LogLevel.Error, message, options); - expect(error).toHaveBeenCalledWith(`[${options.tag}]`, message); + expect(error).toHaveBeenCalledWith( + `[${options.tag}]`, + message, + options.data, + ); }); }); }); diff --git a/packages/core/src/api/logger-subscriber/service/ConsoleLogger.ts b/packages/core/src/api/logger-subscriber/service/ConsoleLogger.ts index 046e88022..2039c5839 100644 --- a/packages/core/src/api/logger-subscriber/service/ConsoleLogger.ts +++ b/packages/core/src/api/logger-subscriber/service/ConsoleLogger.ts @@ -8,20 +8,20 @@ export class ConsoleLogger implements LoggerSubscriberService { switch (level) { case LogLevel.Info: - console.info(tag, message); + console.info(tag, message, options.data); break; case LogLevel.Warning: - console.warn(tag, message); + console.warn(tag, message, options.data); break; case LogLevel.Debug: - console.debug(tag, message); + console.debug(tag, message, options.data); break; case LogLevel.Error: { - console.error(tag, message); + console.error(tag, message, options.data); break; } default: - console.log(tag, message); + console.log(tag, message, options.data); } } } diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts new file mode 100644 index 000000000..f6767eee7 --- /dev/null +++ b/packages/core/src/api/types.ts @@ -0,0 +1,9 @@ +export type { LogSubscriberOptions } from "./logger-subscriber/model/LogSubscriberOptions"; +export type { + DeviceId, + DeviceModel, + DeviceModelId, +} from "@internal/device-model/model/DeviceModel"; +export type { SessionId } from "@internal/device-session/model/Session"; +export type { ConnectionType } from "@internal/discovery/model/ConnectionType"; +export type { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; diff --git a/packages/core/src/api/usb/model/ConnectedDevice.ts b/packages/core/src/api/usb/model/ConnectedDevice.ts new file mode 100644 index 000000000..b358e86c3 --- /dev/null +++ b/packages/core/src/api/usb/model/ConnectedDevice.ts @@ -0,0 +1,30 @@ +import { + DeviceId, + DeviceModelId, +} from "@internal/device-model/model/DeviceModel"; +import { ConnectionType } from "@internal/discovery/model/ConnectionType"; +import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; + +type ConnectedDeviceConstructorArgs = { + internalConnectedDevice: InternalConnectedDevice; +}; + +export class ConnectedDevice { + public readonly id: DeviceId; + public readonly modelId: DeviceModelId; + public readonly name: string; + public readonly type: ConnectionType; + + constructor({ + internalConnectedDevice: { + id, + deviceModel: { id: deviceModelId, productName: deviceName }, + type, + }, + }: ConnectedDeviceConstructorArgs) { + this.id = id; + this.modelId = deviceModelId; + this.name = deviceName; + this.type = type; + } +} diff --git a/packages/core/src/internal/device-session/di/deviceSessionModule.ts b/packages/core/src/internal/device-session/di/deviceSessionModule.ts index 124150c80..fa4a07031 100644 --- a/packages/core/src/internal/device-session/di/deviceSessionModule.ts +++ b/packages/core/src/internal/device-session/di/deviceSessionModule.ts @@ -1,8 +1,19 @@ -import { ContainerModule } from "inversify"; +import { ContainerModule, interfaces } from "inversify"; import { Session } from "@internal/device-session/model/Session"; -import { DefaultFramerService } from "@internal/device-session/service/DefaultFramerService"; +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { + DefaultApduReceiverConstructorArgs, + DefaultApduReceiverService, +} from "@internal/device-session/service/DefaultApduReceiverService"; +import { + DefaultApduSenderService, + DefaultApduSenderServiceConstructorArgs, +} from "@internal/device-session/service/DefaultApduSenderService"; import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { deviceSessionTypes } from "./deviceSessionTypes"; @@ -22,7 +33,32 @@ export const deviceSessionModuleFactory = () => _onActivation, _onDeactivation, ) => { - bind(deviceSessionTypes.FramerService).to(DefaultFramerService); - bind(deviceSessionTypes.SessionService).to(DefaultSessionService); + bind>( + deviceSessionTypes.ApduSenderServiceFactory, + ).toFactory((context) => { + const logger = context.container.get< + (name: string) => LoggerPublisherService + >(loggerTypes.LoggerPublisherServiceFactory); + + return (args: DefaultApduSenderServiceConstructorArgs) => { + return new DefaultApduSenderService(args, logger); + }; + }); + + bind>( + deviceSessionTypes.ApduReceiverServiceFactory, + ).toFactory((context) => { + const logger = context.container.get< + (name: string) => LoggerPublisherService + >(loggerTypes.LoggerPublisherServiceFactory); + + return (args: DefaultApduReceiverConstructorArgs) => { + return new DefaultApduReceiverService(args, logger); + }; + }); + + bind(deviceSessionTypes.SessionService) + .to(DefaultSessionService) + .inSingletonScope(); }, ); diff --git a/packages/core/src/internal/device-session/di/deviceSessionTypes.ts b/packages/core/src/internal/device-session/di/deviceSessionTypes.ts index 9162bd182..44b9da359 100644 --- a/packages/core/src/internal/device-session/di/deviceSessionTypes.ts +++ b/packages/core/src/internal/device-session/di/deviceSessionTypes.ts @@ -1,4 +1,5 @@ export const deviceSessionTypes = { - FramerService: Symbol.for("FramerService"), + ApduSenderServiceFactory: Symbol.for("ApduSenderServiceFactory"), + ApduReceiverServiceFactory: Symbol.for("ApduReceiverServiceFactory"), SessionService: Symbol.for("SessionService"), }; diff --git a/packages/core/src/internal/device-session/model/ApduResponse.stub.ts b/packages/core/src/internal/device-session/model/ApduResponse.stub.ts new file mode 100644 index 000000000..2b22a8502 --- /dev/null +++ b/packages/core/src/internal/device-session/model/ApduResponse.stub.ts @@ -0,0 +1,10 @@ +import { ApduResponse, ApduResponseConstructorArgs } from "./ApduResponse"; + +type ApduResponseStub = ( + props?: Partial, +) => ApduResponse; + +export const defaultApduResponseStubBuilder: ApduResponseStub = ({ + statusCode = Uint8Array.from([0x90, 0x00]), + data = Uint8Array.from([]), +} = {}) => new ApduResponse({ statusCode, data }); diff --git a/packages/core/src/internal/device-session/model/ApduResponse.ts b/packages/core/src/internal/device-session/model/ApduResponse.ts index 63d617ff0..6f05fcaec 100644 --- a/packages/core/src/internal/device-session/model/ApduResponse.ts +++ b/packages/core/src/internal/device-session/model/ApduResponse.ts @@ -1,4 +1,4 @@ -type ApduResponseConstructorArgs = { +export type ApduResponseConstructorArgs = { statusCode: Uint8Array; data: Uint8Array; }; @@ -11,12 +11,4 @@ export class ApduResponse { this._statusCode = statusCode; this._data = data; } - - public getStatusCode() { - return this._statusCode; - } - - public getData() { - return this._data; - } } diff --git a/packages/core/src/internal/device-session/model/Session.stub.ts b/packages/core/src/internal/device-session/model/Session.stub.ts new file mode 100644 index 000000000..66821ab55 --- /dev/null +++ b/packages/core/src/internal/device-session/model/Session.stub.ts @@ -0,0 +1,14 @@ +import { + Session, + SessionConstructorArgs, +} from "@internal/device-session/model/Session"; +import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; + +export const sessionStubBuilder = ( + props: Partial = {}, +) => + new Session({ + connectedDevice: connectedDeviceStubBuilder(), + id: "fakeSessionId", + ...props, + }); diff --git a/packages/core/src/internal/device-session/model/Session.ts b/packages/core/src/internal/device-session/model/Session.ts index c116c47ad..a73233195 100644 --- a/packages/core/src/internal/device-session/model/Session.ts +++ b/packages/core/src/internal/device-session/model/Session.ts @@ -1,20 +1,35 @@ -import { Either } from "purify-ts"; import { v4 as uuidv4 } from "uuid"; +import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; + export type SessionId = ReturnType; +export type SessionConstructorArgs = { + connectedDevice: InternalConnectedDevice; + id?: SessionId; +}; + /** * Represents a session with a device. */ -// [TODO] replace this code with actual implementation export class Session { - id: SessionId; + private readonly _id: SessionId; + private readonly _connectedDevice: InternalConnectedDevice; + + constructor({ connectedDevice, id = uuidv4() }: SessionConstructorArgs) { + this._id = id; + this._connectedDevice = connectedDevice; + } + + public get id() { + return this._id; + } - constructor() { - this.id = uuidv4(); + public get connectedDevice() { + return this._connectedDevice; } - sendApdu(_args: Uint8Array): Promise> { - return Promise.resolve(Either.of("yolo")); + sendApdu(_args: Uint8Array) { + return this._connectedDevice.sendApdu(_args); } } diff --git a/packages/core/src/internal/device-session/service/ApduReceiverService.ts b/packages/core/src/internal/device-session/service/ApduReceiverService.ts index 42ba0d86e..a8407bc21 100644 --- a/packages/core/src/internal/device-session/service/ApduReceiverService.ts +++ b/packages/core/src/internal/device-session/service/ApduReceiverService.ts @@ -1,8 +1,8 @@ import { Either, Maybe } from "purify-ts"; +import { SdkError } from "@api/Error"; import { ApduResponse } from "@internal/device-session/model/ApduResponse"; -import { ReceiverApduError } from "@internal/device-session/model/Errors"; export interface ApduReceiverService { - handleFrame(apdu: Uint8Array): Either>; + handleFrame(apdu: Uint8Array): Either>; } diff --git a/packages/core/src/internal/device-session/service/FramerService.ts b/packages/core/src/internal/device-session/service/ApduSenderService.ts similarity index 74% rename from packages/core/src/internal/device-session/service/FramerService.ts rename to packages/core/src/internal/device-session/service/ApduSenderService.ts index dc295ae3b..1578c0520 100644 --- a/packages/core/src/internal/device-session/service/FramerService.ts +++ b/packages/core/src/internal/device-session/service/ApduSenderService.ts @@ -1,5 +1,5 @@ import { Frame } from "@internal/device-session/model/Frame"; -export interface FramerService { +export interface ApduSenderService { getFrames: (apdu: Uint8Array) => Frame[]; } diff --git a/packages/core/src/internal/device-session/service/DefaultApduReceiverService.stub.ts b/packages/core/src/internal/device-session/service/DefaultApduReceiverService.stub.ts new file mode 100644 index 000000000..547fddae6 --- /dev/null +++ b/packages/core/src/internal/device-session/service/DefaultApduReceiverService.stub.ts @@ -0,0 +1,20 @@ +import { Maybe } from "purify-ts"; + +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { + DefaultApduReceiverConstructorArgs, + DefaultApduReceiverService, +} from "@internal/device-session/service/DefaultApduReceiverService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export const defaultApduReceiverServiceStubBuilder = ( + props: Partial = {}, + loggerFactory: (tag: string) => LoggerPublisherService, +): ApduReceiverService => + new DefaultApduReceiverService( + { + channel: Maybe.of(new Uint8Array([0x12, 0x34])), + ...props, + }, + loggerFactory, + ); diff --git a/packages/core/src/internal/device-session/service/DefaultApduReceiverService.ts b/packages/core/src/internal/device-session/service/DefaultApduReceiverService.ts index 8cd95ead5..09323a318 100644 --- a/packages/core/src/internal/device-session/service/DefaultApduReceiverService.ts +++ b/packages/core/src/internal/device-session/service/DefaultApduReceiverService.ts @@ -19,23 +19,23 @@ import { LoggerPublisherService } from "@internal/logger-publisher/service/Logge import { ApduReceiverService } from "./ApduReceiverService"; -type DefaultReceiverConstructorArgs = { +export type DefaultApduReceiverConstructorArgs = { channel?: Maybe; }; @injectable() export class DefaultApduReceiverService implements ApduReceiverService { - private _channel: Maybe; - private _logger: LoggerPublisherService; + private readonly _channel: Maybe; + private readonly _logger: LoggerPublisherService; private _pendingFrames: Frame[]; constructor( - { channel = Maybe.zero() }: DefaultReceiverConstructorArgs, + { channel = Maybe.zero() }: DefaultApduReceiverConstructorArgs, @inject(loggerTypes.LoggerPublisherServiceFactory) loggerModuleFactory: (tag: string) => LoggerPublisherService, ) { this._channel = channel; - this._logger = loggerModuleFactory("receiver"); + this._logger = loggerModuleFactory("ApduReceiverService"); this._pendingFrames = []; } @@ -48,15 +48,17 @@ export class DefaultApduReceiverService implements ApduReceiverService { * @param Uint8Array */ public handleFrame( - apdu: Uint8Array, + frameBytes: Uint8Array, ): Either> { - const frame = this.apduToFrame(apdu); + const frame = this.getFrameFromBytes(frameBytes); return frame.map((value) => { - this._logger.debug("handle frame", { data: { frame } }); + this._logger.info("handle frame", { + data: { frame: value.getRawData() }, + }); this._pendingFrames.push(value); - const dataSize = this._pendingFrames[0]!.getHeader().getDataLength(); + const dataSize = this._pendingFrames.at(0)!.getHeader().getDataLength(); return this.getCompleteFrame(dataSize); }); } @@ -105,14 +107,16 @@ export class DefaultApduReceiverService implements ApduReceiverService { * * @param Uint8Array */ - private apduToFrame(apdu: Uint8Array): Either { + private getFrameFromBytes( + rawFrame: Uint8Array, + ): Either { const channelSize = this._channel.caseOf({ Just: () => CHANNEL_LENGTH, Nothing: () => 0, }); - const headTag = apdu.slice(channelSize, channelSize + HEAD_TAG_LENGTH); - const index = apdu.slice( + const headTag = rawFrame.slice(channelSize, channelSize + HEAD_TAG_LENGTH); + const index = rawFrame.slice( channelSize + HEAD_TAG_LENGTH, channelSize + HEAD_TAG_LENGTH + INDEX_LENGTH, ); @@ -127,18 +131,18 @@ export class DefaultApduReceiverService implements ApduReceiverService { const dataSizeLength = isFirstIndex ? APDU_DATA_LENGTH_LENGTH : 0; if ( - apdu.length < + rawFrame.length < channelSize + HEAD_TAG_LENGTH + INDEX_LENGTH + dataSizeLength ) { return Left(new ReceiverApduError("Unable to parse header from apdu")); } const dataSize = isFirstIndex - ? Just(apdu.slice(dataSizeIndex, dataSizeIndex + dataSizeLength)) + ? Just(rawFrame.slice(dataSizeIndex, dataSizeIndex + dataSizeLength)) : Nothing; const dataIndex = dataSizeIndex + dataSizeLength; - const data = apdu.slice(dataIndex); + const data = rawFrame.slice(dataIndex); const frame = new Frame({ header: new FrameHeader({ @@ -162,7 +166,7 @@ export class DefaultApduReceiverService implements ApduReceiverService { */ private isComplete(dataSize: number): boolean { const totalReceiveLength = this._pendingFrames.reduce( - (prev: number, curr: Frame) => prev + curr.getData().length, + (prev, curr) => prev + curr.getData().length, 0, ); diff --git a/packages/core/src/internal/device-session/service/DefaultApduSenderService.stub.ts b/packages/core/src/internal/device-session/service/DefaultApduSenderService.stub.ts new file mode 100644 index 000000000..207e16058 --- /dev/null +++ b/packages/core/src/internal/device-session/service/DefaultApduSenderService.stub.ts @@ -0,0 +1,22 @@ +import { Maybe } from "purify-ts"; + +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { + DefaultApduSenderService, + DefaultApduSenderServiceConstructorArgs, +} from "@internal/device-session/service/DefaultApduSenderService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export const defaultApduSenderServiceStubBuilder = ( + props: Partial = {}, + loggerFactory: (tag: string) => LoggerPublisherService, +): ApduSenderService => + new DefaultApduSenderService( + { + frameSize: 64, + channel: Maybe.of(new Uint8Array([0x12, 0x34])), + padding: true, + ...props, + }, + loggerFactory, + ); diff --git a/packages/core/src/internal/device-session/service/DefaultFramerService.test.ts b/packages/core/src/internal/device-session/service/DefaultApduSenderService.test.ts similarity index 91% rename from packages/core/src/internal/device-session/service/DefaultFramerService.test.ts rename to packages/core/src/internal/device-session/service/DefaultApduSenderService.test.ts index c3157003f..6dd9567fa 100644 --- a/packages/core/src/internal/device-session/service/DefaultFramerService.test.ts +++ b/packages/core/src/internal/device-session/service/DefaultApduSenderService.test.ts @@ -7,11 +7,11 @@ import { Frame } from "@internal/device-session/model/Frame"; import { FrameHeader } from "@internal/device-session/model/FrameHeader"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; -import { DefaultFramerService } from "./DefaultFramerService"; +import { DefaultApduSenderService } from "./DefaultApduSenderService"; const loggerService = new DefaultLoggerPublisherService([], "frame"); -describe("DefaultFramerService", () => { +describe("DefaultApduSenderService", () => { beforeAll(() => { jest.spyOn(uuid, "v4").mockReturnValue("42"); }); @@ -20,7 +20,7 @@ describe("DefaultFramerService", () => { it("should return 1 frame", () => { // given const channel = Maybe.of(new Uint8Array([0x12, 0x34])); - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: 64, padding: true, @@ -32,7 +32,7 @@ describe("DefaultFramerService", () => { const apdu = new Uint8Array([0xe0, 0x01, 0x00, 0x00, 0x00]); // when - const frames = framerService.getFrames(apdu); + const frames = apduSenderService.getFrames(apdu); // then expect(frames).toEqual([ @@ -70,7 +70,7 @@ describe("DefaultFramerService", () => { it("should return 2 frames", () => { // given const channel = Maybe.of(new Uint8Array([0x12, 0x34])); - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: 64, padding: true, @@ -91,7 +91,7 @@ describe("DefaultFramerService", () => { ]); // when - const frames = framerService.getFrames(apdu); + const frames = apduSenderService.getFrames(apdu); // then expect(frames).toEqual([ @@ -156,7 +156,7 @@ describe("DefaultFramerService", () => { describe("[BLE] Without padding nor channel", () => { it("should return 1 frame", () => { // given - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: 123, }, @@ -165,7 +165,7 @@ describe("DefaultFramerService", () => { const command = new Uint8Array([0xe0, 0x01, 0x00, 0x00, 0x00]); // when - const frames = framerService.getFrames(command); + const frames = apduSenderService.getFrames(command); // then expect(frames).toEqual([ @@ -190,7 +190,7 @@ describe("DefaultFramerService", () => { it("should return 3 frames", () => { // given - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: 10, }, @@ -202,7 +202,7 @@ describe("DefaultFramerService", () => { ]); // when - const frames = framerService.getFrames(command); + const frames = apduSenderService.getFrames(command); // then expect(frames).toEqual([ @@ -255,7 +255,7 @@ describe("DefaultFramerService", () => { describe("Errors", () => { it("should return a well formatted header with very big channel", () => { // given - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: 64, channel: Maybe.of( @@ -267,7 +267,7 @@ describe("DefaultFramerService", () => { const command = new Uint8Array([0xe0, 0x01, 0x00, 0x00, 0x00]); // when - const frames = framerService.getFrames(command); + const frames = apduSenderService.getFrames(command); // then expect(frames).toEqual([ @@ -292,7 +292,7 @@ describe("DefaultFramerService", () => { }); it("should return empty if packet size smaller than header size", () => { // given - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { frameSize: Math.random() & 4, channel: Maybe.of(new Uint8Array([0x12, 0x34])), @@ -302,7 +302,7 @@ describe("DefaultFramerService", () => { const command = new Uint8Array([0xe0, 0x01, 0x00, 0x00, 0x00]); // when - const frames = framerService.getFrames(command); + const frames = apduSenderService.getFrames(command); // then expect(frames.length).toEqual(0); @@ -310,7 +310,7 @@ describe("DefaultFramerService", () => { it("should return empty if no apdu length", () => { // given - const framerService = new DefaultFramerService( + const apduSenderService = new DefaultApduSenderService( { // random frameSize < 0xff frameSize: Math.random() & 0xff, @@ -323,7 +323,7 @@ describe("DefaultFramerService", () => { const command = new Uint8Array([]); // when - const frames = framerService.getFrames(command); + const frames = apduSenderService.getFrames(command); // then expect(frames.length).toEqual(0); diff --git a/packages/core/src/internal/device-session/service/DefaultFramerService.ts b/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts similarity index 86% rename from packages/core/src/internal/device-session/service/DefaultFramerService.ts rename to packages/core/src/internal/device-session/service/DefaultApduSenderService.ts index feb7ec894..959fd55cc 100644 --- a/packages/core/src/internal/device-session/service/DefaultFramerService.ts +++ b/packages/core/src/internal/device-session/service/DefaultApduSenderService.ts @@ -20,27 +20,40 @@ import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { SdkError } from "@root/src/api/Error"; -import type { FramerService } from "./FramerService"; +import type { ApduSenderService } from "./ApduSenderService"; -export type DefaultFramerServiceConstructorArgs = { +export type DefaultApduSenderServiceConstructorArgs = { frameSize: number; channel?: Maybe; padding?: boolean; }; +/** + * Default implementation of ApduSenderService + * + * Split APDU in an array of frames readies to send to a InternalConnectedDevice + */ @injectable() -export class DefaultFramerService implements FramerService { +export class DefaultApduSenderService implements ApduSenderService { protected _frameSize: number; protected _channel: Maybe; protected _padding: boolean; private _logger: LoggerPublisherService; + /** + * Constructor + * + * @param frameSize + * @param channel + * @param padding + * @param loggerServiceFactory + */ constructor( { frameSize, channel = Maybe.zero(), padding = false, - }: DefaultFramerServiceConstructorArgs, + }: DefaultApduSenderServiceConstructorArgs, @inject(loggerTypes.LoggerPublisherServiceFactory) loggerServiceFactory: (tag: string) => LoggerPublisherService, @@ -66,7 +79,7 @@ export class DefaultFramerService implements FramerService { count += 1; frame = this.getFrameAtIndex(apdu, count).mapLeft((error) => { if (error instanceof FramerOverflowError) { - this._logger.debug("Frames parsed", { data: { count } }); + this._logger.info("Frames parsed", { data: { count } }); } else { this._logger.error("Error while parsing frame", { data: { error } }); } @@ -131,14 +144,14 @@ export class DefaultFramerService implements FramerService { FramerUtils.getLastBytesFrom(channel, CHANNEL_LENGTH), ), headTag: new Uint8Array([HEAD_TAG]), - index: new Uint8Array([Math.floor(frameIndex / 0xff), frameIndex & 0xff]), + index: FramerUtils.numberToByteArray(frameIndex, INDEX_LENGTH), length: this.getFrameHeaderSizeFromIndex(frameIndex), dataSize: Maybe.zero(), }); if (frameIndex === 0) { header.setDataSize( Maybe.of( - new Uint8Array([Math.floor(apduSize / 0xff), apduSize & 0xff]), + FramerUtils.numberToByteArray(apduSize, APDU_DATA_LENGTH_LENGTH), ), ); } diff --git a/packages/core/src/internal/device-session/service/DefaultSessionService.test.ts b/packages/core/src/internal/device-session/service/DefaultSessionService.test.ts index a878beb1e..4275ac6b6 100644 --- a/packages/core/src/internal/device-session/service/DefaultSessionService.test.ts +++ b/packages/core/src/internal/device-session/service/DefaultSessionService.test.ts @@ -3,6 +3,7 @@ import { Either, Left } from "purify-ts"; import { DeviceSessionNotFound } from "@internal/device-session/model/Errors"; import { Session } from "@internal/device-session/model/Session"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; import { DefaultSessionService } from "./DefaultSessionService"; @@ -14,12 +15,9 @@ let session: Session; describe("DefaultSessionService", () => { beforeEach(() => { jest.restoreAllMocks(); - session = { - id: "123", - sendApdu: jest.fn(), - }; + session = new Session({ connectedDevice: connectedDeviceStubBuilder() }); loggerService = new DefaultLoggerPublisherService([], "session"); - sessionService = new DefaultSessionService(() => loggerService, []); + sessionService = new DefaultSessionService(() => loggerService); }); it("should have an empty sessions list", () => { @@ -50,11 +48,13 @@ describe("DefaultSessionService", () => { it("should get a session", () => { sessionService.addSession(session); - expect(sessionService.getSession(session.id)).toEqual(Either.of(session)); + expect(sessionService.getSessionById(session.id)).toEqual( + Either.of(session), + ); }); it("should not get a session if it does not exist", () => { - expect(sessionService.getSession(session.id)).toEqual( + expect(sessionService.getSessionById(session.id)).toEqual( Left(new DeviceSessionNotFound()), ); }); diff --git a/packages/core/src/internal/device-session/service/DefaultSessionService.ts b/packages/core/src/internal/device-session/service/DefaultSessionService.ts index f6bdafe7f..afad1737e 100644 --- a/packages/core/src/internal/device-session/service/DefaultSessionService.ts +++ b/packages/core/src/internal/device-session/service/DefaultSessionService.ts @@ -3,20 +3,20 @@ import { Maybe } from "purify-ts"; import { DeviceSessionNotFound } from "@internal/device-session/model/Errors"; import { Session } from "@internal/device-session/model/Session"; +import { SessionService } from "@internal/device-session/service/SessionService"; import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; @injectable() -export class DefaultSessionService { - private _sessions: Session[] = []; +export class DefaultSessionService implements SessionService { + private _sessions: Session[]; private _logger: LoggerPublisherService; constructor( @inject(loggerTypes.LoggerPublisherServiceFactory) loggerModuleFactory: (tag: string) => LoggerPublisherService, - sessions: Session[], ) { - this._sessions = sessions; + this._sessions = []; this._logger = loggerModuleFactory("session"); } @@ -44,7 +44,7 @@ export class DefaultSessionService { return this; } - getSession(sessionId: string) { + getSessionById(sessionId: string) { const session = Maybe.fromNullable( this._sessions.find((s) => s.id === sessionId), ); diff --git a/packages/core/src/internal/device-session/service/SessionService.ts b/packages/core/src/internal/device-session/service/SessionService.ts new file mode 100644 index 000000000..4ee5fb680 --- /dev/null +++ b/packages/core/src/internal/device-session/service/SessionService.ts @@ -0,0 +1,11 @@ +import { Either } from "purify-ts"; + +import { SdkError } from "@api/Error"; +import { Session } from "@internal/device-session/model/Session"; + +export interface SessionService { + addSession(session: Session): SessionService; + getSessionById(sessionId: string): Either; + removeSession(sessionId: string): SessionService; + getSessions(): Session[]; +} diff --git a/packages/core/src/internal/device-session/utils/FramerUtils.test.ts b/packages/core/src/internal/device-session/utils/FramerUtils.test.ts index e71eb307d..72cb92c2e 100644 --- a/packages/core/src/internal/device-session/utils/FramerUtils.test.ts +++ b/packages/core/src/internal/device-session/utils/FramerUtils.test.ts @@ -79,4 +79,34 @@ describe("FramerUtils", () => { expect(result).toEqual(0); }); }); + + describe("numberToByteArray", () => { + it("should return a correct Uint8Array", () => { + // Arrange + const number = 26505; + const size = 2; + // Act + const result = FramerUtils.numberToByteArray(number, size); + // Assert + expect(result).toEqual(new Uint8Array([0x67, 0x89])); + }); + it("should return a filled Uint8Array when number is 0 and size is 2", () => { + // Arrange + const number = 0; + const size = 2; + // Act + const result = FramerUtils.numberToByteArray(number, size); + // Assert + expect(result).toEqual(new Uint8Array([0, 0])); + }); + it("should return an empty Uint8Array when number is 42 and size is 0", () => { + // Arrange + const number = 42; + const size = 0; + // Act + const result = FramerUtils.numberToByteArray(number, size); + // Assert + expect(result).toEqual(new Uint8Array([])); + }); + }); }); diff --git a/packages/core/src/internal/device-session/utils/FramerUtils.ts b/packages/core/src/internal/device-session/utils/FramerUtils.ts index b8bacb434..842dc0d8b 100644 --- a/packages/core/src/internal/device-session/utils/FramerUtils.ts +++ b/packages/core/src/internal/device-session/utils/FramerUtils.ts @@ -5,7 +5,7 @@ export const FramerUtils = { * @param Uint8Array */ getLastBytesFrom(array: Uint8Array, size: number): Uint8Array { - return new Uint8Array(array.slice(-size)); + return array.slice(-size); }, /* @@ -14,7 +14,7 @@ export const FramerUtils = { * @param Uint8Array */ getFirstBytesFrom(array: Uint8Array, size: number): Uint8Array { - return new Uint8Array(array.slice(0, size)); + return array.slice(0, size); }, /* @@ -22,11 +22,23 @@ export const FramerUtils = { * * @param Uint8Array */ - bytesToNumber(array: Uint8Array) { + bytesToNumber(array: Uint8Array): number { return array.reduce( (acc, val, index) => acc + val * Math.pow(0x100, array.length - 1 - index), 0, ); }, + + /* + * Get bytes Uint8Array from number + * + * @param number + * @param size + */ + numberToByteArray(number: number, size: number): Uint8Array { + return new Uint8Array(size).map((_el, index) => { + return (number >> (8 * (size - 1 - index))) & 0xff; + }); + }, }; diff --git a/packages/core/src/internal/discovery/di/discoveryModule.test.ts b/packages/core/src/internal/discovery/di/discoveryModule.test.ts index 8169359ba..010f99d91 100644 --- a/packages/core/src/internal/discovery/di/discoveryModule.test.ts +++ b/packages/core/src/internal/discovery/di/discoveryModule.test.ts @@ -1,6 +1,7 @@ import { Container } from "inversify"; import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; @@ -22,6 +23,7 @@ describe("discoveryModuleFactory", () => { loggerModuleFactory(), usbModuleFactory(), deviceModelModuleFactory(), + deviceSessionModuleFactory(), ); }); diff --git a/packages/core/src/internal/discovery/model/ConnectionType.ts b/packages/core/src/internal/discovery/model/ConnectionType.ts new file mode 100644 index 000000000..ca778a045 --- /dev/null +++ b/packages/core/src/internal/discovery/model/ConnectionType.ts @@ -0,0 +1 @@ +export type ConnectionType = "USB" | "BLE" | "MOCK"; diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts index c647ca120..34d804864 100644 --- a/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts @@ -1,31 +1,37 @@ import { Left, Right } from "purify-ts"; +import * as uuid from "uuid"; +jest.mock("uuid"); import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; -import { DeviceModel } from "@internal/device-model/model/DeviceModel"; +import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; +import { SessionService } from "@internal/device-session/service/SessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; -import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; import { UnknownDeviceError } from "@internal/usb/model/Errors"; +import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; +import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; import { ConnectUseCase } from "./ConnectUseCase"; let transport: WebUsbHidTransport; let logger: LoggerPublisherService; +let sessionService: SessionService; +const fakeSessionId = "fakeSessionId"; describe("ConnectUseCase", () => { - const stubConnectedDevice: ConnectedDevice = { - id: "", - deviceModel: {} as DeviceModel, - }; + const stubConnectedDevice = connectedDeviceStubBuilder({ id: "1" }); const tag = "logger-tag"; beforeAll(() => { logger = new DefaultLoggerPublisherService([], tag); + jest.spyOn(uuid, "v4").mockReturnValue(fakeSessionId); transport = new WebUsbHidTransport( {} as DeviceModelDataSource, () => logger, + usbHidDeviceConnectionFactoryStubBuilder(), ); + sessionService = new DefaultSessionService(() => logger); }); afterAll(() => { @@ -37,21 +43,21 @@ describe("ConnectUseCase", () => { .spyOn(transport, "connect") .mockResolvedValue(Left(new UnknownDeviceError())); - const usecase = new ConnectUseCase(transport); + const usecase = new ConnectUseCase(transport, sessionService, () => logger); await expect(usecase.execute({ deviceId: "" })).rejects.toBeInstanceOf( UnknownDeviceError, ); }); - test("If connect is in success, return an observable connected device object", async () => { + test("If connect is in success, return a session id", async () => { jest .spyOn(transport, "connect") .mockResolvedValue(Promise.resolve(Right(stubConnectedDevice))); - const usecase = new ConnectUseCase(transport); + const usecase = new ConnectUseCase(transport, sessionService, () => logger); - const connectedDevice = await usecase.execute({ deviceId: "" }); - expect(connectedDevice).toBe(stubConnectedDevice); + const sessionId = await usecase.execute({ deviceId: "" }); + expect(sessionId).toBe(fakeSessionId); }); }); diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts index a21836a0c..de09780de 100644 --- a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts @@ -1,8 +1,12 @@ import { inject, injectable } from "inversify"; import { DeviceId } from "@internal/device-model/model/DeviceModel"; +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import { Session, SessionId } from "@internal/device-session/model/Session"; +import type { SessionService } from "@internal/device-session/service/SessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; 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 = { @@ -14,19 +18,38 @@ export type ConnectUseCaseArgs = { */ @injectable() export class ConnectUseCase { + private readonly _usbHidTransport: UsbHidTransport; + private readonly _sessionService: SessionService; + private readonly _logger: LoggerPublisherService; + constructor( @inject(usbDiTypes.UsbHidTransport) - private usbHidTransport: UsbHidTransport, - // Later: @inject(usbDiTypes.BleTransport) private bleTransport: BleTransport, - ) {} + usbHidTransport: UsbHidTransport, + @inject(deviceSessionTypes.SessionService) + sessionService: SessionService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this._sessionService = sessionService; + this._usbHidTransport = usbHidTransport; + this._logger = loggerFactory("ConnectUseCase"); + } + + async execute({ deviceId }: ConnectUseCaseArgs): Promise | never { + const either = await this._usbHidTransport.connect({ deviceId }); - async execute({ deviceId }: ConnectUseCaseArgs): Promise { - const either = await this.usbHidTransport.connect({ deviceId }); return either.caseOf({ Left: (error) => { + this._logger.error("Error connecting to device", { + data: { deviceId, error }, + }); throw error; }, - Right: (connectedDevice) => connectedDevice, + Right: (connectedDevice) => { + const session = new Session({ connectedDevice }); + this._sessionService.addSession(session); + return session.id; + }, }); } } diff --git a/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts index d5bbc9af6..9d4dba0eb 100644 --- a/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/StartDiscoveringUseCase.test.ts @@ -4,6 +4,7 @@ import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDa import { DeviceModel } from "@internal/device-model/model/DeviceModel"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; import { DiscoveredDevice } from "@root/src"; @@ -24,6 +25,7 @@ describe("StartDiscoveringUseCase", () => { transport = new WebUsbHidTransport( {} as DeviceModelDataSource, () => logger, + usbHidDeviceConnectionFactoryStubBuilder(), ); }); diff --git a/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts index be736a6b5..a43d56cf5 100644 --- a/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/StopDiscoveringUseCase.test.ts @@ -1,6 +1,7 @@ import { DeviceModelDataSource } from "@internal/device-model/data/DeviceModelDataSource"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; import { StopDiscoveringUseCase } from "./StopDiscoveringUseCase"; @@ -15,6 +16,7 @@ describe("StopDiscoveringUseCase", () => { transport = new WebUsbHidTransport( {} as DeviceModelDataSource, () => logger, + usbHidDeviceConnectionFactoryStubBuilder(), ); }); diff --git a/packages/core/src/internal/logger-publisher/di/loggerTypes.ts b/packages/core/src/internal/logger-publisher/di/loggerTypes.ts index 820a66ef5..64880ee0d 100644 --- a/packages/core/src/internal/logger-publisher/di/loggerTypes.ts +++ b/packages/core/src/internal/logger-publisher/di/loggerTypes.ts @@ -1,4 +1,3 @@ export const loggerTypes = { - LoggerPublisherService: Symbol.for("LoggerPublisherService"), LoggerPublisherServiceFactory: Symbol.for("LoggerPublisherServiceFactory"), }; diff --git a/packages/core/src/internal/send/di/sendModule.ts b/packages/core/src/internal/send/di/sendModule.ts index 879d8d57a..6fb233ebc 100644 --- a/packages/core/src/internal/send/di/sendModule.ts +++ b/packages/core/src/internal/send/di/sendModule.ts @@ -1,6 +1,6 @@ import { ContainerModule } from "inversify"; -import { SendApduUseCase } from "@internal/send/usecase/SendApduUseCase"; +import { SendApduUseCase } from "@internal/send/use-case/SendApduUseCase"; import { sendTypes } from "./sendTypes"; @@ -19,6 +19,6 @@ export const sendModuleFactory = (_args: Partial = {}) => _onActivation, _onDeactivation, ) => { - bind(sendTypes.SendService).to(SendApduUseCase); + bind(sendTypes.SendApduUseCase).to(SendApduUseCase); }, ); diff --git a/packages/core/src/internal/send/di/sendTypes.ts b/packages/core/src/internal/send/di/sendTypes.ts index 34c246651..8639ecf5e 100644 --- a/packages/core/src/internal/send/di/sendTypes.ts +++ b/packages/core/src/internal/send/di/sendTypes.ts @@ -1,3 +1,3 @@ export const sendTypes = { - SendService: Symbol.for("SendService"), + SendApduUseCase: Symbol.for("SendApduUseCase"), }; diff --git a/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts new file mode 100644 index 000000000..1c14eafab --- /dev/null +++ b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts @@ -0,0 +1,71 @@ +import { DeviceSessionNotFound } from "@internal/device-session/model/Errors"; +import { sessionStubBuilder } from "@internal/device-session/model/Session.stub"; +import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; +import { SessionService } from "@internal/device-session/service/SessionService"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { SendApduUseCase } from "@internal/send/use-case/SendApduUseCase"; +import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; + +let logger: LoggerPublisherService; +let sessionService: SessionService; +const fakeSessionId = "fakeSessionId"; + +describe("SendApduUseCase", () => { + beforeEach(() => { + logger = new DefaultLoggerPublisherService([], "send-apdu-use-case"); + sessionService = new DefaultSessionService(() => logger); + }); + + it("should send an APDU to a connected device", async () => { + // given + const session = sessionStubBuilder(); + sessionService.addSession(session); + const useCase = new SendApduUseCase(sessionService, () => logger); + + // when + const response = await useCase.execute({ + sessionId: fakeSessionId, + apdu: new Uint8Array([0x00, 0x01, 0x02, 0x03]), + }); + + // then + expect(session.connectedDevice.sendApdu).toHaveBeenCalledTimes(1); + expect(response).toBeDefined(); + }); + + it("should throw an error if the session is not found", async () => { + // given + const useCase = new SendApduUseCase(sessionService, () => logger); + + // when + const response = useCase.execute({ + sessionId: fakeSessionId, + apdu: new Uint8Array([0x00, 0x01, 0x02, 0x03]), + }); + + // then + await expect(response).rejects.toBeInstanceOf(DeviceSessionNotFound); + }); + + it("should throw an error if the apdu receiver failed", () => { + // given + const connectedDevice = connectedDeviceStubBuilder({ + sendApdu: jest.fn(async () => { + throw new Error("apdu receiver failed"); + }), + }); + const session = sessionStubBuilder({ connectedDevice }); + sessionService.addSession(session); + const useCase = new SendApduUseCase(sessionService, () => logger); + + // when + const response = useCase.execute({ + sessionId: fakeSessionId, + apdu: new Uint8Array([0x00, 0x01, 0x02, 0x03]), + }); + + // then + expect(response).rejects.toThrow("apdu receiver failed"); + }); +}); diff --git a/packages/core/src/internal/send/use-case/SendApduUseCase.ts b/packages/core/src/internal/send/use-case/SendApduUseCase.ts new file mode 100644 index 000000000..47ad5cee9 --- /dev/null +++ b/packages/core/src/internal/send/use-case/SendApduUseCase.ts @@ -0,0 +1,64 @@ +import { inject, injectable } from "inversify"; + +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; +import { SessionId } from "@internal/device-session/model/Session"; +import type { SessionService } from "@internal/device-session/service/SessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export type SendApduUseCaseArgs = { + sessionId: SessionId; + apdu: Uint8Array; +}; + +/** + * Sends an APDU to a connected device. + */ +@injectable() +export class SendApduUseCase { + private readonly _sessionService: SessionService; + private readonly _logger: LoggerPublisherService; + + constructor( + @inject(deviceSessionTypes.SessionService) + sessionService: SessionService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this._sessionService = sessionService; + this._logger = loggerFactory("SendApduUseCase"); + } + + async execute({ + sessionId, + apdu, + }: SendApduUseCaseArgs): Promise { + const deviceSession = this._sessionService.getSessionById(sessionId); + + return deviceSession.caseOf({ + // Case device session found + Right: async (session) => { + const response = await session.sendApdu(apdu); + return response.caseOf({ + // Case APDU sent and response received successfully + Right: (data) => data, + // Case error sending or receiving APDU + Left: (error) => { + this._logger.error("Error sending APDU", { + data: { sessionId, apdu, error }, + }); + throw error; + }, + }); + }, + // Case device session not found + Left: (error) => { + this._logger.error("Error getting session", { + data: { error }, + }); + throw error; + }, + }); + } +} diff --git a/packages/core/src/internal/send/usecase/SendApduUseCase.ts b/packages/core/src/internal/send/usecase/SendApduUseCase.ts deleted file mode 100644 index 495958cd9..000000000 --- a/packages/core/src/internal/send/usecase/SendApduUseCase.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { inject, injectable } from "inversify"; - -import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; -import { SessionId } from "@internal/device-session/model/Session"; -import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; - -export type SendApduUseCaseArgs = { - sessionId: SessionId; - apdu: Uint8Array; -}; - -/** - * Sends an APDU to a connected device. - */ -@injectable() -export class SendApduUseCase { - constructor( - @inject(deviceSessionTypes.SessionService) - private sessionService: DefaultSessionService, - // @inject(loggerTypes.LoggerService) private logger: LoggerService, - ) {} - - async execute({ sessionId }: SendApduUseCaseArgs): Promise { - // [TODO] implement logging - // [SHOULD] this is temporary example code, to de replaced with actual implementation - const uint8Array = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00]); - const deviceSession = this.sessionService.getSession(sessionId); - if (deviceSession.isLeft()) { - throw deviceSession.extract(); - } - - if (deviceSession.isRight()) { - const res = await deviceSession.extract().sendApdu(uint8Array); - return res; - } - - return; - } -} diff --git a/packages/core/src/internal/usb/data/UsbHidConfig.ts b/packages/core/src/internal/usb/data/UsbHidConfig.ts index bd56c7d3b..97a63ddc0 100644 --- a/packages/core/src/internal/usb/data/UsbHidConfig.ts +++ b/packages/core/src/internal/usb/data/UsbHidConfig.ts @@ -1,2 +1,3 @@ // [SHOULD] Move it to device-model module -export const ledgerVendorId = 0x2c97; +export const LEDGER_VENDOR_ID = 0x2c97; +export const FRAME_SIZE = 64; diff --git a/packages/core/src/internal/usb/di/usbDiTypes.ts b/packages/core/src/internal/usb/di/usbDiTypes.ts index aea94a6d1..0f40ecfdd 100644 --- a/packages/core/src/internal/usb/di/usbDiTypes.ts +++ b/packages/core/src/internal/usb/di/usbDiTypes.ts @@ -1,3 +1,5 @@ export const usbDiTypes = { UsbHidTransport: Symbol.for("UsbHidTransport"), + UsbHidDeviceConnectionFactory: Symbol.for("UsbHidDeviceConnectionFactory"), + GetConnectedDeviceUseCase: Symbol.for("GetConnectedDeviceUseCase"), }; diff --git a/packages/core/src/internal/usb/di/usbModule.test.ts b/packages/core/src/internal/usb/di/usbModule.test.ts index 9c5e97b3c..bfb864a70 100644 --- a/packages/core/src/internal/usb/di/usbModule.test.ts +++ b/packages/core/src/internal/usb/di/usbModule.test.ts @@ -1,6 +1,7 @@ import { Container } from "inversify"; import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; +import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; @@ -14,7 +15,11 @@ describe("usbModuleFactory", () => { mod = usbModuleFactory(); container = new Container(); container.load(loggerModuleFactory()); - container.load(mod, deviceModelModuleFactory()); + container.load( + mod, + deviceModelModuleFactory(), + deviceSessionModuleFactory(), + ); }); it("should return the usb module", () => { diff --git a/packages/core/src/internal/usb/di/usbModule.ts b/packages/core/src/internal/usb/di/usbModule.ts index 498a18075..602fb4e6b 100644 --- a/packages/core/src/internal/usb/di/usbModule.ts +++ b/packages/core/src/internal/usb/di/usbModule.ts @@ -1,6 +1,8 @@ import { ContainerModule } from "inversify"; +import { UsbHidDeviceConnectionFactory } from "@internal/usb/service/UsbHidDeviceConnectionFactory"; import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; +import { GetConnectedDeviceUseCase } from "@internal/usb/use-case/GetConnectedDeviceUseCase"; import { usbDiTypes } from "./usbDiTypes"; @@ -15,6 +17,14 @@ export const usbModuleFactory = ({ // The transport needs to be a singleton to keep the internal states of the devices bind(usbDiTypes.UsbHidTransport).to(WebUsbHidTransport).inSingletonScope(); + // UsbHidDeviceConnectionFactory + bind(usbDiTypes.UsbHidDeviceConnectionFactory).to( + UsbHidDeviceConnectionFactory, + ); + + // GetConnectedDeviceUseCase + bind(usbDiTypes.GetConnectedDeviceUseCase).to(GetConnectedDeviceUseCase); + 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 deleted file mode 100644 index 76673023c..000000000 --- a/packages/core/src/internal/usb/model/ConnectedDevice.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/Errors.ts b/packages/core/src/internal/usb/model/Errors.ts index ee0c0958f..15150fc13 100644 --- a/packages/core/src/internal/usb/model/Errors.ts +++ b/packages/core/src/internal/usb/model/Errors.ts @@ -1,10 +1,12 @@ +import { SdkError } from "@api/Error"; + export type PromptDeviceAccessError = | UsbHidTransportNotSupportedError | NoAccessibleDeviceError; export type ConnectError = UnknownDeviceError | OpeningConnectionError; -export class DeviceNotRecognizedError { +export class DeviceNotRecognizedError implements SdkError { readonly _tag = "DeviceNotRecognizedError"; originalError?: Error; constructor(readonly err?: Error) { @@ -12,7 +14,7 @@ export class DeviceNotRecognizedError { } } -export class NoAccessibleDeviceError { +export class NoAccessibleDeviceError implements SdkError { readonly _tag = "NoAccessibleDeviceError"; originalError?: Error; constructor(readonly err?: Error) { @@ -20,7 +22,7 @@ export class NoAccessibleDeviceError { } } -export class OpeningConnectionError { +export class OpeningConnectionError implements SdkError { readonly _tag = "ConnectionOpeningError"; originalError?: Error; constructor(readonly err?: Error) { @@ -28,7 +30,7 @@ export class OpeningConnectionError { } } -export class UnknownDeviceError { +export class UnknownDeviceError implements SdkError { readonly _tag = "UnknownDeviceError"; originalError?: Error; constructor(readonly err?: Error) { @@ -36,10 +38,18 @@ export class UnknownDeviceError { } } -export class UsbHidTransportNotSupportedError { +export class UsbHidTransportNotSupportedError implements SdkError { readonly _tag = "UsbHidTransportNotSupportedError"; originalError?: Error; constructor(readonly err?: Error) { this.originalError = err; } } + +export class SendApduConcurrencyError implements SdkError { + readonly _tag = "SendApduConcurrencyError"; + originalError?: Error; + constructor(readonly err?: Error) { + this.originalError = err; + } +} diff --git a/packages/core/src/internal/usb/model/HIDDevice.stub.ts b/packages/core/src/internal/usb/model/HIDDevice.stub.ts new file mode 100644 index 000000000..25a686b0f --- /dev/null +++ b/packages/core/src/internal/usb/model/HIDDevice.stub.ts @@ -0,0 +1,21 @@ +const oninputreport = jest.fn(); +export const hidDeviceStubBuilder = ( + props: Partial = {}, +): HIDDevice => ({ + opened: false, + productId: 0x4011, + vendorId: 0x2c97, + productName: "Ledger Nano X", + collections: [], + open: jest.fn(), + oninputreport, + close: jest.fn(), + sendReport: jest.fn(async () => oninputreport()), + sendFeatureReport: jest.fn(), + forget: jest.fn(), + receiveFeatureReport: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + ...props, +}); diff --git a/packages/core/src/internal/usb/model/InternalConnectedDevice.stub.ts b/packages/core/src/internal/usb/model/InternalConnectedDevice.stub.ts new file mode 100644 index 000000000..a5502c09b --- /dev/null +++ b/packages/core/src/internal/usb/model/InternalConnectedDevice.stub.ts @@ -0,0 +1,23 @@ +import { Right } from "purify-ts"; + +import { deviceModelStubBuilder } from "@internal/device-model/model/DeviceModel.stub"; +import { defaultApduResponseStubBuilder } from "@internal/device-session/model/ApduResponse.stub"; +import { + ConnectedDeviceConstructorArgs, + InternalConnectedDevice, +} from "@internal/usb/model/InternalConnectedDevice"; + +export function connectedDeviceStubBuilder( + props: Partial = {}, +): InternalConnectedDevice { + const deviceModel = deviceModelStubBuilder(); + return new InternalConnectedDevice({ + deviceModel, + id: "42", + type: "MOCK", + sendApdu: jest.fn(async () => + Promise.resolve(Right(defaultApduResponseStubBuilder())), + ), + ...props, + }); +} diff --git a/packages/core/src/internal/usb/model/InternalConnectedDevice.ts b/packages/core/src/internal/usb/model/InternalConnectedDevice.ts new file mode 100644 index 000000000..d34208e44 --- /dev/null +++ b/packages/core/src/internal/usb/model/InternalConnectedDevice.ts @@ -0,0 +1,35 @@ +import { + DeviceId, + DeviceModel, +} from "@internal/device-model/model/DeviceModel"; +import { ConnectionType } from "@internal/discovery/model/ConnectionType"; +import { SendApduFnType } from "@internal/usb/transport/DeviceConnection"; + +/** + * Represents an internal connected device. + */ +export type ConnectedDeviceConstructorArgs = { + id: DeviceId; + deviceModel: DeviceModel; + type: ConnectionType; + sendApdu: SendApduFnType; +}; + +export class InternalConnectedDevice { + public readonly id: DeviceId; + public readonly deviceModel: DeviceModel; + public readonly sendApdu: SendApduFnType; + public readonly type: ConnectionType; + + constructor({ + id, + deviceModel, + sendApdu, + type, + }: ConnectedDeviceConstructorArgs) { + this.id = id; + this.deviceModel = deviceModel; + this.sendApdu = sendApdu; + this.type = type; + } +} diff --git a/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.stub.ts b/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.stub.ts new file mode 100644 index 000000000..2f212301f --- /dev/null +++ b/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.stub.ts @@ -0,0 +1,13 @@ +import { defaultApduReceiverServiceStubBuilder } from "@internal/device-session/service/DefaultApduReceiverService.stub"; +import { defaultApduSenderServiceStubBuilder } from "@internal/device-session/service/DefaultApduSenderService.stub"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/__mocks__/DefaultLoggerService"; +import { UsbHidDeviceConnectionFactory } from "@internal/usb/service/UsbHidDeviceConnectionFactory"; + +const loggerFactory = () => new DefaultLoggerPublisherService(); + +export const usbHidDeviceConnectionFactoryStubBuilder = () => + new UsbHidDeviceConnectionFactory( + () => defaultApduSenderServiceStubBuilder({}, loggerFactory), + () => defaultApduReceiverServiceStubBuilder({}, loggerFactory), + loggerFactory, + ); diff --git a/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.ts b/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.ts new file mode 100644 index 000000000..9ddf940da --- /dev/null +++ b/packages/core/src/internal/usb/service/UsbHidDeviceConnectionFactory.ts @@ -0,0 +1,52 @@ +import { inject, injectable } from "inversify"; +import { Maybe } from "purify-ts"; + +import { CHANNEL_LENGTH } from "@internal/device-session/data/FramerConst"; +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { DefaultApduReceiverConstructorArgs } from "@internal/device-session/service/DefaultApduReceiverService"; +import { DefaultApduSenderServiceConstructorArgs } from "@internal/device-session/service/DefaultApduSenderService"; +import { FramerUtils } from "@internal/device-session/utils/FramerUtils"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { FRAME_SIZE } from "@internal/usb/data/UsbHidConfig"; +import { UsbHidDeviceConnection } from "@internal/usb/transport/UsbHidDeviceConnection"; + +@injectable() +export class UsbHidDeviceConnectionFactory { + randomChannel = Math.floor(Math.random() * 0xffff); + + constructor( + @inject(deviceSessionTypes.ApduSenderServiceFactory) + private readonly apduSenderFactory: ( + args: DefaultApduSenderServiceConstructorArgs, + ) => ApduSenderService, + @inject(deviceSessionTypes.ApduReceiverServiceFactory) + private readonly apduReceiverFactory: ( + args: DefaultApduReceiverConstructorArgs, + ) => ApduReceiverService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + private readonly loggerFactory: (name: string) => LoggerPublisherService, + ) {} + + public create( + device: HIDDevice, + channel = Maybe.of( + FramerUtils.numberToByteArray(this.randomChannel, CHANNEL_LENGTH), + ), + ): UsbHidDeviceConnection { + return new UsbHidDeviceConnection( + { + device, + apduSender: this.apduSenderFactory({ + frameSize: FRAME_SIZE, + channel, + padding: true, + }), + apduReceiver: this.apduReceiverFactory({ channel }), + }, + this.loggerFactory, + ); + } +} diff --git a/packages/core/src/internal/usb/transport/DeviceConnection.ts b/packages/core/src/internal/usb/transport/DeviceConnection.ts new file mode 100644 index 000000000..ddf27e177 --- /dev/null +++ b/packages/core/src/internal/usb/transport/DeviceConnection.ts @@ -0,0 +1,12 @@ +import { Either } from "purify-ts"; + +import { SdkError } from "@api/Error"; +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; + +export type SendApduFnType = ( + apdu: Uint8Array, +) => Promise>; + +export interface DeviceConnection { + sendApdu: SendApduFnType; +} diff --git a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts new file mode 100644 index 000000000..a39482d70 --- /dev/null +++ b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts @@ -0,0 +1,70 @@ +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { defaultApduReceiverServiceStubBuilder } from "@internal/device-session/service/DefaultApduReceiverService.stub"; +import { defaultApduSenderServiceStubBuilder } from "@internal/device-session/service/DefaultApduSenderService.stub"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { hidDeviceStubBuilder } from "@internal/usb/model/HIDDevice.stub"; +import { UsbHidDeviceConnection } from "@internal/usb/transport/UsbHidDeviceConnection"; + +const RESPONSE_LOCKED_DEVICE = new Uint8Array([ + 0xaa, 0xaa, 0x05, 0x00, 0x00, 0x00, 0x02, 0x55, 0x15, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +describe("UsbHidDeviceConnection", () => { + let device: HIDDevice; + let apduSender: ApduSenderService; + let apduReceiver: ApduReceiverService; + const logger = (tag: string) => new DefaultLoggerPublisherService([], tag); + + beforeEach(async () => { + device = hidDeviceStubBuilder(); + apduSender = defaultApduSenderServiceStubBuilder(undefined, logger); + apduReceiver = defaultApduReceiverServiceStubBuilder(undefined, logger); + }); + + it("should get device", () => { + // given + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + // when + const cDevice = connection.device; + // then + expect(cDevice).toStrictEqual(device); + }); + + it("should send APDU through hid report", async () => { + // given + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + // when + connection.sendApdu(new Uint8Array(0)); + // then + expect(device.sendReport).toHaveBeenCalled(); + }); + + it("should receive APDU through hid report", async () => { + // given + device.sendReport = jest.fn(async () => { + device.oninputreport!({ + type: "inputreport", + data: new DataView(Uint8Array.from(RESPONSE_LOCKED_DEVICE).buffer), + } as HIDInputReportEvent); + }); + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + // when + const response = connection.sendApdu(Uint8Array.from([])); + // then + expect(response).resolves.toBe(RESPONSE_LOCKED_DEVICE); + }); +}); diff --git a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts new file mode 100644 index 000000000..87b11a403 --- /dev/null +++ b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts @@ -0,0 +1,86 @@ +import { inject } from "inversify"; +import { Left, Right } from "purify-ts"; +import { Subject } from "rxjs"; + +import { ApduResponse } from "@internal/device-session/model/ApduResponse"; +import { ApduReceiverService } from "@internal/device-session/service/ApduReceiverService"; +import { ApduSenderService } from "@internal/device-session/service/ApduSenderService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import type { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +import { DeviceConnection, SendApduFnType } from "./DeviceConnection"; + +type UsbHidDeviceConnectionConstructorArgs = { + device: HIDDevice; + apduSender: ApduSenderService; + apduReceiver: ApduReceiverService; +}; + +export class UsbHidDeviceConnection implements DeviceConnection { + private readonly _device: HIDDevice; + private readonly _apduSender: ApduSenderService; + private readonly _apduReceiver: ApduReceiverService; + private _sendApduSubject: Subject; + private readonly _logger: LoggerPublisherService; + + constructor( + { device, apduSender, apduReceiver }: UsbHidDeviceConnectionConstructorArgs, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerServiceFactory: (tag: string) => LoggerPublisherService, + ) { + this._device = device; + this._apduSender = apduSender; + this._apduReceiver = apduReceiver; + this._sendApduSubject = new Subject(); + this._device.oninputreport = this.receiveHidInputReport; + this._logger = loggerServiceFactory("UsbHidDeviceConnection"); + } + + public get device() { + return this._device; + } + + sendApdu: SendApduFnType = async (apdu) => { + this._sendApduSubject = new Subject(); + + this._logger.info("Sending APDU", { data: { apdu } }); + const frames = this._apduSender.getFrames(apdu); + for (const frame of frames) { + this._logger.info("Sending Frame", { + data: { frame: frame.getRawData() }, + }); + await this._device.sendReport(0, frame.getRawData()); + } + + return new Promise((resolve) => { + this._sendApduSubject.subscribe({ + next: (r) => { + resolve(Right(r)); + }, + error: (err) => { + resolve(Left(err)); + }, + }); + }); + }; + + private receiveHidInputReport = (event: HIDInputReportEvent) => { + const data = new Uint8Array(event.data.buffer); + this._logger.info("Received Frame", { data: { frame: data } }); + const response = this._apduReceiver.handleFrame(data); + response.caseOf({ + Right: (maybeApduResponse) => { + maybeApduResponse.map((apduResponse) => { + this._logger.info("Received APDU Response", { + data: { response: apduResponse }, + }); + this._sendApduSubject.next(apduResponse); + this._sendApduSubject.complete(); + }); + }, + Left: (err) => { + this._sendApduSubject.error(err); + }, + }); + }; +} diff --git a/packages/core/src/internal/usb/transport/UsbHidTransport.ts b/packages/core/src/internal/usb/transport/UsbHidTransport.ts index e89e8e5ab..aca83adc2 100644 --- a/packages/core/src/internal/usb/transport/UsbHidTransport.ts +++ b/packages/core/src/internal/usb/transport/UsbHidTransport.ts @@ -2,9 +2,9 @@ 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"; +import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; /** * Transport interface representing a USB HID communication @@ -24,5 +24,5 @@ export interface UsbHidTransport { */ connect(params: { deviceId: DeviceId; - }): Promise>; + }): Promise>; } diff --git a/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts b/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts index 674980fda..b27822657 100644 --- a/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts +++ b/packages/core/src/internal/usb/transport/WebUsbHidTransport.test.ts @@ -13,6 +13,8 @@ import { UnknownDeviceError, UsbHidTransportNotSupportedError, } from "@internal/usb/model/Errors"; +import { hidDeviceStubBuilder } from "@internal/usb/model/HIDDevice.stub"; +import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; import { WebUsbHidTransport } from "./WebUsbHidTransport"; @@ -22,20 +24,17 @@ jest.mock("@internal/logger-publisher/service/LoggerPublisherService"); const usbDeviceModelDataSource = new StaticDeviceModelDataSource(); const logger = new DefaultLoggerPublisherService([], "web-usb-hid"); -const stubDevice = { - opened: false, - productId: 0x4011, - vendorId: 0x2c97, - productName: "Ledger Nano X", - collections: [], - open: jest.fn(), -}; +const stubDevice: HIDDevice = hidDeviceStubBuilder(); describe("WebUsbHidTransport", () => { let transport: WebUsbHidTransport; beforeEach(() => { - transport = new WebUsbHidTransport(usbDeviceModelDataSource, () => logger); + transport = new WebUsbHidTransport( + usbDeviceModelDataSource, + () => logger, + usbHidDeviceConnectionFactoryStubBuilder(), + ); }); afterEach(() => { diff --git a/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts index 7ba87e2cf..31924c797 100644 --- a/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts +++ b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts @@ -9,8 +9,8 @@ import { deviceModelTypes } from "@internal/device-model/di/deviceModelTypes"; import { DeviceId } from "@internal/device-model/model/DeviceModel"; import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import type { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; -import { ledgerVendorId } from "@internal/usb/data/UsbHidConfig"; -import { ConnectedDevice } from "@internal/usb/model/ConnectedDevice"; +import { LEDGER_VENDOR_ID } from "@internal/usb/data/UsbHidConfig"; +import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; import { DiscoveredDevice } from "@internal/usb/model/DiscoveredDevice"; import { ConnectError, @@ -21,6 +21,8 @@ import { UnknownDeviceError, UsbHidTransportNotSupportedError, } from "@internal/usb/model/Errors"; +import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; +import { UsbHidDeviceConnectionFactory } from "@internal/usb/service/UsbHidDeviceConnectionFactory"; import { UsbHidTransport } from "./UsbHidTransport"; @@ -29,31 +31,35 @@ 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; - private logger: LoggerPublisherService; + private _internalDevicesById: Map; + private _connectionListenersAbortController: AbortController; + private _logger: LoggerPublisherService; + private _usbHidDeviceConnectionFactory: UsbHidDeviceConnectionFactory; constructor( @inject(deviceModelTypes.DeviceModelDataSource) private deviceModelDataSource: DeviceModelDataSource, @inject(loggerTypes.LoggerPublisherServiceFactory) loggerServiceFactory: (tag: string) => LoggerPublisherService, + @inject(usbDiTypes.UsbHidDeviceConnectionFactory) + usbHidDeviceConnectionFactory: UsbHidDeviceConnectionFactory, ) { - this.internalDevicesById = new Map(); - this.connectionListenersAbortController = new AbortController(); - this.logger = loggerServiceFactory("WebUsbHidTransport"); + this._internalDevicesById = new Map(); + this._connectionListenersAbortController = new AbortController(); + this._logger = loggerServiceFactory("WebUsbHidTransport"); + this._usbHidDeviceConnectionFactory = usbHidDeviceConnectionFactory; } /** - * @returns `Either` an error if the WebHID API is not supported, or the WebHID API itself + * Get the WebHID API if supported or error + * @returns `Either` */ - private hidApi = (): Either => { + private get hidApi(): Either { if (this.isSupported()) { return Right(navigator.hid); } @@ -61,15 +67,15 @@ export class WebUsbHidTransport implements UsbHidTransport { return Left( new UsbHidTransportNotSupportedError(new Error("WebHID not supported")), ); - }; + } - isSupported(): boolean { + isSupported() { try { const result = !!navigator?.hid; - this.logger.debug(`isSupported: ${result}`); + this._logger.debug(`isSupported: ${result}`); return result; } catch (error) { - this.logger.error(`isSupported: error`, { data: { error } }); + this._logger.error(`isSupported: error`, { data: { error } }); return false; } } @@ -85,31 +91,31 @@ export class WebUsbHidTransport implements UsbHidTransport { private async promptDeviceAccess(): Promise< Either > { - return EitherAsync.liftEither(this.hidApi()) + 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 }], + filters: [{ vendorId: LEDGER_VENDOR_ID }], }); } catch (error) { const deviceError = new NoAccessibleDeviceError(error as Error); - this.logger.error(`promptDeviceAccess: error requesting device`, { + this._logger.error(`promptDeviceAccess: error requesting device`, { data: { error }, }); Sentry.captureException(deviceError); throw deviceError; } - this.logger.debug( + this._logger.debug( `promptDeviceAccess: hidDevices len ${hidDevices.length}`, ); // Granted access to 0 device (by clicking on cancel for ex) results in an error if (hidDevices.length === 0) { - this.logger.warn("No device was selected"); + this._logger.warn("No device was selected"); throw new NoAccessibleDeviceError(new Error("No selected device")); } @@ -118,7 +124,7 @@ export class WebUsbHidTransport implements UsbHidTransport { for (const hidDevice of hidDevices) { discoveredHidDevices.push(hidDevice); - this.logger.debug(`promptDeviceAccess: selected device`, { + this._logger.debug(`promptDeviceAccess: selected device`, { data: { hidDevice }, }); } @@ -148,27 +154,27 @@ export class WebUsbHidTransport implements UsbHidTransport { * So the consumer can directly select this device. */ startDiscovering(): Observable { - this.logger.debug("startDiscovering"); + this._logger.debug("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(); + this._internalDevicesById.clear(); return from(this.promptDeviceAccess()).pipe( switchMap((either) => { return either.caseOf({ Left: (error) => { - this.logger.error("Error while getting accessible device", { + this._logger.error("Error while getting accessible device", { data: { error }, }); Sentry.captureException(error); throw error; }, Right: (hidDevices) => { - this.logger.info(`Got access to ${hidDevices.length} HID devices`); + this._logger.info(`Got access to ${hidDevices.length} HID devices`); const discoveredDevices = hidDevices.map((hidDevice) => { const usbProductId = this.getHidUsbProductId(hidDevice.productId); @@ -189,15 +195,15 @@ export class WebUsbHidTransport implements UsbHidTransport { discoveredDevice, }; - this.logger.debug( + this._logger.debug( `Discovered device ${id} ${discoveredDevice.deviceModel.productName}`, ); - this.internalDevicesById.set(id, internalDevice); + this._internalDevicesById.set(id, internalDevice); return discoveredDevice; } else { // [ASK] Or we just ignore the not recognized device ? And log them - this.logger.warn( + this._logger.warn( `Device not recognized: 0x${usbProductId.toString(16)}`, ); throw new DeviceNotRecognizedError( @@ -215,7 +221,7 @@ export class WebUsbHidTransport implements UsbHidTransport { } stopDiscovering(): void { - this.logger.debug("stopDiscovering"); + this._logger.debug("stopDiscovering"); this.stopListeningToConnectionEvents(); } @@ -224,30 +230,30 @@ export class WebUsbHidTransport implements UsbHidTransport { * Logs `connect` and `disconnect` events for already accessible devices */ private startListeningToConnectionEvents(): void { - this.logger.debug("startListeningToConnectionEvents"); + this._logger.debug("startListeningToConnectionEvents"); - this.hidApi().map((hidApi) => { + this.hidApi.map((hidApi) => { hidApi.addEventListener( "connect", (event) => { - this.logger.debug("connection event", { data: { event } }); + this._logger.debug("connection event", { data: { event } }); }, - { signal: this.connectionListenersAbortController.signal }, + { signal: this._connectionListenersAbortController.signal }, ); hidApi.addEventListener( "disconnect", (event) => { - this.logger.debug("disconnect event", { data: { event } }); + this._logger.debug("disconnect event", { data: { event } }); }, - { signal: this.connectionListenersAbortController.signal }, + { signal: this._connectionListenersAbortController.signal }, ); }); } private stopListeningToConnectionEvents(): void { - this.logger.debug("stopListeningToConnectionEvents"); - this.connectionListenersAbortController.abort(); + this._logger.debug("stopListeningToConnectionEvents"); + this._connectionListenersAbortController.abort(); } /** @@ -257,13 +263,13 @@ export class WebUsbHidTransport implements UsbHidTransport { deviceId, }: { deviceId: DeviceId; - }): Promise> { - this.logger.debug("connect", { data: { deviceId } }); + }): Promise> { + this._logger.debug("connect", { data: { deviceId } }); - const internalDevice = this.internalDevicesById.get(deviceId); + const internalDevice = this._internalDevicesById.get(deviceId); if (!internalDevice) { - this.logger.error(`Unknown device ${deviceId}`); + this._logger.error(`Unknown device ${deviceId}`); return Left( new UnknownDeviceError(new Error(`Unknown device ${deviceId}`)), ); @@ -273,10 +279,10 @@ export class WebUsbHidTransport implements UsbHidTransport { await internalDevice.hidDevice.open(); } catch (error) { if (error instanceof DOMException && error.name === "InvalidStateError") { - this.logger.debug(`Device ${deviceId} is already opened`); + this._logger.debug(`Device ${deviceId} is already opened`); } else { const connectionError = new OpeningConnectionError(error as Error); - this.logger.debug(`Error while opening device: ${deviceId}`, { + this._logger.debug(`Error while opening device: ${deviceId}`, { data: { error }, }); Sentry.captureException(connectionError); @@ -284,13 +290,21 @@ export class WebUsbHidTransport implements UsbHidTransport { } } - internalDevice.connectedDevice = { + const { + discoveredDevice: { deviceModel }, + } = internalDevice; + + const deviceConnection = this._usbHidDeviceConnectionFactory.create( + internalDevice.hidDevice, + ); + const connectedDevice = new InternalConnectedDevice({ + sendApdu: deviceConnection.sendApdu, + deviceModel, id: deviceId, - deviceModel: internalDevice.discoveredDevice.deviceModel, - }; + type: "USB", + }); - // TODO: return a device session USB - return Right(internalDevice.connectedDevice); + return Right(connectedDevice); } /** diff --git a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts new file mode 100644 index 000000000..c8d6043e3 --- /dev/null +++ b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts @@ -0,0 +1,54 @@ +import { sessionStubBuilder } from "@internal/device-session/model/Session.stub"; +import { DefaultSessionService } from "@internal/device-session/service/DefaultSessionService"; +import { SessionService } from "@internal/device-session/service/SessionService"; +import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { GetConnectedDeviceUseCase } from "@internal/usb/use-case/GetConnectedDeviceUseCase"; +import { ConnectedDevice } from "@root/src"; + +let logger: LoggerPublisherService; +let sessionService: SessionService; + +const fakeSessionId = "fakeSessionId"; + +describe("GetConnectedDevice", () => { + beforeEach(() => { + logger = new DefaultLoggerPublisherService( + [], + "get-connected-device-use-case", + ); + sessionService = new DefaultSessionService(() => logger); + }); + + it("should retrieve an instance of ConnectedDevice", () => { + // given + const session = sessionStubBuilder({ id: fakeSessionId }); + sessionService.addSession(session); + const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger); + + // when + const response = useCase.execute({ + sessionId: fakeSessionId, + }); + + // then + expect(response).toBeInstanceOf(ConnectedDevice); + }); + + it("should retrieve correct device from session", () => { + // given + const session = sessionStubBuilder({ id: fakeSessionId }); + sessionService.addSession(session); + const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger); + + // when + const response = useCase.execute({ + sessionId: fakeSessionId, + }); + + // then + expect(response).toStrictEqual( + new ConnectedDevice({ internalConnectedDevice: session.connectedDevice }), + ); + }); +}); diff --git a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.ts b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.ts new file mode 100644 index 000000000..7e396634c --- /dev/null +++ b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.ts @@ -0,0 +1,48 @@ +import { inject, injectable } from "inversify"; + +import { ConnectedDevice } from "@api/usb/model/ConnectedDevice"; +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import { SessionId } from "@internal/device-session/model/Session"; +import type { SessionService } from "@internal/device-session/service/SessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export type GetConnectedDeviceUseCaseArgs = { + sessionId: SessionId; +}; + +/** + * Get a connected device from session id. + */ +@injectable() +export class GetConnectedDeviceUseCase { + private readonly _sessionService: SessionService; + private readonly _logger: LoggerPublisherService; + + constructor( + @inject(deviceSessionTypes.SessionService) + sessionService: SessionService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this._sessionService = sessionService; + this._logger = loggerFactory("GetConnectedDeviceUseCase"); + } + + execute({ sessionId }: GetConnectedDeviceUseCaseArgs): ConnectedDevice { + const deviceSession = this._sessionService.getSessionById(sessionId); + + return deviceSession.caseOf({ + Right: (session) => + new ConnectedDevice({ + internalConnectedDevice: session.connectedDevice, + }), + Left: (error) => { + this._logger.error("Error getting session", { + data: { error }, + }); + throw error; + }, + }); + } +}