@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
522 lines (475 loc) • 17.1 kB
text/typescript
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);
};