diff --git a/apps/sample/src/app/device-actions/page.tsx b/apps/sample/src/app/device-actions/page.tsx new file mode 100644 index 000000000..c3ca43f9c --- /dev/null +++ b/apps/sample/src/app/device-actions/page.tsx @@ -0,0 +1,10 @@ +"use client"; +import React from "react"; + +import { DeviceActionsView } from "@/components/DeviceActionsView"; + +const DeviceActions: React.FC = () => { + return ; +}; + +export default DeviceActions; diff --git a/apps/sample/src/components/Block.tsx b/apps/sample/src/components/Block.tsx new file mode 100644 index 000000000..4ea212aa4 --- /dev/null +++ b/apps/sample/src/components/Block.tsx @@ -0,0 +1,10 @@ +import { Flex } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +export const Block = styled(Flex).attrs({ + flexDirection: "column", + backgroundColor: "opacityDefault.c05", + p: 5, + borderRadius: 2, + rowGap: 4, +})``; diff --git a/apps/sample/src/components/ClickableListItem.tsx b/apps/sample/src/components/ClickableListItem.tsx new file mode 100644 index 000000000..e195acd7c --- /dev/null +++ b/apps/sample/src/components/ClickableListItem.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Flex, Text, Icons } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +const ListItemWrapper = styled(Flex)` + opacity: 0.8; + + &:hover { + opacity: 1; + } + + cursor: pointer; +`; + +export const ClickableListItem: React.FC<{ + title: string; + description: string; + onClick(): void; +}> = ({ title, description, onClick }) => { + return ( + + + + {title} + + + {description} + + + + + ); +}; diff --git a/apps/sample/src/components/CommandsView/Command.tsx b/apps/sample/src/components/CommandsView/Command.tsx index fba4006fc..9a8588746 100644 --- a/apps/sample/src/components/CommandsView/Command.tsx +++ b/apps/sample/src/components/CommandsView/Command.tsx @@ -1,35 +1,12 @@ import React, { useCallback, useEffect, useState } from "react"; -import { - Flex, - Text, - Icons, - Drawer, - Button, - InfiniteLoader, -} from "@ledgerhq/react-ui"; -import styled from "styled-components"; +import { Flex, Icons, Button, InfiniteLoader } from "@ledgerhq/react-ui"; import { CommandForm, ValueSelector } from "./CommandForm"; import { FieldType } from "@/hooks/useForm"; import { CommandResponse, CommandResponseProps } from "./CommandResponse"; - -const Wrapper = styled(Flex)` - opacity: 0.8; - - &:hover { - opacity: 1; - } - - cursor: pointer; -`; - -const Container = styled(Flex).attrs({ - flexDirection: "column", - backgroundColor: "opacityDefault.c05", - p: 5, - borderRadius: 2, - rowGap: 4, -})``; +import { Block } from "../Block"; +import { ClickableListItem } from "../ClickableListItem"; +import { StyledDrawer } from "../StyledDrawer"; export type CommandProps< CommandArgs extends Record | void, @@ -51,7 +28,7 @@ export function Command< const [values, setValues] = useState(initialValues); - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = useState(false); const [responses, setResponses] = useState[]>( [], @@ -105,81 +82,66 @@ export function Command< }, [responses]); return ( - - - - {title} - - - {description} - - - - - - + + + + + + + + + {responses.map(({ args, date, response, loading }, index) => ( + + ))} + + - - - - {responses.map(({ args, date, response, loading }, index) => ( - - ))} - - - - - - + Clear responses + + + + ); } diff --git a/apps/sample/src/components/CommandsView/CommandForm.tsx b/apps/sample/src/components/CommandsView/CommandForm.tsx index 40123a3a2..822b6f957 100644 --- a/apps/sample/src/components/CommandsView/CommandForm.tsx +++ b/apps/sample/src/components/CommandsView/CommandForm.tsx @@ -25,10 +25,12 @@ export function CommandForm>({ initialValues, onChange, valueSelector, + disabled, }: { initialValues: Args; onChange: (values: Args) => void; valueSelector?: ValueSelector; + disabled?: boolean; }) { const { formValues, setFormValue } = useForm(initialValues); @@ -54,6 +56,7 @@ export function CommandForm>({ {valueSelector?.[key] ? ( val.value === value)} isMulti={false} @@ -67,6 +70,7 @@ export function CommandForm>({ name="key" checked={value} onChange={() => setFormValue(key, !value)} + disabled={disabled} /> ) : typeof value === "string" ? ( >({ value={value} placeholder={key} onChange={(newVal) => setFormValue(key, newVal)} + disabled={disabled} /> ) : ( >({ placeholder={key} onChange={(newVal) => setFormValue(key, newVal ?? 0)} type="number" + disabled={disabled} /> )} diff --git a/apps/sample/src/components/CommandsView/index.tsx b/apps/sample/src/components/CommandsView/index.tsx index 14ffaf238..0d1e864db 100644 --- a/apps/sample/src/components/CommandsView/index.tsx +++ b/apps/sample/src/components/CommandsView/index.tsx @@ -1,6 +1,5 @@ import React, { useMemo } from "react"; -import { Divider, Flex, Grid, Text } from "@ledgerhq/react-ui"; -import styled from "styled-components"; +import { Grid } from "@ledgerhq/react-ui"; import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; import Command, { CommandProps } from "./Command"; @@ -23,28 +22,7 @@ import { import { useRouter } from "next/navigation"; import { BatteryStatusType } from "@ledgerhq/device-sdk-core/src/api/command/os/GetBatteryStatusCommand.js"; import { getValueSelectorFromEnum } from "./CommandForm"; - -const Root = styled(Flex).attrs({ mx: 15, mt: 10, mb: 5 })` - flex-direction: column; - flex: 1; - justify-content: center; - align-items: center; -`; - -const Container = styled(Flex)` - height: 100%; - width: 100%; - flex-direction: column; - border-radius: 12px; -`; - -const Header = styled(Flex).attrs({ py: 6 })``; - -const Title = styled(Text).attrs({ - variant: "h5Inter", - fontWeight: "semiBold", - fontSize: 18, -})``; +import { PageWithHeader } from "../PageWithHeader"; export const CommandsView: React.FC = () => { const { @@ -150,25 +128,15 @@ export const CommandsView: React.FC = () => { } return ( - - -
- Commands -
- - - {commands.map((command) => ( - - ))} - -
-
+ + + {commands.map((command) => ( + + ))} + + ); }; diff --git a/apps/sample/src/components/DeviceActionsView/DeviceAction.tsx b/apps/sample/src/components/DeviceActionsView/DeviceAction.tsx new file mode 100644 index 000000000..76fcf16b5 --- /dev/null +++ b/apps/sample/src/components/DeviceActionsView/DeviceAction.tsx @@ -0,0 +1,241 @@ +import { + type SdkError, + type ExecuteDeviceActionReturnType, + type DeviceActionIntermediateValue, +} from "@ledgerhq/device-sdk-core"; +import { CommandForm, ValueSelector } from "../CommandsView/CommandForm"; +import { FieldType } from "@/hooks/useForm"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { ClickableListItem } from "@/components/ClickableListItem"; +import { StyledDrawer } from "@/components//StyledDrawer"; +import { Block } from "@/components/Block"; +import { + Flex, + Button, + Icons, + InfiniteLoader, + Divider, + Switch, +} from "@ledgerhq/react-ui"; +import { + DeviceActionResponseProps, + DeviceActionResponse, +} from "./DeviceActionResponse"; + +export type DeviceActionProps< + Output, + Input extends Record, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +> = { + title: string; + description: string; + executeDeviceAction: ( + args: Input, + debug?: boolean, + ) => ExecuteDeviceActionReturnType; + initialValues: Input; + valueSelector?: ValueSelector; +}; + +export function DeviceActionDrawer< + Output, + Input extends Record, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +>(props: DeviceActionProps) { + const { initialValues, executeDeviceAction, valueSelector } = props; + + const nonce = useRef(-1); + + const [values, setValues] = useState(initialValues); + const [inspect, setInspect] = useState(false); + + const [responses, setResponses] = useState< + DeviceActionResponseProps[] + >([]); + + const [loading, setLoading] = useState(false); + + const cancelDeviceActionRef = useRef(() => {}); + + const handleClickExecute = useCallback(() => { + setLoading(true); + + const id = ++nonce.current; + + const { cancel, observable } = executeDeviceAction(values, inspect); + + cancelDeviceActionRef.current = cancel; + + const handleDeviceActionDone = () => { + setLoading(false); + cancelDeviceActionRef.current = () => {}; + }; + + observable.subscribe({ + next: (deviceActionState) => { + setResponses((prev) => [ + ...prev, + { + args: values, + date: new Date(), + deviceActionState, + loading: false, + id, + }, + ]); + }, + error: (error) => { + setResponses((prev) => [ + ...prev, + { + args: values, + date: new Date(), + error, + loading: false, + id, + }, + ]); + handleDeviceActionDone(); + }, + complete: () => { + handleDeviceActionDone(); + }, + }); + }, [values, executeDeviceAction, inspect]); + + const handleClickClear = useCallback(() => { + setResponses([]); + }, []); + + const handleClickCancel = useCallback(() => { + cancelDeviceActionRef.current(); + }, []); + + const responseBoxRef = useRef(null); + + useEffect(() => { + // scroll response box to bottom + if (responseBoxRef.current) { + responseBoxRef.current.scrollTop = responseBoxRef.current.scrollHeight; + } + }, [responses]); + + useEffect(() => { + () => cancelDeviceActionRef.current(); + }, []); + + return ( + <> + + + + setInspect((d) => !d)} + label="Inspect (dev tools)" + name="Inspect" + /> + + + + + + + + + {responses.map((response, index, arr) => { + const isLatest = index === arr.length - 1; + return ( + + + + + ); + })} + + + + + ); +} + +export function DeviceAction< + Output, + Input extends Record, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +>(props: DeviceActionProps) { + const { title, description } = props; + + const [isOpen, setIsOpen] = useState(false); + const openDrawer = useCallback(() => { + setIsOpen(true); + }, []); + + const closeDrawer = useCallback(() => { + setIsOpen(false); + }, []); + + return ( + <> + + + + + + ); +} diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx new file mode 100644 index 000000000..1e1a1e86b --- /dev/null +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx @@ -0,0 +1,108 @@ +import { + type DeviceActionState, + DeviceActionStatus, +} from "@ledgerhq/device-sdk-core"; +import { FieldType } from "@/hooks/useForm"; +import React from "react"; +import { Flex, Icons, Tag, Text, Tooltip } from "@ledgerhq/react-ui"; +import { + UserInteractionRequired, + DeviceActionIntermediateValue, +} from "@ledgerhq/device-sdk-core"; + +export type DeviceActionResponseProps = { + args: Record; + date: Date; + id: number; +} & ( + | { + deviceActionState: DeviceActionState; + } + | { error: unknown } +); + +export function DeviceActionResponse< + Output, + Error, + IntermediateValue extends DeviceActionIntermediateValue, +>( + props: DeviceActionResponseProps & { + isLatest: boolean; + }, +) { + const { args, date, isLatest, id } = props; + + const isError = "error" in props; + + return ( + + + Arguments:{"\n"} + {JSON.stringify(args, null, 2)} + + } + > + + (id: {id}) {date.toLocaleTimeString()} {isError ? "Error" : ""} + + + + {JSON.stringify( + isError ? props.error : props.deviceActionState, + null, + 2, + )} + + {!isError && + props.deviceActionState.status === DeviceActionStatus.Pending ? ( + props.deviceActionState.intermediateValue?.requiredUserInteraction !== + UserInteractionRequired.None ? ( + + + User action required: + + { + props.deviceActionState.intermediateValue + .requiredUserInteraction + } + + + ) : null + ) : null} + + ); +} diff --git a/apps/sample/src/components/DeviceActionsView/index.tsx b/apps/sample/src/components/DeviceActionsView/index.tsx new file mode 100644 index 000000000..7e5e79923 --- /dev/null +++ b/apps/sample/src/components/DeviceActionsView/index.tsx @@ -0,0 +1,71 @@ +import { useSdk } from "@/providers/DeviceSdkProvider"; +import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { PageWithHeader } from "@/components/PageWithHeader"; +import { Grid } from "@ledgerhq/react-ui"; +import { DeviceAction, DeviceActionProps } from "./DeviceAction"; +import { + OpenAppDeviceAction, + OpenAppDAError, + OpenAppDAInput, + OpenAppDAIntermediateValue, + OpenAppDAOutput, +} from "@ledgerhq/device-sdk-core"; + +export const DeviceActionsView: React.FC = () => { + const { + state: { selectedId: selectedSessionId }, + } = useDeviceSessionsContext(); + const router = useRouter(); + const sdk = useSdk(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deviceActions: DeviceActionProps[] = useMemo( + () => + !selectedSessionId + ? [] + : [ + { + title: "Open app", + description: + "Perform all the actions necessary to open an app on the device", + executeDeviceAction: ({ appName }, inspect) => { + const deviceAction = new OpenAppDeviceAction({ + input: { appName }, + inspect, + }); + return sdk.executeDeviceAction({ + sessionId: selectedSessionId, + deviceAction, + }); + }, + initialValues: { appName: "" }, + } satisfies DeviceActionProps< + OpenAppDAOutput, + OpenAppDAInput, + OpenAppDAError, + OpenAppDAIntermediateValue + >, + ], + [], + ); + + if (!selectedSessionId) { + router.replace("/"); + return null; + } + + return ( + + + {deviceActions.map((deviceAction) => ( + + ))} + + + ); +}; diff --git a/apps/sample/src/components/Menu/index.tsx b/apps/sample/src/components/Menu/index.tsx index a39ca7361..e201e6eb2 100644 --- a/apps/sample/src/components/Menu/index.tsx +++ b/apps/sample/src/components/Menu/index.tsx @@ -27,7 +27,9 @@ export const Menu: React.FC = () => { - Device action + router.push("device-actions")}> + Device actions + diff --git a/apps/sample/src/components/PageWithHeader.tsx b/apps/sample/src/components/PageWithHeader.tsx new file mode 100644 index 000000000..024e4fa3e --- /dev/null +++ b/apps/sample/src/components/PageWithHeader.tsx @@ -0,0 +1,43 @@ +import { Divider, Flex, Text } from "@ledgerhq/react-ui"; +import styled from "styled-components"; + +import React from "react"; + +const Root = styled(Flex).attrs({ mx: 15, mt: 10, mb: 5 })` + flex-direction: column; + flex: 1; + justify-content: center; + align-items: center; +`; + +const Container = styled(Flex)` + height: 100%; + width: 100%; + flex-direction: column; + border-radius: 12px; +`; + +const Header = styled(Flex).attrs({ py: 6 })``; + +const Title = styled(Text).attrs({ + variant: "h5Inter", + fontWeight: "semiBold", + fontSize: 18, +})``; + +export const PageWithHeader: React.FC<{ + title: string; + children: React.ReactNode; +}> = ({ title, children }) => { + return ( + + +
+ {title} +
+ + {children} +
+
+ ); +}; diff --git a/apps/sample/src/components/StyledDrawer.tsx b/apps/sample/src/components/StyledDrawer.tsx new file mode 100644 index 000000000..0a5988bd0 --- /dev/null +++ b/apps/sample/src/components/StyledDrawer.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Flex, Text, Drawer } from "@ledgerhq/react-ui"; + +export const StyledDrawer: React.FC<{ + title: string; + description: string; + big: boolean; + isOpen: boolean; + onClose(): void; + children: React.ReactNode; +}> = ({ title, description, big, isOpen, onClose, children }) => { + return ( + + + + {description} + + {children} + + + ); +}; diff --git a/packages/core/README.md b/packages/core/README.md index 2cc428bf3..a55a5b783 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,6 +19,8 @@ - [Get OS Version](#get-os-version) - [Get App and Version](#get-app-and-version) - [Building a Custom Command](#building-a-custom-command) + - [Executing a device action](#executing-a-device-action) + - [Open App Device Action](#open-app-device-action) - [Example in React](#example-in-react) ## Description @@ -243,6 +245,75 @@ This is strongly recommended over direct usage of `sendApdu`. Check the existing commands for a variety of examples. +### Executing a device action + +Device actions define a succession of commands to be sent to the device. + +They are useful for actions that require user interaction, like opening an app, +or approving a transaction. + +The result of a device action execution is an observable that will emit different states of the action execution. These states contain information about the current status of the action, some intermediate values like the user action required, and the final result. + +#### Open App Device Action + +```ts +import { OpenAppDeviceAction, OpenAppDAState } from "@ledgerhq/device-sdk-core"; + +const openAppDeviceAction = new OpenAppDeviceAction({ appName: "Bitcoin" }); + +const { observable, cancel } = await sdk.executeDeviceAction({ + deviceAction: openAppDeviceAction, + command, +}); + +observable.subscribe({ + next: (state: OpenAppDAState) => { + switch (state.status) { + case DeviceActionStatus.NotStarted: + console.log("Action not started yet"); + break; + case DeviceActionStatus.Pending: + const { + intermediateValue: { userActionRequired }, + } = state; + switch (userActionRequired) { + case UserActionRequiredType.None: + console.log("No user action required"); + break; + case UserActionRequiredType.ConfirmOpenApp: + console.log( + "The user should confirm the app opening on the device", + ); + break; + case UserActionRequiredType.UnlockDevice: + console.log("The user should unlock the device"); + break; + default: + /** + * you should make sure that you handle all the possible user action + * required types by displaying them to the user. + */ + throw new Exception("Unhandled user action required"); + break; + } + console.log("Action is pending"); + break; + case DeviceActionStatus.Stopped: + console.log("Action has been stopped"); + break; + case DeviceActionStatus.Completed: + const { output } = state; + console.log("Action has been completed", output); + break; + case DeviceActionStatus.Error: + const { error } = state; + console.log("An error occurred during the action", error); + break; + } + }, +}); +``` + ### Example in React Check [the sample app](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/apps/sample) for an advanced example showcasing all possible usages of the device SDK in a React app. diff --git a/packages/core/package.json b/packages/core/package.json index a9b8d185e..a64968b74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,13 +48,15 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "xstate": "^5.13.2" }, "devDependencies": { "@ledgerhq/eslint-config-dsdk": "workspace:*", "@ledgerhq/jest-config-dsdk": "workspace:*", "@ledgerhq/prettier-config-dsdk": "workspace:*", "@ledgerhq/tsconfig-dsdk": "workspace:*", + "@statelyai/inspect": "^0.3.1", "@types/semver": "^7.5.8", "@types/uuid": "^10.0.0", "@types/w3c-web-hid": "^1.0.6", diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index 589072948..634dbeeaa 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -6,6 +6,10 @@ import { SendCommandUseCase, SendCommandUseCaseArgs, } from "@api/command/use-case/SendCommandUseCase"; +import { + ExecuteDeviceActionUseCase, + ExecuteDeviceActionUseCaseArgs, +} from "@api/device-action/use-case/ExecuteDeviceActionUseCase"; import { ApduResponse } from "@api/device-session/ApduResponse"; import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; import { DeviceSessionId } from "@api/device-session/types"; @@ -34,6 +38,13 @@ import { } from "@internal/usb/use-case/GetConnectedDeviceUseCase"; import { makeContainer, MakeContainerProps } from "@root/src/di"; +import { + DeviceActionIntermediateValue, + ExecuteDeviceActionReturnType, +} from "./device-action/DeviceAction"; +import { deviceActionTypes } from "./device-action/di/deviceActionTypes"; +import { SdkError } from "./Error"; + /** * The main class to interact with the SDK. * @@ -134,6 +145,26 @@ export class DeviceSdk { .execute(args); } + executeDeviceAction< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, + >( + args: ExecuteDeviceActionUseCaseArgs< + Output, + Input, + Error, + IntermediateValue + >, + ): ExecuteDeviceActionReturnType { + return this.container + .get( + deviceActionTypes.ExecuteDeviceActionUseCase, + ) + .execute(args); + } + /** * Gets the connected from its device session ID. * diff --git a/packages/core/src/api/command/Command.ts b/packages/core/src/api/command/Command.ts index 588fd13e2..0e356940f 100644 --- a/packages/core/src/api/command/Command.ts +++ b/packages/core/src/api/command/Command.ts @@ -9,6 +9,12 @@ import { ApduResponse } from "@api/device-session/ApduResponse"; * @template Args - The type of the arguments passed to the command (optional). */ export interface Command { + /** + * Indicates whether the command triggers a disconnection from the device when + * it succeeds. + */ + readonly triggersDisconnection?: boolean; + /** * Gets the APDU (Application Protocol Data Unit) for the command. * diff --git a/packages/core/src/api/command/os/CloseAppCommand.ts b/packages/core/src/api/command/os/CloseAppCommand.ts index de9b00611..c16c1b6db 100644 --- a/packages/core/src/api/command/os/CloseAppCommand.ts +++ b/packages/core/src/api/command/os/CloseAppCommand.ts @@ -12,6 +12,8 @@ import { ApduResponse } from "@api/device-session/ApduResponse"; export class CloseAppCommand implements Command { args = undefined; + readonly triggersDisconnection = true; + getApdu(): Apdu { const closeAppApduArgs: ApduBuilderArgs = { cla: 0xb0, diff --git a/packages/core/src/api/command/os/OpenAppCommand.ts b/packages/core/src/api/command/os/OpenAppCommand.ts index 787794eda..e4a972906 100644 --- a/packages/core/src/api/command/os/OpenAppCommand.ts +++ b/packages/core/src/api/command/os/OpenAppCommand.ts @@ -16,6 +16,8 @@ export type OpenAppArgs = { export class OpenAppCommand implements Command { args: OpenAppArgs; + readonly triggersDisconnection = true; + constructor(args: OpenAppArgs) { this.args = args; } diff --git a/packages/core/src/api/device-action/DeviceAction.ts b/packages/core/src/api/device-action/DeviceAction.ts new file mode 100644 index 000000000..3677945b3 --- /dev/null +++ b/packages/core/src/api/device-action/DeviceAction.ts @@ -0,0 +1,36 @@ +import { Observable } from "rxjs"; + +import { Command } from "@api/command/Command"; +import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; +import { SdkError } from "@api/Error"; + +import { DeviceActionState } from "./model/DeviceActionState"; + +export type InternalApi = { + sendCommand: ( + command: Command, + ) => Promise; + getDeviceSessionState: () => DeviceSessionState; +}; + +export type DeviceActionIntermediateValue = { + requiredUserInteraction: string; +}; + +export type ExecuteDeviceActionReturnType = { + observable: Observable>; + cancel(): void; +}; + +export interface DeviceAction< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +> { + readonly input: Input; + + _execute( + params: InternalApi, + ): ExecuteDeviceActionReturnType; +} diff --git a/packages/core/src/api/device-action/__test-utils__/testDeviceActionStates.ts b/packages/core/src/api/device-action/__test-utils__/testDeviceActionStates.ts new file mode 100644 index 000000000..283eaef5a --- /dev/null +++ b/packages/core/src/api/device-action/__test-utils__/testDeviceActionStates.ts @@ -0,0 +1,43 @@ +import { + DeviceAction, + DeviceActionIntermediateValue, + InternalApi, +} from "@api/device-action/DeviceAction"; +import { DeviceActionState } from "@api/device-action/model/DeviceActionState"; +import { SdkError } from "@api/Error"; + +/** + * Test that the states emitted by a device action match the expected states. + * @param deviceAction The device action to test. + * @param expectedStates The expected states. + * @param done The Jest done callback. + */ +export function testDeviceActionStates< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +>( + deviceAction: DeviceAction, + expectedStates: Array>, + done: jest.DoneCallback, +) { + const observedStates: Array< + DeviceActionState + > = []; + + const { observable, cancel } = deviceAction._execute({} as InternalApi); + observable.subscribe({ + next: (state) => { + observedStates.push(state); + }, + error: (error) => { + done(error); + }, + complete: () => { + expect(observedStates).toEqual(expectedStates); + done(); + }, + }); + return { observable, cancel }; +} diff --git a/packages/core/src/api/device-action/di/deviceActionModule.test.ts b/packages/core/src/api/device-action/di/deviceActionModule.test.ts new file mode 100644 index 000000000..7c2eef59a --- /dev/null +++ b/packages/core/src/api/device-action/di/deviceActionModule.test.ts @@ -0,0 +1,56 @@ +import { Container } from "inversify"; + +import { ExecuteDeviceActionUseCase } from "@api/device-action/use-case/ExecuteDeviceActionUseCase"; +import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; +import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; +import { StubUseCase } from "@root/src/di.stub"; + +import { deviceActionModuleFactory } from "./deviceActionModule"; +import { deviceActionTypes } from "./deviceActionTypes"; + +describe("deviceActionModule", () => { + describe("Default", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = deviceActionModuleFactory(); + container = new Container(); + container.load(mod, deviceSessionModuleFactory(), loggerModuleFactory()); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return non-stubbed executeDeviceAction usecase", () => { + const executeDeviceActionUseCase = + container.get( + deviceActionTypes.ExecuteDeviceActionUseCase, + ); + expect(executeDeviceActionUseCase).toBeInstanceOf( + ExecuteDeviceActionUseCase, + ); + }); + }); + + describe("Stubbed", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = deviceActionModuleFactory({ stub: true }); + container = new Container(); + container.load(mod); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return stubbed executeDeviceAction usecase", () => { + const executeDeviceActionUseCase = container.get( + deviceActionTypes.ExecuteDeviceActionUseCase, + ); + expect(executeDeviceActionUseCase).toBeInstanceOf(StubUseCase); + }); + }); +}); diff --git a/packages/core/src/api/device-action/di/deviceActionModule.ts b/packages/core/src/api/device-action/di/deviceActionModule.ts new file mode 100644 index 000000000..6d63981a0 --- /dev/null +++ b/packages/core/src/api/device-action/di/deviceActionModule.ts @@ -0,0 +1,32 @@ +import { ContainerModule } from "inversify"; + +import { ExecuteDeviceActionUseCase } from "@api/device-action/use-case/ExecuteDeviceActionUseCase"; +import { StubUseCase } from "@root/src/di.stub"; + +import { deviceActionTypes } from "./deviceActionTypes"; + +type DeviceActionModuleArgs = Partial<{ + stub: boolean; +}>; + +export const deviceActionModuleFactory = ({ + stub = false, +}: DeviceActionModuleArgs = {}) => + new ContainerModule( + ( + bind, + _unbind, + _isBound, + rebind, + _unbindAsync, + _onActivation, + _onDeactivation, + ) => { + bind(deviceActionTypes.ExecuteDeviceActionUseCase).to( + ExecuteDeviceActionUseCase, + ); + if (stub) { + rebind(deviceActionTypes.ExecuteDeviceActionUseCase).to(StubUseCase); + } + }, + ); diff --git a/packages/core/src/api/device-action/di/deviceActionTypes.ts b/packages/core/src/api/device-action/di/deviceActionTypes.ts new file mode 100644 index 000000000..d1a6bbdb6 --- /dev/null +++ b/packages/core/src/api/device-action/di/deviceActionTypes.ts @@ -0,0 +1,3 @@ +export const deviceActionTypes = { + ExecuteDeviceActionUseCase: Symbol.for("ExecuteDeviceActionUseCase"), +}; diff --git a/packages/core/src/api/device-action/model/DeviceActionState.ts b/packages/core/src/api/device-action/model/DeviceActionState.ts new file mode 100644 index 000000000..43843c50e --- /dev/null +++ b/packages/core/src/api/device-action/model/DeviceActionState.ts @@ -0,0 +1,17 @@ +/** + * The status of a device action. + */ +export enum DeviceActionStatus { + NotStarted = "not-started", + Pending = "pending", + Stopped = "stopped", + Completed = "completed", + Error = "error", +} + +export type DeviceActionState = + | { status: DeviceActionStatus.NotStarted } + | { status: DeviceActionStatus.Pending; intermediateValue: IntermediateValue } + | { status: DeviceActionStatus.Stopped } + | { status: DeviceActionStatus.Completed; output: Output } + | { status: DeviceActionStatus.Error; error: Error }; diff --git a/packages/core/src/api/device-action/model/UserInteractionRequired.ts b/packages/core/src/api/device-action/model/UserInteractionRequired.ts new file mode 100644 index 000000000..f11077f70 --- /dev/null +++ b/packages/core/src/api/device-action/model/UserInteractionRequired.ts @@ -0,0 +1,11 @@ +/** + * The user interaction required on the device to move further in a device action. + * This is used to inform the user about the action they need to take on the device. + */ +export enum UserInteractionRequired { + None = "none", + UnlockDevice = "unlock-device", + AllowSecureConnection = "allow-secure-connection", + ConfirmOpenApp = "confirm-open-app", + SignTransaction = "sign-transaction", +} diff --git a/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts new file mode 100644 index 000000000..9399dcd5c --- /dev/null +++ b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts @@ -0,0 +1,365 @@ +import { InvalidStatusWordError } from "@api/command/Errors"; +import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; +import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { DeviceSessionStateType } from "@api/device-session/DeviceSessionState"; +import { DeviceStatus } from "@api/index"; + +import { DeviceLockedError } from "./errors"; +import { OpenAppDeviceAction } from "./OpenAppDeviceAction"; +import { OpenAppDAState } from "./types"; + +describe("OpenAppDeviceAction", () => { + const getAppAndVersionMock = jest.fn(); + const openAppMock = jest.fn(); + const closeAppMock = jest.fn(); + const getDeviceSessionStateMock = jest.fn(); + + function extractDependenciesMock() { + return { + getDeviceSessionState: getDeviceSessionStateMock, + getAppAndVersion: getAppAndVersionMock, + openApp: openAppMock, + closeApp: closeAppMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should end in an error if the device is not onboarded", (done) => { + // TODO: the logic for that test case is not implemented yet + done(); + }); + + it("should end in an error if the device is locked", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.LOCKED, + currentApp: "mockedCurrentApp", + }); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Error, + error: new DeviceLockedError(), + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in an error if getAppAndVersion throws an error", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "mockedCurrentApp", + }); + getAppAndVersionMock.mockRejectedValue( + new InvalidStatusWordError("mocked error"), + ); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("mocked error"), + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in a success if the app is already opened", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "Bitcoin", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "Bitcoin", + version: "0.0.0", + }); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Completed, + output: undefined, + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in a success if the dashboard is open and open app succeeds", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "BOLOS", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "BOLOS", + version: "0.0.0", + }); + openAppMock.mockResolvedValue(undefined); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // open app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Completed, + output: undefined, + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in an error if the dashboard is open and open app throws an error", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "BOLOS", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "BOLOS", + version: "0.0.0", + }); + openAppMock.mockRejectedValue(new InvalidStatusWordError("mocked error")); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // open app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("mocked error"), + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in an error if another app is open, and close app throws", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "AnotherApp", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "AnotherApp", + version: "0.0.0", + }); + closeAppMock.mockRejectedValue(new InvalidStatusWordError("mocked error")); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // close app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("mocked error"), + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in an error if another app is open, close app succeeds but open app throws", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "AnotherApp", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "AnotherApp", + version: "0.0.0", + }); + closeAppMock.mockResolvedValue(undefined); + openAppMock.mockRejectedValue(new InvalidStatusWordError("mocked error")); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // close app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // open app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("mocked error"), + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should end in a success if another app is open, close app succeeds and open app succeeds", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "AnotherApp", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "AnotherApp", + version: "0.0.0", + }); + closeAppMock.mockResolvedValue(undefined); + openAppMock.mockResolvedValue(undefined); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // close app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, // open app + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Completed, + output: undefined, + }, + ]; + + testDeviceActionStates(openAppDeviceAction, expectedStates, done); + }); + + it("should emit a stopped state if the action is cancelled", (done) => { + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "AnotherApp", + }); + getAppAndVersionMock.mockResolvedValue({ + app: "AnotherApp", + version: "0.0.0", + }); + + const openAppDeviceAction = new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }); + openAppDeviceAction._unsafeSetExtractDependencies(extractDependenciesMock); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, // get app and version + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Stopped, + }, + ]; + + const { cancel } = testDeviceActionStates( + openAppDeviceAction, + expectedStates, + done, + ); + cancel(); + }); +}); diff --git a/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.ts b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.ts new file mode 100644 index 000000000..b00f8bbc6 --- /dev/null +++ b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.ts @@ -0,0 +1,319 @@ +import { Left, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { InternalApi } from "@api/device-action/DeviceAction"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { StateMachineTypes } from "@api/device-action/xstate-utils/StateMachineTypes"; +import { XStateDeviceAction } from "@api/device-action/xstate-utils/XStateDeviceAction"; +import { + DeviceSessionState, + DeviceSessionStateType, +} from "@api/device-session/DeviceSessionState"; +import { + CloseAppCommand, + GetAppAndVersionCommand, + OpenAppCommand, +} from "@api/index"; +import { DeviceStatus } from "@api/index"; + +import { DeviceLockedError, DeviceNotOnboardedError } from "./errors"; +import { + OpenAppDAError, + OpenAppDAInput, + OpenAppDAIntermediateValue, + OpenAppDAOutput, +} from "./types"; + +type OpenAppStateMachineInternalState = { + currentlyRunningApp: string | null; + error: OpenAppDAError | null; +}; + +export type MachineDependencies = { + getAppAndVersion: () => Promise<{ app: string; version: string }>; + closeApp: () => Promise; + openApp: (arg0: { input: { appName: string } }) => Promise; + getDeviceSessionState: () => DeviceSessionState; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class OpenAppDeviceAction extends XStateDeviceAction< + OpenAppDAOutput, + OpenAppDAInput, + OpenAppDAError, + OpenAppDAIntermediateValue, + OpenAppStateMachineInternalState +> { + makeStateMachine(internalApi: InternalApi) { + type types = StateMachineTypes< + OpenAppDAOutput, + OpenAppDAInput, + OpenAppDAError, + OpenAppDAIntermediateValue, + OpenAppStateMachineInternalState + >; + + const { getAppAndVersion, closeApp, openApp, getDeviceSessionState } = + this.extractDependencies(internalApi); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + actors: { + getAppAndVersion: fromPromise(getAppAndVersion), + closeApp: fromPromise(closeApp), + openApp: fromPromise(openApp), + }, + guards: { + isDeviceOnboarded: () => true, // TODO: we don't have this info for now, this can be derived from the "flags" obtained in the getVersion command + isDeviceUnlocked: () => + getDeviceSessionState().deviceStatus !== DeviceStatus.LOCKED, + isRequestedAppOpen: ({ context }: { context: types["context"] }) => { + if (context._internalState.currentlyRunningApp === null) + throw new Error("context.currentlyRunningApp === null"); + return ( + context._internalState.currentlyRunningApp === context.input.appName + ); + }, + isDashboardOpen: ({ context }: { context: types["context"] }) => { + if (context._internalState.currentlyRunningApp === null) + throw new Error("context.currentlyRunningApp === null"); + return context._internalState.currentlyRunningApp === "BOLOS"; + }, + }, + actions: { + assignErrorDeviceNotOnboarded: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: new DeviceNotOnboardedError(), + }), + }), + assignErrorDeviceLocked: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: new DeviceLockedError(), + }), + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.UnlockDevice, + }, + }), + assignUserActionNeededOpenApp: assign({ + intermediateValue: (_) => + ({ + ..._.context.intermediateValue, + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }) satisfies types["context"]["intermediateValue"], + }), + assignNoUserActionNeeded: assign({ + intermediateValue: (_) => + ({ + ..._.context.intermediateValue, + requiredUserInteraction: UserInteractionRequired.None, + }) satisfies types["context"]["intermediateValue"], + }), + assignErrorFromEvent: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: _.event["error"], // FIXME: add a typeguard + }), + }), + assignNoError: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: null, + }), + }), + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QHkAOYB2BBVqAiYAbgJYDGYWpALsQPYYB0BJ5ASmAIYQCeAxANoAGALqJQqWrGI16YkAA9EAdgCMDFQE4ALIMEBmLQDZj+gKwaANCG6IVADi3q7xoyq0qPG0wF9vVtJg4+ERkFNR0jMgYAEa0HABOEMQYUADCABZgpADWAiJyElIyGHKKCBoMShqqgloATG56pnoGVjYIeoJKDHamdnrOqu5KTT5+IAHYuMyhlMUMUbEJSSkZWbn8KqJIIIXSEaWIdYZ1DDp6hn3GnUpavW1Hveotel51pkrmGnW+-uhTwRYYXmABlaDlkmlMjk8ttxJJ9rIdmU3AwNF5TJo9FUTipaloHgh6oYGIJTOTarcRloNIZfhN-kEZuQ5hEGGCIatoRstgUEcVDghjqctK47EolIJqvVaYTzgwWmS3HUNOLnL16ZMmSEWeF6AwggAbMgcYpYQgcYiGjjRQ1gXgQehgBjJQi0bLOrXTHXAtlGk1mi1Wm12hCu8GmiJCYTRvlFA7I5SkhrOQwaFRNZwaMmEy7dcxdXQ6D7NPSaxneoGs-X+0iR+jmy3W232sDxeK0eIMVDWqgAM07AFsFhXAbM9Yxa-XsEHm6Hw3XitHYzs9gLE0LLqSWloDHY6nYvKNCc1TOpjtpDLo6voGuXApXx-Mp8U1jCV-D40jQGU9GipQMnxKO8FyGCohiEr0f7gYIdiCGm9SmEYSj3gCzK+jWuDGouERvjycK7PyCY-kcIyVO4gjgaYLgqhB1iIFB6hXs4WIqKo4GodqVYTkwHCwOkSyJHhsJxoiJQbu8ah6CBej2AeIzvISeLwQwlxsRoBjwVe5icY+urzHgfECXEQncgIvKrkR34KIgphkqShj9PYGmfHBlj0QgykkmpIwOGxoqwbpY76WyqSGpIFBYQGEQOk6LoYG6Hojg+wUYYwYURS+ERhglEZLiIH6EV+4kkUSdh2Aq3zgX5FzOaYSnOZUKiYroqgDIYu5Beh1bpeFsCRT20X0LwbYdl2PamgO8TDl6qU9QwGX9Vl9A5W6OH0Mu+SWcVgrARV1Fweq9j6PYSnwWoqiluKXiGJ8ZbjLN3U8bN2HTrFGDOuGSWPT680vUNGCrXlUYFVtn5iYKdTAQw5jAR8rFQ3USlaEhCradRHjtbJXW-c9o6vcUI3tp23a9lNM2jk98z-etgMLtOm0EWuxE2QgYGOLB9S9F0aaHnYSmyacdQqrU2nVCcWg49xz5RbT7BcHwhXM9ZZSqKplFIcBAy9DiSlXt07wSu4TToh193jBgtAQHAcg-dLLPKyVrMALSo5K0mwS4lFkkjHkXD0XifFewtuLddIPZTuMGT68s8KJ66le4MPfKKkoSniN4qEpdR-qqXhSmSpi0X0UtPmyiwmSsULrPHLNlDnpJKLdGluFUWZaEohJ1B3MN4ioB6yZKcH3X8KVU2yHLZJCeG1yriDYk4YH7sYxg3qohIXGedkSgecnaAYpchZhg2042wYtrPTtlM7JLuzeqY6HimL8x5zSnA45XwTnEqdCPDJj1HP0stpwz22hDCSjhvhuQHk3bE2Z6oeSUHBHoghjgZglJROwbFD5pV4vxQSEBQHgwTqzYUqkLyqG7s5fchJtAXSaB3C4LRzDNBwfNRaA0CYOyslfRAdw-wjBxLSFGXRhadw8niaGzhhYNB3FKd4bC8YpS4dZR2kM+i93JMYKoUpsxZwkSqaC-ddBdDgq1FCEcAH22PiojAsd2jELrkcLeOcUb2HqLuJC8ElIUjRB8To4pYES0UfMAAygAV1IOQWA8AwEkLKDoVSHV4Jc1TOYAkEiXAMBzoBNiN4bxjFHmhQB+oACixN4iX0FN8XO1QmgjBaI5C4esOrZJaOKPJqDKS+F8EAA */ + id: "OpenAppDeviceAction", + initial: "DeviceReady", + context: ({ input }) => { + const sessionState = getDeviceSessionState(); + const { sessionStateType } = sessionState; + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + currentlyRunningApp: + sessionStateType === + DeviceSessionStateType.ReadyWithoutSecureChannel + ? sessionState.currentApp + : null, + }, + }; + }, + states: { + DeviceReady: { + // check device capabilities & status known + always: { + target: "OnboardingCheck", + }, + }, + + OnboardingCheck: { + // check onboarding status provided by device session + always: [ + { + target: "LockingCheck", + guard: { + type: "isDeviceOnboarded", + }, + }, + { + target: "Error", + actions: "assignErrorDeviceNotOnboarded", + }, + ], + }, + + LockingCheck: { + // check locking status provided by device session + always: [ + { + target: "ApplicationAvailable", + guard: "isDeviceUnlocked", + }, + { + target: "Error", + actions: "assignErrorDeviceLocked", + }, + ], + }, + + ApplicationAvailable: { + // execute getAppAndVersion command + invoke: { + src: "getAppAndVersion", + onDone: { + target: "ApplicationCheck", + actions: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + currentlyRunningApp: _.event.output.app, + }), + }), + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + + ApplicationCheck: { + // Is the current application the requested one + always: [ + { + target: "ApplicationReady", + guard: "isRequestedAppOpen", + }, + "DashboardCheck", + ], + }, + + DashboardCheck: { + // Is the current application the dashboard + always: [ + { + target: "OpenApplication", + guard: "isDashboardOpen", + }, + "CloseApplication", + ], + }, + + CloseApplication: { + invoke: { + src: "closeApp", + onDone: { + target: "OpenApplication", + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + + OpenApplication: { + // execute openApp command, + entry: "assignUserActionNeededOpenApp", + exit: "assignNoUserActionNeeded", + invoke: { + src: "openApp", + input: ({ context }) => ({ appName: context.input.appName }), + onDone: { + target: "ApplicationReady", + actions: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + currentlyRunningApp: _.context.input.appName, + }), + }), + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + + ApplicationReady: { + // application is ready to be used + always: "Success", + }, + + // success state + Success: { + type: "final", + actions: "assignNoError", // TODO, we should not need this + }, + + // error state + Error: { + type: "final", + }, + }, + output: ({ context }) => + context._internalState.error // TODO: instead we should rely on the current state ("Success" or "Error") + ? Left(context._internalState.error) + : Right(undefined), + }); + } + + private extractDependencies(internalApi: InternalApi): MachineDependencies { + const getAppAndVersion = async () => + internalApi + .sendCommand(new GetAppAndVersionCommand()) + .then((res) => ({ app: res.name, version: res.version })); + const closeApp = async () => internalApi.sendCommand(new CloseAppCommand()); + const openApp = async (arg0: { input: { appName: string } }) => + internalApi.sendCommand( + new OpenAppCommand({ appName: arg0.input.appName }), + ); + + return { + getAppAndVersion, + closeApp, + openApp, + getDeviceSessionState: () => internalApi.getDeviceSessionState(), + }; + } + + /** This is to allow injecting dependencies for testing purposes */ + _unsafeSetExtractDependencies( + extractDependencies: ExtractMachineDependencies, + ) { + this.extractDependencies = extractDependencies; + } +} diff --git a/packages/core/src/api/device-action/os/OpenAppDeviceAction/errors.ts b/packages/core/src/api/device-action/os/OpenAppDeviceAction/errors.ts new file mode 100644 index 000000000..e96500600 --- /dev/null +++ b/packages/core/src/api/device-action/os/OpenAppDeviceAction/errors.ts @@ -0,0 +1,41 @@ +import { SdkError } from "@api/Error"; + +export class DeviceNotOnboardedError implements SdkError { + readonly _tag = "DeviceNotOnboardedError"; + originalError?: Error; + + constructor(message?: string) { + this.originalError = new Error(message ?? "Device not onboarded."); + } +} + +export class DeviceLockedError implements SdkError { + readonly _tag = "DeviceLockedError"; + originalError?: Error; + + constructor(message?: string) { + this.originalError = new Error(message ?? "Device locked."); + } +} + +export class UnknownOpenAppDAError implements SdkError { + readonly _tag = "UnknownOpenAppDAError"; + originalError?: Error; + + constructor(message?: string) { + this.originalError = new Error(message ?? "Unknown error."); + } +} + +export class OpenAppRejectedError implements SdkError { + readonly _tag = "OpenAppRejectedError"; + originalError?: Error; + + constructor(message?: string) { + this.originalError = new Error(message ?? "Open app rejected."); + } +} + +// TODO: open app rejected error (also we should already have a similar error in the open app command parsing) + +// TODO: app not installed error (same as above) diff --git a/packages/core/src/api/device-action/os/OpenAppDeviceAction/types.ts b/packages/core/src/api/device-action/os/OpenAppDeviceAction/types.ts new file mode 100644 index 000000000..ad83cbd6d --- /dev/null +++ b/packages/core/src/api/device-action/os/OpenAppDeviceAction/types.ts @@ -0,0 +1,36 @@ +import { DeviceActionState } from "@api/device-action/model/DeviceActionState"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { SdkError } from "@api/Error"; + +import { + DeviceLockedError, + DeviceNotOnboardedError, + UnknownOpenAppDAError, +} from "./errors"; + +export type OpenAppDAOutput = void; + +export type OpenAppDAInput = { + appName: string; +}; + +export type OpenAppDAError = + | DeviceNotOnboardedError + | DeviceLockedError + | UnknownOpenAppDAError + | SdkError; /// TODO: remove, we should have an exhaustive list of errors + +type OpenAppDARequiredInteraction = + | UserInteractionRequired.None + | UserInteractionRequired.UnlockDevice + | UserInteractionRequired.ConfirmOpenApp; + +export type OpenAppDAIntermediateValue = { + requiredUserInteraction: OpenAppDARequiredInteraction; +}; + +export type OpenAppDAState = DeviceActionState< + OpenAppDAOutput, + OpenAppDAError, + OpenAppDAIntermediateValue +>; diff --git a/packages/core/src/api/device-action/use-case/ExecuteDeviceActionUseCase.ts b/packages/core/src/api/device-action/use-case/ExecuteDeviceActionUseCase.ts new file mode 100644 index 000000000..f58dd71d0 --- /dev/null +++ b/packages/core/src/api/device-action/use-case/ExecuteDeviceActionUseCase.ts @@ -0,0 +1,89 @@ +import { inject, injectable } from "inversify"; + +import { + DeviceAction, + DeviceActionIntermediateValue, + ExecuteDeviceActionReturnType, +} from "@api/device-action/DeviceAction"; +import { SdkError } from "@api/Error"; +import { deviceSessionTypes } from "@internal/device-session/di/deviceSessionTypes"; +import type { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; +import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; +import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; + +export type ExecuteDeviceActionUseCaseArgs< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, +> = { + /** + * The device session id. + */ + sessionId: string; + /** + * The device action to execute. + */ + deviceAction: DeviceAction; +}; + +/** + * Executes a device action to a device through a device session. + */ +@injectable() +export class ExecuteDeviceActionUseCase { + private readonly _sessionService: DeviceSessionService; + private readonly _logger: LoggerPublisherService; + constructor( + @inject(deviceSessionTypes.DeviceSessionService) + sessionService: DeviceSessionService, + @inject(loggerTypes.LoggerPublisherServiceFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this._sessionService = sessionService; + this._logger = loggerFactory("ExecuteDeviceActionUseCase"); + } + + /** + * Executes a device action to a device through a device session. + * + * @param sessionId - The device session id. + * @param deviceAction - The device action to execute + * @returns An object containing an observable of the device action state, and a cancel function. + */ + execute< + Output, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, + Input, + >({ + sessionId, + deviceAction, + }: ExecuteDeviceActionUseCaseArgs< + Output, + Input, + Error, + IntermediateValue + >): ExecuteDeviceActionReturnType { + const deviceSessionOrError = + this._sessionService.getDeviceSessionById(sessionId); + + return deviceSessionOrError.caseOf({ + // Case device session found + Right: (deviceSession) => + deviceSession.executeDeviceAction< + Output, + Input, + Error, + IntermediateValue + >(deviceAction), + // Case device session not found + Left: (error) => { + this._logger.error("Error getting session", { + data: { error }, + }); + throw error; + }, + }); + } +} diff --git a/packages/core/src/api/device-action/xstate-utils/StateMachineTypes.ts b/packages/core/src/api/device-action/xstate-utils/StateMachineTypes.ts new file mode 100644 index 000000000..f46e351fb --- /dev/null +++ b/packages/core/src/api/device-action/xstate-utils/StateMachineTypes.ts @@ -0,0 +1,22 @@ +import { Either } from "purify-ts"; + +/** + * The internal types of an XState state machine. + * This is to easily map between the snapshots of any state machine with these + * types and a DeviceActionState. + */ +export type StateMachineTypes< + Output, + Input, + Error, + IntermediateValue, + InternalState, +> = { + output: Either; + input: Input; + context: { + input: Input; + intermediateValue: IntermediateValue; + _internalState: InternalState; + }; +}; diff --git a/packages/core/src/api/device-action/xstate-utils/XStateDeviceAction.ts b/packages/core/src/api/device-action/xstate-utils/XStateDeviceAction.ts new file mode 100644 index 000000000..2a64d2385 --- /dev/null +++ b/packages/core/src/api/device-action/xstate-utils/XStateDeviceAction.ts @@ -0,0 +1,197 @@ +import { createBrowserInspector } from "@statelyai/inspect"; +import { Observable, ReplaySubject, share } from "rxjs"; +import { createActor, SnapshotFrom, StateMachine } from "xstate"; + +import { + DeviceAction, + DeviceActionIntermediateValue, + ExecuteDeviceActionReturnType, + InternalApi, +} from "@api/device-action/DeviceAction"; +import { + DeviceActionState, + DeviceActionStatus, +} from "@api/device-action/model/DeviceActionState"; +import { SdkError } from "@api/Error"; + +import { StateMachineTypes } from "./StateMachineTypes"; + +type DeviceActionStateMachine< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, + InternalState, +> = StateMachine< + StateMachineTypes< + Output, + Input, + Error, + IntermediateValue, + InternalState + >["context"], // context + /** + * The following usages `any` are OK because this is just a wrapper around the + * state machine and we are not directly going to use these types. + */ + /* eslint-disable @typescript-eslint/no-explicit-any */ + any, // event + any, // children + any, // actor + any, // action + any, // guard + any, // delay + any, // state value + any, // tag + /* eslint-enable @typescript-eslint/no-explicit-any */ + StateMachineTypes< + Output, + Input, + Error, + IntermediateValue, + InternalState + >["input"], + StateMachineTypes< + Output, + Input, + Error, + IntermediateValue, + InternalState + >["output"], + /* eslint-disable @typescript-eslint/no-explicit-any */ + any, + any, + any + /* eslint-enable @typescript-eslint/no-explicit-any */ +>; + +/** + * A DeviceAction that uses an XState state machine to execute. + * It maps the state machine snapshots to the DeviceActionState. + * This class is abstract and should be extended to implement the state machine. + */ +export abstract class XStateDeviceAction< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, + InternalState, +> implements DeviceAction +{ + readonly input: Input; + readonly inspect: boolean = false; + + /** + * + * @param input The input for the DeviceAction + * @param inspect If true, the state machine will be inspected in the browser + */ + constructor(args: { input: Input; inspect?: boolean }) { + this.input = args.input; + this.inspect = Boolean(args.inspect); + } + + protected abstract makeStateMachine( + internalAPI: InternalApi, + ): DeviceActionStateMachine< + Output, + Input, + Error, + IntermediateValue, + InternalState + >; + + _execute( + internalApi: InternalApi, + ): ExecuteDeviceActionReturnType { + const stateMachine = this.makeStateMachine(internalApi); + + const actor = createActor(stateMachine, { + input: this.input, + // optional inspector for debugging + inspect: this.inspect ? createBrowserInspector().inspect : undefined, + }); + + /** + * Using a ReplaySubject is important because the first snapshots might be + * emitted before the observable is subscribed (if the machine goes through + * those states fully synchronously). + * This way, we ensure that the subscriber always receives the latest snapshot. + * */ + const subject = new ReplaySubject< + DeviceActionState + >(); + + const handleActorSnapshot = ( + snapshot: SnapshotFrom, + ) => { + const { context, status, output, error } = snapshot; + switch (status) { + case "active": + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: context.intermediateValue, + }); + break; + case "done": + output.caseOf({ + Left: (output) => { + subject.next({ + status: DeviceActionStatus.Error, + error: output, + }); + }, + Right: (output) => { + subject.next({ + status: DeviceActionStatus.Completed, + output, + }); + }, + }); + subject.complete(); + break; + case "error": + // this is an error in the execution of the state machine, it should not happen + subject.error(error); + subject.complete(); + break; + case "stopped": + subject.next({ + status: DeviceActionStatus.Stopped, + }); + subject.complete(); + break; + default: + this._exhaustiveMatchingGuard(status); + } + }; + + const observable = new Observable< + DeviceActionState + >((subscriber) => { + const subjectSubscription = subject.subscribe(subscriber); + return () => { + actorSubscription.unsubscribe(); + subjectSubscription.unsubscribe(); + actor.stop(); // stop the actor when the observable is unsubscribed + }; + }); + + const actorSubscription = actor.subscribe(handleActorSnapshot); + actor.start(); + + return { + observable: observable.pipe(share()), // share to garantee that once there is no more observer, the actor is stopped + cancel: () => { + actor.stop(); + actorSubscription.unsubscribe(); + handleActorSnapshot(actor.getSnapshot()); + }, + }; + } + + private _exhaustiveMatchingGuard(status: never): never { + console.log("_exhaustiveMatchingGuard status", status); + throw new Error(`Unhandled status: ${status}`); + } +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 12554c2ca..ac4e0da06 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -32,4 +32,23 @@ export { LogLevel } from "./logger-subscriber/model/LogLevel"; export { ConsoleLogger } from "./logger-subscriber/service/ConsoleLogger"; export * from "./types"; export { ConnectedDevice } from "./usb/model/ConnectedDevice"; +export { + type DeviceAction, + type DeviceActionIntermediateValue, + type ExecuteDeviceActionReturnType, +} from "@api/device-action/DeviceAction"; +export { + type DeviceActionState, + DeviceActionStatus, +} from "@api/device-action/model/DeviceActionState"; +export { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +export { OpenAppDeviceAction } from "@api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction"; +export { + type OpenAppDAError, + type OpenAppDAInput, + type OpenAppDAIntermediateValue, + type OpenAppDAOutput, + type OpenAppDAState, +} from "@api/device-action/os/OpenAppDeviceAction/types"; export { type DeviceSessionState } from "@api/device-session/DeviceSessionState"; +export { type SdkError } from "@api/Error"; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index 92fbe5a2e..38d9e7330 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -6,6 +6,7 @@ export type { DiscoveredDevice } from "./usb/model/DiscoveredDevice"; export type { Command } from "@api/command/Command"; export type { SendCommandUseCaseArgs } from "@api/command/use-case/SendCommandUseCase"; export type { DeviceModelId } from "@api/device/DeviceModel"; +export type { ExecuteDeviceActionUseCaseArgs } from "@api/device-action/use-case/ExecuteDeviceActionUseCase"; export type { DeviceSessionId } from "@api/device-session/types"; export type { ConnectUseCaseArgs } from "@internal/discovery/use-case/ConnectUseCase"; export type { DisconnectUseCaseArgs } from "@internal/discovery/use-case/DisconnectUseCase"; diff --git a/packages/core/src/di.ts b/packages/core/src/di.ts index 050e9e320..a5516674d 100644 --- a/packages/core/src/di.ts +++ b/packages/core/src/di.ts @@ -1,6 +1,7 @@ import { Container } from "inversify"; import { commandModuleFactory } from "@api/command/di/commandModule"; +import { deviceActionModuleFactory } from "@api/device-action/di/deviceActionModule"; import { LoggerSubscriberService } from "@api/logger-subscriber/service/LoggerSubscriberService"; // Uncomment this line to enable the logger middleware // import { makeLoggerMiddleware } from "inversify-logger-middleware"; @@ -38,6 +39,7 @@ export const makeContainer = ({ deviceSessionModuleFactory({ stub }), sendModuleFactory({ stub }), commandModuleFactory({ stub }), + deviceActionModuleFactory({ stub }), // modules go here ); diff --git a/packages/core/src/internal/device-session/model/DeviceSession.ts b/packages/core/src/internal/device-session/model/DeviceSession.ts index 5bb693241..c8ed74caf 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.ts @@ -5,11 +5,17 @@ import { v4 as uuidv4 } from "uuid"; import { Command } from "@api/command/Command"; import { CommandUtils } from "@api/command/utils/CommandUtils"; import { DeviceStatus } from "@api/device/DeviceStatus"; +import { + DeviceAction, + DeviceActionIntermediateValue, + ExecuteDeviceActionReturnType, +} from "@api/device-action/DeviceAction"; import { DeviceSessionState, DeviceSessionStateType, } from "@api/device-session/DeviceSessionState"; import { DeviceSessionId } from "@api/device-session/types"; +import { SdkError } from "@api/Error"; import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; @@ -46,7 +52,10 @@ export class DeviceSession { refreshInterval: 1000, deviceStatus: DeviceStatus.CONNECTED, sendApduFn: (rawApdu: Uint8Array) => - this.sendApdu(rawApdu, { isPolling: true }), + this.sendApdu(rawApdu, { + isPolling: true, + triggersDisconnection: false, + }), updateStateFn: (state: DeviceSessionState) => this.setDeviceSessionState(state), }, @@ -81,11 +90,17 @@ export class DeviceSession { async sendApdu( rawApdu: Uint8Array, - options: { isPolling: boolean } = { isPolling: false }, + options: { isPolling: boolean; triggersDisconnection: boolean } = { + isPolling: false, + triggersDisconnection: false, + }, ) { if (!options.isPolling) this.updateDeviceStatus(DeviceStatus.BUSY); - const errorOrResponse = await this._connectedDevice.sendApdu(rawApdu); + const errorOrResponse = await this._connectedDevice.sendApdu( + rawApdu, + options.triggersDisconnection, + ); return errorOrResponse.ifRight((response) => { if (CommandUtils.isLockedDeviceResponse(response)) { @@ -100,7 +115,10 @@ export class DeviceSession { command: Command, ): Promise { const apdu = command.getApdu(); - const response = await this.sendApdu(apdu.getRawApdu()); + const response = await this.sendApdu(apdu.getRawApdu(), { + isPolling: false, + triggersDisconnection: command.triggersDisconnection ?? false, + }); return response.caseOf({ Left: (err) => { @@ -111,6 +129,26 @@ export class DeviceSession { }); } + executeDeviceAction< + Output, + Input, + Error extends SdkError, + IntermediateValue extends DeviceActionIntermediateValue, + >( + deviceAction: DeviceAction, + ): ExecuteDeviceActionReturnType { + const { observable, cancel } = deviceAction._execute({ + sendCommand: async (command: Command) => + this.sendCommand(command), + getDeviceSessionState: () => this._deviceState.getValue(), + }); + + return { + observable, + cancel, + }; + } + close() { this.updateDeviceStatus(DeviceStatus.NOT_CONNECTED); this._deviceState.complete(); diff --git a/packages/core/src/internal/usb/model/Errors.ts b/packages/core/src/internal/usb/model/Errors.ts index 2ae48ab6f..f566dc0b5 100644 --- a/packages/core/src/internal/usb/model/Errors.ts +++ b/packages/core/src/internal/usb/model/Errors.ts @@ -66,3 +66,10 @@ export class DisconnectError extends GeneralSdkError { super(err); } } + +export class ReconnectionFailedError extends GeneralSdkError { + override readonly _tag = "ReconnectionFailedError"; + constructor(readonly err?: unknown) { + super(err); + } +} diff --git a/packages/core/src/internal/usb/transport/DeviceConnection.ts b/packages/core/src/internal/usb/transport/DeviceConnection.ts index 7f1cb1448..30aa65f8a 100644 --- a/packages/core/src/internal/usb/transport/DeviceConnection.ts +++ b/packages/core/src/internal/usb/transport/DeviceConnection.ts @@ -5,6 +5,7 @@ import { SdkError } from "@api/Error"; export type SendApduFnType = ( apdu: Uint8Array, + triggersDisconnection?: boolean, ) => Promise>; export interface DeviceConnection { diff --git a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts index e02089f10..49f7af8a4 100644 --- a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts +++ b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.test.ts @@ -1,11 +1,16 @@ +import { Left, Right } from "purify-ts"; + 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 { ReconnectionFailedError } from "@internal/usb/model/Errors"; import { hidDeviceStubBuilder } from "@internal/usb/model/HIDDevice.stub"; import { UsbHidDeviceConnection } from "@internal/usb/transport/UsbHidDeviceConnection"; +jest.useFakeTimers(); + 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, @@ -14,6 +19,20 @@ const RESPONSE_LOCKED_DEVICE = new Uint8Array([ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]); +const RESPONSE_SUCCESS = new Uint8Array([ + 0xaa, 0xaa, 0x05, 0x00, 0x00, 0x00, 0x02, 0x90, 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, 0x00, +]); + +/** + * Flushes all pending promises + */ +const flushPromises = () => + new Promise(jest.requireActual("timers").setImmediate); + describe("UsbHidDeviceConnection", () => { let device: HIDDevice; let apduSender: ApduSenderService; @@ -50,7 +69,74 @@ describe("UsbHidDeviceConnection", () => { expect(device.sendReport).toHaveBeenCalled(); }); - it("should receive APDU through hid report", () => { + it("should receive APDU through hid report", async () => { + // given + device.sendReport = jest.fn(() => + Promise.resolve( + device.oninputreport!({ + type: "inputreport", + data: new DataView(Uint8Array.from(RESPONSE_SUCCESS).buffer), + } as HIDInputReportEvent), + ), + ); + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + // when + const response = await connection.sendApdu(Uint8Array.from([])); + // then + expect(response).toEqual( + Right({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([]), + }), + ); + }); + + test("sendApdu(whatever, true) should wait for reconnection before resolving if the response is a success", async () => { + // given + device.sendReport = jest.fn(() => + Promise.resolve( + device.oninputreport!({ + type: "inputreport", + data: new DataView(Uint8Array.from(RESPONSE_SUCCESS).buffer), + } as HIDInputReportEvent), + ), + ); + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + + let hasResolved = false; + const responsePromise = connection + .sendApdu(Uint8Array.from([]), true) + .then((response) => { + hasResolved = true; + return response; + }); + + // before reconnecting + await flushPromises(); + expect(hasResolved).toBe(false); + + // when reconnecting + connection.device = device; + await flushPromises(); + expect(hasResolved).toBe(true); + + const response = await responsePromise; + + expect(response).toEqual( + Right({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([]), + }), + ); + }); + + test("sendApdu(whatever, true) should not wait for reconnection if the response is not a success", async () => { // given device.sendReport = jest.fn(() => Promise.resolve( @@ -64,9 +150,42 @@ describe("UsbHidDeviceConnection", () => { { device, apduSender, apduReceiver }, logger, ); + // when - const response = connection.sendApdu(Uint8Array.from([])); + const response = await connection.sendApdu(Uint8Array.from([]), true); + + // then + expect(response).toEqual( + Right({ + statusCode: new Uint8Array([0x55, 0x15]), + data: new Uint8Array([]), + }), + ); + }); + + test("sendApdu(whatever, true) should return an error if the device gets disconnected while waiting for reconnection", async () => { + // given + device.sendReport = jest.fn(() => + Promise.resolve( + device.oninputreport!({ + type: "inputreport", + data: new DataView(Uint8Array.from(RESPONSE_SUCCESS).buffer), + } as HIDInputReportEvent), + ), + ); + const connection = new UsbHidDeviceConnection( + { device, apduSender, apduReceiver }, + logger, + ); + + const responsePromise = connection.sendApdu(Uint8Array.from([]), true); + + // when disconnecting + connection.disconnect(); + await flushPromises(); + // then - expect(response).resolves.toBe(RESPONSE_LOCKED_DEVICE); + const response = await responsePromise; + expect(response).toEqual(Left(new ReconnectionFailedError())); }); }); diff --git a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts index eeb0518e0..cb8844463 100644 --- a/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts +++ b/packages/core/src/internal/usb/transport/UsbHidDeviceConnection.ts @@ -1,13 +1,15 @@ import { inject } from "inversify"; -import { Either, Left, Right } from "purify-ts"; +import { Either, Left, Maybe, Right } from "purify-ts"; import { Subject } from "rxjs"; import { ApduResponse } from "@api/device-session/ApduResponse"; import { SdkError } from "@api/Error"; +import { CommandUtils } from "@api/index"; 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 { ReconnectionFailedError } from "@internal/usb/model/Errors"; import { DeviceConnection } from "./DeviceConnection"; @@ -23,6 +25,10 @@ export class UsbHidDeviceConnection implements DeviceConnection { private readonly _apduReceiver: ApduReceiverService; private _sendApduSubject: Subject; private readonly _logger: LoggerPublisherService; + private _settleReconnectionPromise: Maybe<{ + resolve(): void; + reject(err: SdkError): void; + }> = Maybe.zero(); constructor( { device, apduSender, apduReceiver }: UsbHidDeviceConnectionConstructorArgs, @@ -44,15 +50,44 @@ export class UsbHidDeviceConnection implements DeviceConnection { public set device(device: HIDDevice) { this._device = device; this._device.oninputreport = (event) => this.receiveHidInputReport(event); + + this._settleReconnectionPromise.ifJust(() => { + this.reconnected(); + }); } - async sendApdu(apdu: Uint8Array): Promise> { + async sendApdu( + apdu: Uint8Array, + triggersDisconnection?: boolean, + ): Promise> { this._sendApduSubject = new Subject(); this._logger.debug("Sending APDU", { data: { apdu }, tag: "apdu-sender", }); + + const resultPromise = new Promise>( + (resolve) => { + this._sendApduSubject.subscribe({ + next: async (r) => { + if (triggersDisconnection && CommandUtils.isSuccessResponse(r)) { + const reconnectionRes = await this.setupWaitForReconnection(); + reconnectionRes.caseOf({ + Left: (err) => resolve(Left(err)), + Right: () => resolve(Right(r)), + }); + } else { + resolve(Right(r)); + } + }, + error: (err) => { + resolve(Left(err)); + }, + }); + }, + ); + const frames = this._apduSender.getFrames(apdu); for (const frame of frames) { this._logger.debug("Sending Frame", { @@ -65,16 +100,7 @@ export class UsbHidDeviceConnection implements DeviceConnection { } } - return new Promise((resolve) => { - this._sendApduSubject.subscribe({ - next: (r) => { - resolve(Right(r)); - }, - error: (err) => { - resolve(Left(err)); - }, - }); - }); + return resultPromise; } private receiveHidInputReport(event: HIDInputReportEvent) { @@ -99,4 +125,27 @@ export class UsbHidDeviceConnection implements DeviceConnection { }, }); } + + private setupWaitForReconnection(): Promise> { + return new Promise>((resolve) => { + this._settleReconnectionPromise = Maybe.of({ + resolve: () => resolve(Right(undefined)), + reject: (error: SdkError) => resolve(Left(error)), + }); + }); + } + + private reconnected() { + this._settleReconnectionPromise.ifJust((promise) => { + promise.resolve(); + this._settleReconnectionPromise = Maybe.zero(); + }); + } + + public disconnect() { + this._settleReconnectionPromise.ifJust((promise) => { + promise.reject(new ReconnectionFailedError()); + this._settleReconnectionPromise = Maybe.zero(); + }); + } } diff --git a/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts index 248ab42eb..ba188c998 100644 --- a/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts +++ b/packages/core/src/internal/usb/transport/WebUsbHidTransport.ts @@ -316,15 +316,17 @@ export class WebUsbHidTransport implements UsbHidTransport { deviceConnection, ); const connectedDevice = new InternalConnectedDevice({ - sendApdu: (apdu) => deviceConnection.sendApdu(apdu), + sendApdu: (apdu, triggersDisconnection) => + deviceConnection.sendApdu(apdu, triggersDisconnection), deviceModel, id: deviceId, type: "USB", }); this._disconnectionHandlersByHidId.set( this.getHidUsbProductId(internalDevice.hidDevice.productId), - () => - this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)), + () => { + this.disconnect({ connectedDevice }).then(() => onDisconnect(deviceId)); + }, ); return Right(connectedDevice); } @@ -354,6 +356,12 @@ export class WebUsbHidTransport implements UsbHidTransport { ); } + const deviceConnection = this._deviceConnectionByHidId.get( + this.getHidUsbProductId(internalDevice.hidDevice.productId), + ); + + deviceConnection?.disconnect(); + try { const usbProductId = this.getHidUsbProductId( internalDevice.hidDevice.productId, diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index 2c686b0dd..2a786393f 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -4,6 +4,7 @@ "src/**/*.test.ts", "src/**/*.stub.ts", "src/**/__mocks__", + "src/**/__test-utils__", "jest.*.ts" ], "compilerOptions": { diff --git a/packages/core/tsconfig.esm.json b/packages/core/tsconfig.esm.json index 582fb4f0f..7918341c9 100644 --- a/packages/core/tsconfig.esm.json +++ b/packages/core/tsconfig.esm.json @@ -4,6 +4,7 @@ "src/**/*.test.ts", "src/**/*.stub.ts", "src/**/__mocks__", + "src/**/__test-utils__", "jest.*.ts" ], "compilerOptions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2293307d5..1fa4823ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: uuid: specifier: ^10.0.0 version: 10.0.0 + xstate: + specifier: ^5.13.2 + version: 5.14.0 devDependencies: '@ledgerhq/eslint-config-dsdk': specifier: workspace:* @@ -193,6 +196,9 @@ importers: '@ledgerhq/tsconfig-dsdk': specifier: workspace:* version: link:../config/typescript + '@statelyai/inspect': + specifier: ^0.3.1 + version: 0.3.1(ws@7.5.10)(xstate@5.14.0) '@types/semver': specifier: ^7.5.8 version: 7.5.8 @@ -2170,6 +2176,11 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@statelyai/inspect@0.3.1': + resolution: {integrity: sha512-KW3owf5UbPs1+/xOGJoSV4D69hP5xTX7PCzARr2R1senwfUIwyGP8yEsB8dvkMvekYvgFS0qa6lmg1eszYr2tw==} + peerDependencies: + xstate: ^5.5.1 + '@styled-system/background@5.1.2': resolution: {integrity: sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==} @@ -3117,6 +3128,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + core-js-compat@3.37.1: resolution: {integrity: sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==} @@ -3624,6 +3639,10 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + event-target-shim@6.0.2: + resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==} + engines: {node: '>=10.13.0'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3678,6 +3697,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-xml-parser@4.4.0: resolution: {integrity: sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==} hasBin: true @@ -4342,6 +4364,10 @@ packages: is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4367,6 +4393,11 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5272,6 +5303,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partysocket@0.0.25: + resolution: {integrity: sha512-1oCGA65fydX/FgdnsiBh68buOvfxuteoZVSb3Paci2kRp/7lhF0HyA8EDb5X/O6FxId1e+usPTQNRuzFEvkJbQ==} + pascal-case@2.0.1: resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} @@ -5763,6 +5797,10 @@ packages: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6092,6 +6130,10 @@ packages: sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + superjson@1.13.3: + resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} + engines: {node: '>=10'} + supports-color@4.5.0: resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} engines: {node: '>=4'} @@ -6643,6 +6685,9 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xstate@5.14.0: + resolution: {integrity: sha512-3c1H8dzebSODUSY1vM86zIE3Eg+VFzElvhklaF/ZTN5K2i6HsLc4j38qn9+TF2mHPTUFXrOn4M0Cxw9m63upLA==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9662,6 +9707,18 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.0 + '@statelyai/inspect@0.3.1(ws@7.5.10)(xstate@5.14.0)': + dependencies: + fast-safe-stringify: 2.1.1 + isomorphic-ws: 5.0.0(ws@7.5.10) + partysocket: 0.0.25 + safe-stable-stringify: 2.4.3 + superjson: 1.13.3 + uuid: 9.0.1 + xstate: 5.14.0 + transitivePeerDependencies: + - ws + '@styled-system/background@5.1.2': dependencies: '@styled-system/core': 5.1.2 @@ -10821,6 +10878,10 @@ snapshots: convert-source-map@2.0.0: {} + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + core-js-compat@3.37.1: dependencies: browserslist: 4.23.1 @@ -11471,6 +11532,8 @@ snapshots: event-target-shim@5.0.1: {} + event-target-shim@6.0.2: {} + events@3.3.0: {} execa@5.1.1: @@ -11541,6 +11604,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fast-xml-parser@4.4.0: dependencies: strnum: 1.0.5 @@ -12239,6 +12304,8 @@ snapshots: dependencies: call-bind: 1.0.7 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@1.1.0: {} @@ -12255,6 +12322,10 @@ snapshots: isobject@3.0.1: {} + isomorphic-ws@5.0.0(ws@7.5.10): + dependencies: + ws: 7.5.10 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@5.2.1: @@ -13452,6 +13523,10 @@ snapshots: parseurl@1.3.3: {} + partysocket@0.0.25: + dependencies: + event-target-shim: 6.0.2 + pascal-case@2.0.1: dependencies: camel-case: 3.0.0 @@ -13986,6 +14061,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.1.4 + safe-stable-stringify@2.4.3: {} + safer-buffer@2.1.2: {} scheduler@0.23.2: @@ -14345,6 +14422,10 @@ snapshots: sudo-prompt@9.2.1: {} + superjson@1.13.3: + dependencies: + copy-anything: 3.0.5 + supports-color@4.5.0: dependencies: has-flag: 2.0.0 @@ -14872,6 +14953,8 @@ snapshots: xdg-basedir@5.1.0: {} + xstate@5.14.0: {} + xtend@4.0.2: {} y18n@4.0.3: {}