import invariant from "invariant"; import { interval, Observable, of } from "rxjs"; import { scan, debounce, tap, takeWhile } from "rxjs/operators"; import { useEffect, useCallback, useState, useMemo, useRef } from "react"; import { log } from "@ledgerhq/logs"; import { getDerivationScheme, getDerivationModesForCurrency, runDerivationScheme, } from "@ledgerhq/ledger-wallet-framework/derivation"; import type { AppAndVersion, ConnectAppEvent, ConnectAppRequest, Input as ConnectAppInput, DeviceDeprecationRules, } from "../connectApp"; import { useReplaySubject } from "../../observable"; import type { Device, Action } from "./types"; import { shouldUpgrade } from "../../apps"; import { AppOp, SkippedAppOp } from "../../apps/types"; import perFamilyAccount from "../../generated/account"; import type { Account, DeviceInfo, FirmwareUpdateContext } from "@ledgerhq/types-live"; import { DeviceId } from "@ledgerhq/client-ids/ids"; import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { getImplementation, ImplementationType } from "./implementations"; import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName"; export type State = { isLoading: boolean; requestQuitApp: boolean; requestOpenApp: string | null | undefined; requiresAppInstallation: | { appName: string; appNames: string[]; } | null | undefined; opened: boolean; appAndVersion: AppAndVersion | null | undefined; unresponsive: boolean; allowOpeningRequestedWording: string | null | undefined; allowOpeningGranted: boolean; allowManagerRequested: boolean; allowManagerGranted: boolean; device: Device | null | undefined; deviceInfo?: DeviceInfo | null | undefined; deviceId: DeviceId | null | undefined; latestFirmware?: FirmwareUpdateContext | null | undefined; error: Error | null | undefined; derivation: | { address: string; } | null | undefined; displayUpgradeWarning: boolean; installingApp?: boolean; progress?: number; listingApps?: boolean; request: AppRequest | undefined; installQueue?: string[]; currentAppOp?: AppOp; itemProgress?: number; isLocked: boolean; skippedAppOps: SkippedAppOp[]; listedApps?: boolean; deviceDeprecationRules?: DeviceDeprecationRules; }; export type AppState = State & { onRetry: () => void; passWarning: () => void; inWrongDeviceForAccount: | { accountName: string; } | null | undefined; }; export type AppRequest = { appName?: string; currency?: CryptoCurrency | null; account?: Account; tokenCurrency?: TokenCurrency; dependencies?: AppRequest[]; withInlineInstallProgress?: boolean; requireLatestFirmware?: boolean; allowPartialDependencies?: boolean; }; export type AppResult = { device: Device; appAndVersion: AppAndVersion | null | undefined; appName?: string; currency?: CryptoCurrency; account?: Account; tokenCurrency?: TokenCurrency; dependencies?: AppRequest[]; withInlineInstallProgress?: boolean; requireLatestFirmware?: boolean; }; type AppAction = Action; export type Event = | { type: "error"; error: Error; device?: Device | null | undefined; } | { type: "deviceChange"; device: Device | null | undefined; } | ConnectAppEvent | { type: "display-upgrade-warning"; displayUpgradeWarning: boolean; }; const mapResult = ({ opened, device, appAndVersion, displayUpgradeWarning, }: AppState): AppResult | null | undefined => opened && device && !displayUpgradeWarning ? { device, appAndVersion, } : null; const getInitialState = (device?: Device | null | undefined, request?: AppRequest): State => ({ isLoading: !!device, requestQuitApp: false, requestOpenApp: null, unresponsive: false, isLocked: false, requiresAppInstallation: null, allowOpeningRequestedWording: null, allowOpeningGranted: false, allowManagerRequested: false, allowManagerGranted: false, device: null, deviceInfo: null, deviceId: null, latestFirmware: null, opened: false, appAndVersion: null, error: null, derivation: null, displayUpgradeWarning: false, installingApp: false, listingApps: false, request, currentAppOp: undefined, installQueue: [], listedApps: false, // Nb maybe expose the result skippedAppOps: [], itemProgress: 0, progress: undefined, }); const reducer = (state: State, e: Event): State => { switch (e.type) { case "deprecation": return { ...state, deviceDeprecationRules: e.deprecate }; case "unresponsiveDevice": return { ...state, unresponsive: true }; case "lockedDevice": return { ...state, isLocked: true }; // This event does not set isLocked and unresponsive properties, as // by itself it does not request anything from the device case "device-update-last-seen": return { ...state, deviceInfo: e.deviceInfo, latestFirmware: e.latestFirmware, }; case "disconnected": // disconnected event can happen for example: // - when the wired device is unplugged // - before a ask-open-app event when an other app is already open return { ...getInitialState(null, state.request), isLoading: !!e.expected, }; case "deviceChange": return { ...getInitialState(e.device, state.request), device: e.device }; case "some-apps-skipped": return { ...state, skippedAppOps: e.skippedAppOps, installQueue: state.installQueue, }; case "inline-install": return { ...getInitialState(state.device, state.request), isLoading: false, allowOpeningGranted: true, allowManagerRequested: false, allowManagerGranted: true, device: state.device, installingApp: true, progress: e.progress || 0, deviceInfo: undefined, latestFirmware: undefined, request: state.request, skippedAppOps: state.skippedAppOps, currentAppOp: e.currentAppOp, listedApps: state.listedApps, itemProgress: e.itemProgress || 0, installQueue: e.installQueue || [], }; case "listing-apps": return { ...state, listedApps: false, listingApps: true, unresponsive: false, isLocked: false, }; case "error": return { ...getInitialState(state.device, state.request), device: state.device || null, error: e.error, isLoading: false, listingApps: false, request: state.request, skippedAppOps: state.skippedAppOps, }; case "ask-open-app": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, requestOpenApp: e.appName, deviceInfo: undefined, latestFirmware: undefined, installingApp: undefined, listingApps: undefined, installQueue: undefined, listedApps: undefined, itemProgress: undefined, skippedAppOps: state.skippedAppOps, }; case "ask-quit-app": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, requestQuitApp: true, installingApp: undefined, listingApps: undefined, installQueue: undefined, listedApps: undefined, itemProgress: undefined, skippedAppOps: state.skippedAppOps, }; case "device-permission-requested": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, allowManagerRequested: true, deviceInfo: undefined, latestFirmware: undefined, installingApp: undefined, listingApps: undefined, listedApps: undefined, itemProgress: undefined, skippedAppOps: state.skippedAppOps, installQueue: state.installQueue, }; case "device-permission-granted": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, allowOpeningGranted: true, allowManagerGranted: true, deviceInfo: undefined, latestFirmware: undefined, installingApp: undefined, listingApps: undefined, itemProgress: undefined, skippedAppOps: state.skippedAppOps, installQueue: state.installQueue, listedApps: state.listedApps, }; case "device-id": return { ...state, deviceId: e.deviceId, }; case "app-not-installed": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, requiresAppInstallation: { appNames: e.appNames, appName: e.appName, }, deviceInfo: undefined, latestFirmware: undefined, installingApp: undefined, listingApps: undefined, installQueue: undefined, listedApps: undefined, itemProgress: undefined, skippedAppOps: state.skippedAppOps, }; case "listed-apps": return { ...state, listedApps: true, installQueue: e.installQueue, }; case "opened": return { ...getInitialState(state.device, state.request), isLoading: false, device: state.device, opened: true, appAndVersion: e.app, derivation: e.derivation, deviceInfo: undefined, latestFirmware: undefined, installingApp: undefined, listingApps: undefined, installQueue: undefined, itemProgress: undefined, request: state.request, skippedAppOps: state.skippedAppOps, listedApps: state.listedApps, displayUpgradeWarning: state.device && e.app ? shouldUpgrade(e.app.name, e.app.version) : false, }; } return state; }; /** * Map between an AppRequest and a ConnectAppRequest, allowing us to * specify an account or a currency without resolving manually the actual * applications we depend on in order to access the flow. */ function inferCommandParams(appRequest: AppRequest): ConnectAppRequest { let derivationMode; let derivationPath; const { account, requireLatestFirmware, allowPartialDependencies = false, dependencies: appDependencies, } = appRequest; let { appName, currency } = appRequest; if (!currency && account) { currency = account.currency; } if (!appName && currency) { appName = currency.managerAppName; } invariant(appName, "appName or currency or account is missing"); let dependencies: string[] | undefined = undefined; if (appDependencies) { dependencies = appDependencies.map(d => inferCommandParams(d).appName); } if (!currency) { return { appName, dependencies, requireLatestFirmware, allowPartialDependencies, }; } let extra; if (account) { derivationMode = account.derivationMode; derivationPath = account.freshAddressPath; const m = perFamilyAccount[account.currency.family]; if (m && m.injectGetAddressParams) { extra = m.injectGetAddressParams(account); } } else { const modes = getDerivationModesForCurrency(currency); derivationMode = modes[modes.length - 1]; derivationPath = runDerivationScheme( getDerivationScheme({ currency, derivationMode, }), currency, ); } return { appName, dependencies, requireLatestFirmware, requiresDerivation: { derivationMode, path: derivationPath, currencyId: currency.id, ...extra, }, allowPartialDependencies, }; } export let currentMode: keyof typeof ImplementationType = "event"; export function setDeviceMode(mode: keyof typeof ImplementationType): void { currentMode = mode; } export const createAction = ( connectAppExec: (arg0: ConnectAppInput) => Observable, ): AppAction => { const useHook = (device: Device | null | undefined, appRequest: AppRequest): AppState => { const dependenciesResolvedRef = useRef(false); const firmwareResolvedRef = useRef(false); const outdatedAppRef = useRef(undefined); const request = useMemo( () => inferCommandParams(appRequest), // for now i don't have better // eslint-disable-next-line react-hooks/exhaustive-deps [ appRequest.appName, appRequest.account?.id, appRequest.currency?.id, appRequest.dependencies, ], ); const task: (arg0: ConnectAppInput) => Observable = useCallback( ({ deviceId, deviceName, request }: ConnectAppInput) => { //To avoid redundant checks, we remove passed checks from the request. const { dependencies, requireLatestFirmware } = request; return connectAppExec({ deviceId, deviceName, request: { ...request, dependencies: dependenciesResolvedRef.current ? undefined : dependencies, requireLatestFirmware: firmwareResolvedRef.current ? undefined : requireLatestFirmware, outdatedApp: outdatedAppRef.current, }, }).pipe( tap(e => { // These events signal the resolution of pending checks. if (e.type === "dependencies-resolved") { dependenciesResolvedRef.current = true; } else if (e.type === "latest-firmware-resolved") { firmwareResolvedRef.current = true; } else if (e.type === "has-outdated-app") { outdatedAppRef.current = e.outdatedApp as AppAndVersion; } }), ); }, [], ); // repair modal will interrupt everything and be rendered instead of the background content const [state, setState] = useState(() => getInitialState(device)); const [resetIndex, setResetIndex] = useState(0); const deviceSubject = useReplaySubject(device); useEffect(() => { if (state.opened) return; const impl = getImplementation(currentMode)({ deviceSubject, task, request, }); const sub = impl .pipe( tap((e: any) => log("actions-app-event", e.type, e)), debounce((e: Event) => ("replaceable" in e && e.replaceable ? interval(100) : of(null))), scan(reducer, getInitialState()), takeWhile((s: State) => !s.requiresAppInstallation && !s.error, true), ) .subscribe(setState); return () => { sub.unsubscribe(); }; }, [deviceSubject, state.opened, resetIndex, task, request]); const onRetry = useCallback(() => { // After an error we can't guarantee resolutions. dependenciesResolvedRef.current = false; firmwareResolvedRef.current = false; // The nonce change triggers a refresh. setResetIndex(i => i + 1); setState(getInitialState(device)); }, [device]); const passWarning = useCallback(() => { setState(currState => ({ ...currState, displayUpgradeWarning: false, })); }, []); return { ...state, inWrongDeviceForAccount: state.derivation && appRequest.account ? state.derivation.address !== appRequest.account.freshAddress && state.derivation.address !== appRequest.account.seedIdentifier // Use-case added for Hedera ? { accountName: getDefaultAccountName(appRequest.account), } : null : null, onRetry, passWarning, }; }; return { useHook, mapResult, }; }; export function dependenciesToAppRequests(dependencies?: string[]): AppRequest[] { if (!dependencies) { return []; } return dependencies.map(appName => ({ appName })); }