@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
186 lines (170 loc) • 6.78 kB
text/typescript
import { defer, from, of, throwError, Observable, TimeoutError, timer } from "rxjs";
import { map, catchError, first, timeout, repeat, switchMap } from "rxjs/operators";
import { getVersion } from "../device/use-cases/getVersionUseCase";
import { withDevice } from "./deviceAccess";
import {
TransportStatusError,
StatusCodes,
DeviceOnboardingStatePollingError,
DeviceExtractOnboardingStateError,
DisconnectedDevice,
CantOpenDevice,
TransportRaceCondition,
LockedDeviceError,
UnexpectedBootloader,
TransportExchangeTimeoutError,
DisconnectedDeviceDuringOperation,
} from "@ledgerhq/errors";
import { FirmwareInfo } from "@ledgerhq/types-live";
import { extractOnboardingState, OnboardingState } from "./extractOnboardingState";
import { DeviceDisconnectedWhileSendingError } from "@ledgerhq/device-management-kit";
import { quitApp } from "../deviceSDK/commands/quitApp";
export type OnboardingStatePollingResult = {
onboardingState: OnboardingState | null;
allowedError: Error | null;
lockedDevice: boolean;
};
export type GetOnboardingStatePollingResult = Observable<OnboardingStatePollingResult>;
export type GetOnboardingStatePollingArgs = {
deviceId: string;
deviceName: string | null;
pollingPeriodMs: number;
transportAbortTimeoutMs?: number;
safeGuardTimeoutMs?: number;
allowedErrorChecks?: ((error: unknown) => boolean)[];
};
/**
* Polls the device onboarding state at a given frequency
*
* @param deviceId A device id
* @param pollingPeriodMs The period in ms after which the device onboarding state is fetched again
* @param transportAbortTimeoutMs Depending on the transport implementation, an "abort timeout" will be set (and throw an error) when:
* - opening a transport instance
* - on called commands (where `getVersion`)
* Default to (pollingPeriodMs - 100) ms
* @param safeGuardTimeoutMs For Transport implementations not implementing an "abort timeout", a timeout will be triggered (and throw an error) at this function call level
* @returns An Observable that polls the device onboarding state and pushes an object containing:
* - onboardingState: the device state during the onboarding
* - allowedError: any error that is allowed and does not stop the polling
* - lockedDevice: a boolean set to true if the device is currently locked, false otherwise
*/
export const getOnboardingStatePolling = ({
deviceId,
deviceName,
pollingPeriodMs,
transportAbortTimeoutMs = pollingPeriodMs - 100,
safeGuardTimeoutMs = pollingPeriodMs * 10, // Nb Empirical value
allowedErrorChecks = [],
}: GetOnboardingStatePollingArgs): GetOnboardingStatePollingResult => {
let hasQuitAppAlreadyRun = false;
const getOnboardingStateOnce = (): Observable<OnboardingStatePollingResult> => {
return withDevice(deviceId, {
openTimeoutMs: transportAbortTimeoutMs,
matchDeviceByName: deviceName ?? undefined,
})(t => {
const getVersionObs = defer(() =>
from(getVersion(t, { abortTimeoutMs: transportAbortTimeoutMs })),
);
return getVersionObs.pipe(
catchError((error: unknown) => {
const isApduNotSupported =
error instanceof TransportStatusError &&
[StatusCodes.CLA_NOT_SUPPORTED, StatusCodes.INS_NOT_SUPPORTED].includes(
(error as TransportStatusError).statusCode,
);
if (isApduNotSupported && !hasQuitAppAlreadyRun) {
hasQuitAppAlreadyRun = true;
return quitApp(t).pipe(switchMap(() => getVersionObs));
}
return throwError(() => error);
}),
);
}).pipe(
timeout(safeGuardTimeoutMs), // Throws a TimeoutError
first(),
catchError((error: unknown) => {
if (
isAllowedOnboardingStatePollingError(error) ||
allowedErrorChecks?.some(fn => fn(error))
) {
// Pushes the error to the next step to be processed (no retry from the beginning)
return of(error as Error);
}
return throwError(() => error);
}),
map((event: FirmwareInfo | Error) => {
if ("flags" in event) {
const firmwareInfo = event as FirmwareInfo;
let onboardingState: OnboardingState | null = null;
if (firmwareInfo.isBootloader) {
// Throws so it will be considered a fatal error
throw new UnexpectedBootloader("Device in bootloader during the polling");
}
try {
onboardingState = extractOnboardingState(firmwareInfo.flags, firmwareInfo.charonState);
} catch (error: unknown) {
if (error instanceof DeviceExtractOnboardingStateError) {
return {
onboardingState: null,
allowedError: error,
lockedDevice: false,
};
} else {
let errorMessage = "";
if (error instanceof Error) {
errorMessage = `${error.name}: ${error.message}`;
} else {
errorMessage = `${error}`;
}
return {
onboardingState: null,
allowedError: new DeviceOnboardingStatePollingError(
`SyncOnboarding: Unknown error while extracting the onboarding state ${errorMessage}`,
),
lockedDevice: false,
};
}
}
return {
onboardingState,
allowedError: null,
lockedDevice: false,
};
} else {
// If an error is caught previously, and this error is "allowed",
// the value from the observable is not a FirmwareInfo but an Error
const allowedError = event as Error;
return {
onboardingState: null,
allowedError: allowedError,
lockedDevice: allowedError instanceof LockedDeviceError,
};
}
}),
);
};
return getOnboardingStateOnce().pipe(
repeat({
delay: _count => timer(pollingPeriodMs),
}),
);
};
export const isAllowedOnboardingStatePollingError = (error: unknown): boolean => {
if (
error &&
// Timeout error is thrown by rxjs's timeout
(error instanceof TimeoutError ||
error instanceof TransportExchangeTimeoutError ||
error instanceof DisconnectedDevice ||
error instanceof DisconnectedDeviceDuringOperation ||
error instanceof DeviceDisconnectedWhileSendingError ||
error instanceof CantOpenDevice ||
error instanceof TransportRaceCondition ||
error instanceof TransportStatusError ||
// A locked device is handled as an allowed error
error instanceof LockedDeviceError)
) {
return true;
}
return false;
};