@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
457 lines • 19.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.predictOptimisticState = exports.getNextAppOp = exports.getActionPlan = exports.updateAllProgress = exports.isLiveSupportedApp = exports.isOutOfMemoryState = exports.isIncompleteState = exports.distribute = exports.reducer = exports.initState = void 0;
exports.getBlockSize = getBlockSize;
const rxjs_1 = require("rxjs");
const flatMap_1 = __importDefault(require("lodash/flatMap"));
const semver_1 = __importDefault(require("semver"));
const devices_1 = require("@ledgerhq/devices");
const types_1 = require("./types");
const currencies_1 = require("../currencies");
const errors_1 = require("../errors");
const live_env_1 = require("@ledgerhq/live-env");
const errors_2 = require("@ledgerhq/errors");
const initState = ({ deviceModelId, appsListNames, installed, appByName, ...listAppsResult }, appsToRestore) => {
let state = {
...listAppsResult,
installed,
appByName,
apps: appsListNames.map(name => appByName[name]).filter(Boolean),
deviceModel: (0, devices_1.getDeviceModel)(deviceModelId),
recentlyInstalledApps: [],
installQueue: [],
uninstallQueue: [],
updateAllQueue: [],
currentProgressSubject: new rxjs_1.Subject(),
currentError: null,
currentAppOp: null,
skippedAppOps: [],
skipAppDataBackup: false,
};
if (appsToRestore) {
state = appsToRestore
.filter(name => appByName[name] && !installed.some(a => a.name === name))
.map(name => ({
type: "install",
name,
}))
.reduce(exports.reducer, state);
}
return state;
};
exports.initState = initState;
// ^TODO move this to legacyDependencies.js
// we should have dependency as part of the data!
const reorderInstallQueue = (appByName, apps) => {
const list = [];
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, apps) => reorderInstallQueue(appByName, apps.slice(0).reverse()).reverse();
const findDependents = (appByName, name) => {
const all = [];
for (const k in appByName) {
const app = appByName[k];
if (app.dependencies.includes(name)) {
all.push(app.name);
}
}
return all;
};
const reducer = (state, action) => {
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 rxjs_1.Subject(),
};
}
else if (event.type === "runSuccess") {
if (state.currentProgressSubject) {
state.currentProgressSubject.complete();
}
let nextState;
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 }) => ((0, live_env_1.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: types_1.SkipReason.AppAlreadyInstalled,
appOp: action,
});
return { ...state, skippedAppOps };
}
const appToInstall = 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_1.default.lt(state.deviceInfo.version, state.firmware?.updateAvailable?.final?.version || "")) {
throw new errors_2.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: types_1.SkipReason.NoSuchAppOnProvider,
appOp: action,
});
return { ...state, skippedAppOps };
}
else {
// Some flows are not, and we want to stop with an error.
throw new errors_1.NoSuchAppOnProvider("", { appName: name });
}
}
const dependencies = appToInstall.dependencies;
const dependentsOfDep = (0, flatMap_1.default)(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,
};
}
}
};
exports.reducer = reducer;
const defaultConfig = {
warnMemoryRatio: 0.1,
sortApps: false,
};
// calculate all size information useful for display
const distribute = (state, config) => {
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)
(0, currencies_1.findCryptoCurrency)(c => c.name === name) ||
// Else take the first one with that manager app
(0, currencies_1.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,
};
};
exports.distribute = distribute;
function getBlockSize(state) {
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
const isIncompleteState = (state) => state.installed.some(a => !a.name);
exports.isIncompleteState = isIncompleteState;
// calculate if a given state (typically a predicted one) is out of memory (meaning impossible to reach with a device)
const isOutOfMemoryState = (state) => {
const { totalAppsBlocks, appsSpaceBlocks } = (0, exports.distribute)(state);
return totalAppsBlocks > appsSpaceBlocks;
};
exports.isOutOfMemoryState = isOutOfMemoryState;
const isLiveSupportedApp = (app) => {
const currency = app?.currencyId ? (0, currencies_1.findCryptoCurrencyById)(app.currencyId) : null;
return currency ? (0, currencies_1.isCurrencySupported)(currency) : false;
};
exports.isLiveSupportedApp = isLiveSupportedApp;
const updateAllProgress = (state) => {
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));
};
exports.updateAllProgress = updateAllProgress;
// a series of operation to perform on the device for current state
const getActionPlan = (state) => state.uninstallQueue
.map(name => ({
type: "uninstall",
name,
}))
.concat(state.installQueue.map(name => ({
type: "install",
name,
})));
exports.getActionPlan = getActionPlan;
// get next operation to perform
const getNextAppOp = (state) => {
if (state.uninstallQueue.length) {
return {
type: "uninstall",
name: state.uninstallQueue[0],
};
}
else if (state.installQueue.length) {
return {
type: "install",
name: state.installQueue[0],
};
}
};
exports.getNextAppOp = getNextAppOp;
// resolve the State to predict when all queued ops are done
const predictOptimisticState = (state) => {
const s = { ...state, currentProgressSubject: null };
return (0, exports.getActionPlan)(s)
.map(appOp => ({
type: "onRunnerEvent",
event: {
type: "runSuccess",
appOp,
},
}))
.reduce(exports.reducer, s);
};
exports.predictOptimisticState = predictOptimisticState;
//# sourceMappingURL=logic.js.map