UNPKG

@ledgerhq/live-common

Version:
278 lines (245 loc) • 6.94 kB
import { Observable, interval, of } from "rxjs"; import { debounce, scan, tap } from "rxjs/operators"; import { useEffect, useCallback, useState } from "react"; import { log } from "@ledgerhq/logs"; import type { DeviceInfo } from "@ledgerhq/types-live"; import type { ListAppsResult } from "../../apps/types"; import { useReplaySubject } from "../../observable"; import type { Input as ConnectManagerInput, ConnectManagerEvent } from "../connectManager"; import type { Action, Device } from "./types"; import { currentMode } from "./app"; import { getImplementation } from "./implementations"; import { getLatestFirmwareForDeviceUseCase } from "../../device/use-cases/getLatestFirmwareForDeviceUseCase"; import { DeviceId } from "@ledgerhq/client-ids/ids"; type State = { isLoading: boolean; requestQuitApp: boolean; unresponsive: boolean; allowManagerRequested: boolean; allowManagerGranted: boolean; device: Device | null | undefined; deviceInfo: DeviceInfo | null | undefined; deviceId: DeviceId | null | undefined; result: ListAppsResult | null | undefined; error: Error | null | undefined; isLocked: boolean; }; export type ManagerState = State & { repairModalOpened: | { auto: boolean; } | null | undefined; onRetry: () => void; onAutoRepair: () => void; onRepairModal: (arg0: boolean) => void; closeRepairModal: () => void; }; export type ManagerRequest = | { autoQuitAppDisabled?: boolean; cancelExecution?: boolean; } | null | undefined; export type Result = { device: Device; deviceInfo: DeviceInfo; result: ListAppsResult | null | undefined; }; type ConnectManagerAction = Action<ManagerRequest, ManagerState, Result>; type Event = | ConnectManagerEvent | { type: "error"; error: Error; } | { type: "deviceChange"; device: Device | null | undefined; } | { type: "device-id"; deviceId: DeviceId; }; const mapResult = ({ deviceInfo, device, result }): Result | null | undefined => deviceInfo && device ? { device, deviceInfo, result, } : null; const getInitialState = (device?: Device | null | undefined): State => ({ isLoading: device === undefined || !!device, requestQuitApp: false, unresponsive: false, isLocked: false, allowManagerRequested: false, allowManagerGranted: false, device, deviceInfo: null, deviceId: null, result: null, error: null, }); const reducer = (state: State, e: Event): State => { switch (e.type) { case "unresponsiveDevice": return { ...state, unresponsive: true }; case "lockedDevice": return { ...state, isLocked: true }; case "deviceChange": return getInitialState(e.device); case "error": return { ...getInitialState(state.device), error: e.error, isLoading: false, isLocked: false, }; case "appDetected": return { ...state, unresponsive: false, isLocked: false, requestQuitApp: true, }; case "osu": case "bootloader": return { ...state, isLoading: false, unresponsive: false, isLocked: false, requestQuitApp: false, deviceInfo: e.deviceInfo, }; case "listingApps": return { ...state, isLoading: true, requestQuitApp: false, unresponsive: false, isLocked: false, deviceInfo: e.deviceInfo, }; case "device-permission-requested": return { ...state, unresponsive: false, isLocked: false, allowManagerRequested: true, }; case "device-permission-granted": return { ...state, unresponsive: false, isLocked: false, allowManagerRequested: false, allowManagerGranted: true, }; case "device-id": return { ...state, deviceId: e.deviceId, }; case "result": return { ...state, isLoading: false, unresponsive: false, isLocked: false, result: e.result, }; } return state; }; export const createAction = ( task: (arg0: ConnectManagerInput) => Observable<ConnectManagerEvent>, ): ConnectManagerAction => { const useHook = ( device: Device | null | undefined, request: ManagerRequest = {}, ): ManagerState => { const [state, setState] = useState(() => getInitialState()); const [resetIndex, setResetIndex] = useState(0); const deviceSubject = useReplaySubject(device); // repair modal will interrupt everything and be rendered instead of the background content const [repairModalOpened, setRepairModalOpened] = useState<{ auto: boolean; } | null>(null); useEffect(() => { if (request?.cancelExecution) return; const impl = getImplementation(currentMode)<ConnectManagerEvent, ManagerRequest>({ deviceSubject, task, request, retryableWithDelayDisconnectedErrors: [], }); if (repairModalOpened) return; const sub = impl .pipe( tap((e: any) => log("actions-manager-event", e.type, e)), debounce((e: Event) => ("replaceable" in e && e.replaceable ? interval(100) : of(null))), scan(reducer, getInitialState()), ) .subscribe(setState); return () => { sub.unsubscribe(); }; }, [deviceSubject, resetIndex, repairModalOpened, request]); const { deviceInfo } = state; useEffect(() => { if (!deviceInfo) return; // Preload latest firmware in parallel getLatestFirmwareForDeviceUseCase(deviceInfo).catch((e: Error) => { log("warn", e.message); }); }, [deviceInfo]); const onRepairModal = useCallback(open => { setRepairModalOpened( open ? { auto: false, } : null, ); }, []); const closeRepairModal = useCallback(() => { // Sets isBootloader to true to avoid having the renderBootloaderStep rendered, // on which the user could re-trigger a bootloader repairing scenario that is not needed setState(prevState => { return { ...prevState, deviceInfo: prevState.deviceInfo ? { ...prevState.deviceInfo, isBootloader: false } : null, }; }); setRepairModalOpened(null); }, []); const onRetry = useCallback(() => { setResetIndex(currIndex => currIndex + 1); setState(s => getInitialState(s.device)); }, []); const onAutoRepair = useCallback(() => { setRepairModalOpened({ auto: true, }); }, []); return { ...state, repairModalOpened, onRetry, onAutoRepair, closeRepairModal, onRepairModal, }; }; return { useHook, mapResult, }; };