UNPKG

@ledgerhq/live-common

Version:
522 lines (475 loc) • 17.1 kB
import { $Shape } from "utility-types"; import { Subject } from "rxjs"; import flatMap from "lodash/flatMap"; import semver from "semver"; import { getDeviceModel } from "@ledgerhq/devices"; import { AppOp, State, Action, ListAppsResult, AppsDistribution, SkipReason } from "./types"; import { findCryptoCurrency, findCryptoCurrencyById, isCurrencySupported } from "../currencies"; import { NoSuchAppOnProvider } from "../errors"; import { App } from "@ledgerhq/types-live"; import { getEnv } from "@ledgerhq/live-env"; import { LatestFirmwareVersionRequired } from "@ledgerhq/errors"; export const initState = ( { deviceModelId, appsListNames, installed, appByName, ...listAppsResult }: ListAppsResult, appsToRestore?: string[], ): State => { let state: State = { ...listAppsResult, installed, appByName, apps: appsListNames.map(name => appByName[name]).filter(Boolean), deviceModel: getDeviceModel(deviceModelId), recentlyInstalledApps: [], installQueue: [], uninstallQueue: [], updateAllQueue: [], currentProgressSubject: new Subject(), currentError: null, currentAppOp: null, skippedAppOps: [], skipAppDataBackup: false, }; if (appsToRestore) { state = appsToRestore .filter(name => appByName[name] && !installed.some(a => a.name === name)) .map( name => <Action>{ type: "install", name, }, ) .reduce(reducer, state); } return state; }; // ^TODO move this to legacyDependencies.js // we should have dependency as part of the data! const reorderInstallQueue = (appByName: Record<string, App>, apps: string[]): string[] => { const list: string[] = []; apps.forEach(app => { if (list.includes(app)) return; if (app in appByName) { const deps = appByName[app].dependencies; deps.forEach(dep => { if (apps.includes(dep) && !list.includes(dep)) { list.push(dep); } }); } list.push(app); }); return list; }; const reorderUninstallQueue = (appByName: Record<string, App>, apps: string[]): string[] => reorderInstallQueue(appByName, apps.slice(0).reverse()).reverse(); const findDependents = (appByName: Record<string, App>, name: string): string[] => { const all: string[] = []; for (const k in appByName) { const app = appByName[k]; if (app.dependencies.includes(name)) { all.push(app.name); } } return all; }; export const reducer = (state: State, action: Action): State => { switch (action.type) { case "reset": return action.initialState; case "onRunnerEvent": { // an app operation was correctly prefered. update state accordingly const { event } = action; const { appOp } = event; if (event.type === "runStart") { return { ...state, currentAppOp: appOp, currentProgressSubject: new Subject(), }; } else if (event.type === "runSuccess") { if (state.currentProgressSubject) { state.currentProgressSubject.complete(); } let nextState: State; if (appOp.type === "install") { const app = state.apps.find(a => a.name === appOp.name); nextState = { ...state, currentAppOp: null, currentProgressSubject: null, currentError: null, recentlyInstalledApps: state.recentlyInstalledApps.concat(appOp.name), // append the app to known installed apps installed: state.installed .filter(o => o.name !== appOp.name) .concat({ name: appOp.name, updated: true, hash: app ? app.hash : "", blocks: app && app.bytes ? Math.ceil(app.bytes / getBlockSize(state)) : 0, version: app ? app.version : "", availableVersion: app ? app.version : "", }), // remove the install action installQueue: state.installQueue.filter(name => appOp.name !== name), }; } else { nextState = { ...state, currentAppOp: null, currentProgressSubject: null, currentError: null, // remove apps to known installed apps installed: state.installed.filter(i => appOp.name !== i.name), // remove the uninstall action uninstallQueue: state.uninstallQueue.filter(name => appOp.name !== name), }; } if (nextState.installQueue.length + nextState.uninstallQueue.length === 0) { nextState.updateAllQueue = []; } return nextState; } else if (event.type === "runError") { // TO BE CONTINUED LL-2138 // to handle recovering from error. however we are not correctly using it at the moment. /* const error = event.error; if (error instanceof ManagerDeviceLockedError) { return { ...state, currentError: { appOp: appOp, error: event.error } }; } */ if (state.currentProgressSubject) { state.currentProgressSubject.complete(); } // any other error stops everything return { ...state, uninstallQueue: [], installQueue: [], updateAllQueue: [], currentAppOp: null, currentProgressSubject: null, currentError: { appOp: appOp, error: event.error, }, }; } else if (event.type === "runProgress") { // we just emit on the subject if (state.currentProgressSubject) { state.currentProgressSubject.next(event.progress); } return state; // identity state will not re-render the UI } return state; } case "recover": return { ...state, currentError: null }; case "wiped": // Reconciliate an already achieved wipe via single uninstall apdu return { ...state, installed: [], skipAppDataBackup: false, }; case "wipe": return { ...state, currentError: null, installQueue: [], uninstallQueue: reorderUninstallQueue( state.appByName, state.installed.map(({ name }) => name), ), skipAppDataBackup: true, }; case "updateAll": { let installList = state.installQueue.slice(0); let uninstallList = state.uninstallQueue.slice(0); state.installed .filter( ({ updated, name }) => (getEnv("MOCK_APP_UPDATE") || !updated) && state.appByName[name], ) .forEach(app => { const dependents = state.installed .filter(a => { const depApp = state.appByName[a.name]; return depApp && depApp.dependencies.includes(app.name); }) .map(a => a.name); uninstallList = uninstallList.concat([app.name, ...dependents]); installList = installList.concat([app.name, ...dependents]); }); const installQueue = reorderInstallQueue(state.appByName, installList); const uninstallQueue = reorderUninstallQueue(state.appByName, uninstallList); /** since install queue === uninstall queue in this case we can map the update queue to either one */ const updateAllQueue = installQueue; return { ...state, currentError: null, installQueue, uninstallQueue, updateAllQueue, }; } case "install": { const { name, allowPartialDependencies } = action; if (state.installQueue.includes(name)) { // already queued for install return state; } const existing = state.installed.find(app => app.name === name); const skippedAppOps = [...state.skippedAppOps]; if (existing && existing.updated && state.installedAvailable) { // already installed and up to date skippedAppOps.push({ reason: SkipReason.AppAlreadyInstalled, appOp: action, }); return { ...state, skippedAppOps }; } const appToInstall: App | null | undefined = state.appByName[name]; // The target application was not found in the specified provider BUT // we can update the firmware instead, and perhaps in the new FW it is available. if ( !appToInstall && state.firmware?.updateAvailable?.final?.version && semver.lt(state.deviceInfo.version, state.firmware?.updateAvailable?.final?.version || "") ) { throw new LatestFirmwareVersionRequired("LatestFirmwareVersionRequired", { current: state.deviceInfo.version, latest: state.firmware?.updateAvailable?.final?.version, }); } // The target application was not found in the specified provider. if (!appToInstall) { if (allowPartialDependencies) { // Some flows are resillient to missing operations. skippedAppOps.push({ reason: SkipReason.NoSuchAppOnProvider, appOp: action, }); return { ...state, skippedAppOps }; } else { // Some flows are not, and we want to stop with an error. throw new NoSuchAppOnProvider("", { appName: name }); } } const dependencies = appToInstall.dependencies; const dependentsOfDep = flatMap(dependencies, dep => findDependents(state.appByName, dep)); const depsInstalledOutdated = state.installed.filter( a => dependencies.includes(a.name) && !a.updated, ); let installList = state.installQueue; // installing an app will remove if planned for uninstalling let uninstallList = state.uninstallQueue.filter(u => name !== u && !dependencies.includes(u)); if (state.uninstallQueue.length !== uninstallList.length) { // app was asked for uninstall so it means we need to just cancel. // TODO cover this in tests... } else { // if app is already installed but outdated, we'll need to update related deps if ((existing && !existing.updated) || depsInstalledOutdated.length) { // if app has installed direct dependent apps, we'll need to update them too const directDependents = findDependents(state.appByName, name).filter(d => state.installed.some(a => a.name === d), ); const outdated = state.installed .filter( a => !a.updated && [name, ...dependencies, ...directDependents, ...dependentsOfDep].includes(a.name), ) .map(a => a.name); uninstallList = uninstallList.concat(outdated); installList = installList.concat(outdated); } installList = installList.concat([ ...dependencies.filter(d => !state.installed.some(a => a.name === d)), name, ]); } const installQueue = reorderInstallQueue(state.appByName, installList); const uninstallQueue = reorderUninstallQueue(state.appByName, uninstallList); return { ...state, currentError: null, installQueue, uninstallQueue }; } case "setCustomImage": { const { lastSeenCustomImage } = action; const { size } = lastSeenCustomImage; return { ...state, customImageBlocks: Math.ceil(size / getBlockSize(state)), }; } case "uninstall": { const { name } = action; if (state.uninstallQueue.includes(name)) { // already queued return state; } // uninstalling an app will remove from installQueue as well as direct deps const installQueue = state.installQueue.filter(u => name !== u); let uninstallQueue = state.uninstallQueue; if ( state.installed.some(a => a.name === name || a.name === "") || action.force || // if installed unavailable and it was not a cancellation // TODO cover this in tests... (!state.installedAvailable && !state.installQueue.includes(name)) ) { uninstallQueue = reorderUninstallQueue( state.appByName, uninstallQueue.concat([ ...findDependents(state.appByName, name).filter(d => state.installed.some(a => a.name === d), ), name, ]), ); } return { ...state, currentError: null, installQueue, uninstallQueue, skipAppDataBackup: true, }; } } }; const defaultConfig = { warnMemoryRatio: 0.1, sortApps: false, }; // calculate all size information useful for display export const distribute = ( state: State, config?: $Shape<typeof defaultConfig>, ): AppsDistribution => { const { customImageBlocks } = state; const { warnMemoryRatio, sortApps } = { ...defaultConfig, ...config }; const blockSize = getBlockSize(state); const totalBytes = state.deviceModel.memorySize; const totalBlocks = Math.floor(totalBytes / blockSize); const osBytes = (state.firmware && state.firmware.bytes) || 0; const osBlocks = Math.ceil(osBytes / blockSize); const languagePackBytes = state.installedLanguagePack?.bytes || 0; const languagePackBlocks = Math.ceil(languagePackBytes / blockSize); const appsSpaceBlocks = totalBlocks - osBlocks - customImageBlocks - languagePackBlocks; const appsSpaceBytes = appsSpaceBlocks * blockSize; let totalAppsBlocks = 0; const apps = state.installed.map(app => { const { name, blocks } = app; totalAppsBlocks += blocks; const currency = // try to find the "official" currency when possible (2 currencies can have the same manager app and ticker) findCryptoCurrency(c => c.name === name) || // Else take the first one with that manager app findCryptoCurrency(c => c.managerAppName === name); return { currency, name, blocks, bytes: blocks * blockSize, }; }); if (sortApps) { apps.sort((a, b) => b.blocks - a.blocks); } const totalAppsBytes = totalAppsBlocks * blockSize; const freeSpaceBlocks = appsSpaceBlocks - totalAppsBlocks; const freeSpaceBytes = freeSpaceBlocks * blockSize; const shouldWarnMemory = freeSpaceBlocks / appsSpaceBlocks < warnMemoryRatio; return { totalBlocks, totalBytes, osBlocks, osBytes, apps, appsSpaceBlocks, appsSpaceBytes, totalAppsBlocks, customImageBlocks, languagePackBlocks, totalAppsBytes, freeSpaceBlocks, freeSpaceBytes, shouldWarnMemory, }; }; export function getBlockSize(state: State): number { return state.deviceModel.getBlockSize(state.deviceInfo.version); } // tells if the state is "incomplete" to implement the Manager v2 feature // this happens when some apps are unrecognized export const isIncompleteState = (state: State): boolean => state.installed.some(a => !a.name); // calculate if a given state (typically a predicted one) is out of memory (meaning impossible to reach with a device) export const isOutOfMemoryState = (state: State): boolean => { const { totalAppsBlocks, appsSpaceBlocks } = distribute(state); return totalAppsBlocks > appsSpaceBlocks; }; export const isLiveSupportedApp = (app: App): boolean => { const currency = app?.currencyId ? findCryptoCurrencyById(app.currencyId) : null; return currency ? isCurrencySupported(currency) : false; }; export const updateAllProgress = (state: State): number => { const total = state.updateAllQueue.length; /** each uninstall and install comes in a pair and have a weight of 0.5 in the progress */ const current = (state.uninstallQueue.length + state.installQueue.length) / 2; if (total === 0 || current === 0) return 1; return Math.max(0, Math.min((total - current) / total, 1)); }; // a series of operation to perform on the device for current state export const getActionPlan = (state: State): AppOp[] => state.uninstallQueue .map( name => <AppOp>{ type: "uninstall", name, }, ) .concat( state.installQueue.map( name => <AppOp>{ type: "install", name, }, ), ); // get next operation to perform export const getNextAppOp = (state: State): AppOp | null | undefined => { if (state.uninstallQueue.length) { return { type: "uninstall", name: state.uninstallQueue[0], }; } else if (state.installQueue.length) { return { type: "install", name: state.installQueue[0], }; } }; // resolve the State to predict when all queued ops are done export const predictOptimisticState = (state: State): State => { const s = { ...state, currentProgressSubject: null }; return getActionPlan(s) .map( appOp => <Action>{ type: "onRunnerEvent", event: { type: "runSuccess", appOp, }, }, ) .reduce(reducer, s); };