@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
367 lines (317 loc) • 13 kB
text/typescript
import Transport from "@ledgerhq/hw-transport";
import { DeviceModelId, getDeviceModel, identifyTargetId } from "@ledgerhq/devices";
import { StatusCodes, TransportStatusError, UnexpectedBootloader } from "@ledgerhq/errors";
import { Observable, throwError, Subscription } from "rxjs";
import { App, DeviceInfo, idsToLanguage, languageIds } from "@ledgerhq/types-live";
import { LocalTracer } from "@ledgerhq/logs";
import type { ListAppsEvent, ListAppsResult, ListAppResponse } from "./types";
import hwListApps from "../hw/listApps";
import customLockScreenFetchSize from "../hw/customLockScreenFetchSize";
import { listCryptoCurrencies, currenciesByMarketcap, findCryptoCurrencyById } from "../currencies";
import ManagerAPI from "../manager/api";
import { getDeviceName } from "../device/use-cases/getDeviceNameUseCase";
import { getLatestFirmwareForDeviceUseCase } from "../device/use-cases/getLatestFirmwareForDeviceUseCase";
import { getProviderIdUseCase } from "../device/use-cases/getProviderIdUseCase";
import { calculateDependencies, mapApplicationV2ToApp } from "./polyfill";
import { ManagerApiRepository } from "../device/factories/HttpManagerApiRepositoryFactory";
import { isCustomLockScreenSupported } from "../device/use-cases/isCustomLockScreenSupported";
import { DeviceId } from "@ledgerhq/client-ids/ids";
// Hash discrepancies for these apps do NOT indicate a potential update,
// these apps have a mechanism that makes their hash change every time.
const appsWithDynamicHashes = ["Fido U2F", "Security Key"];
// Empty hash data means we won't have information on the app.
const emptyHashData = "0".repeat(64);
type ListAppsParams = {
transport: Transport;
deviceInfo: DeviceInfo;
managerDevModeEnabled: boolean;
forceProvider: number;
managerApiRepository: ManagerApiRepository;
deviceProxyModel?: DeviceModelId;
};
export const listApps = ({
transport,
deviceInfo,
deviceProxyModel,
managerDevModeEnabled,
forceProvider,
managerApiRepository,
}: ListAppsParams): Observable<ListAppsEvent> => {
const tracer = new LocalTracer("list-apps", { transport: transport.getTraceContext() });
tracer.trace("Using new version", { deviceInfo });
if (deviceInfo.isOSU || deviceInfo.isBootloader) {
return throwError(() => new UnexpectedBootloader(""));
}
const deviceModelId =
transport?.deviceModel?.id ||
identifyTargetId(deviceInfo.targetId as number)?.id ||
deviceProxyModel;
return new Observable(o => {
let sub: Subscription;
async function main() {
const provider = getProviderIdUseCase({ deviceInfo, forceProvider });
if (!deviceModelId) throw new Error("Bad usage of listAppsV2: missing deviceModelId");
const deviceModel = getDeviceModel(deviceModelId);
const bytesPerBlock = deviceModel.getBlockSize(deviceInfo.version);
/** The following are several asynchronous sequences running in parallel */
/**
* Sequence 1: obtain the full data regarding apps installed on the device
* -> list raw data of apps installed on device
* -> then filter apps (eliminate language packs and such)
* -> then fetch matching app metadata using apps' hashes
*/
let listAppsResponsePromise: Promise<ListAppResponse>;
function listAppsWithSingleCommand(): Promise<ListAppResponse> {
return hwListApps(transport);
}
function listAppsWithManagerApi(): Promise<ListAppResponse> {
return new Promise<ListAppResponse>((resolve, reject) => {
// TODO: migrate this ManagerAPI call to ManagerApiRepository
sub = ManagerAPI.listInstalledApps(transport, {
targetId: deviceInfo.targetId,
perso: "perso_11",
}).subscribe({
next: e => {
switch (e.type) {
case "result":
resolve(e.payload);
break;
case "device-permission-granted":
case "device-permission-requested":
o.next(e);
break;
case "device-id": {
// Normalize deviceId to DeviceId object (SocketEvent may have string or DeviceId)
const deviceIdValue =
typeof e.deviceId === "string" ? DeviceId.fromString(e.deviceId) : e.deviceId;
o.next({
type: "device-id",
deviceId: deviceIdValue,
});
break;
}
}
},
error: reject,
});
});
}
if (deviceInfo.managerAllowed) {
// If the user has already allowed a secure channel during this session we can directly
// ask the device for the installed applications instead of going through a scriptrunner,
// this is a performance optimization, part of a larger rework with Manager API v2.
tracer.trace("Using direct apdu listapps");
listAppsResponsePromise = listAppsWithSingleCommand().catch(e => {
// For some old versions of the firmware, the listapps command is not supported.
// In this case, we fallback to the scriptrunner listapps.
if (
e instanceof TransportStatusError &&
[
StatusCodes.CLA_NOT_SUPPORTED,
StatusCodes.INS_NOT_SUPPORTED,
StatusCodes.UNKNOWN_APDU,
0x6e01, // No StatusCodes definition
0x6d01, // No StatusCodes definition
].includes(e.statusCode)
) {
tracer.trace("Fallback to scriptrunner listapps");
return listAppsWithManagerApi();
}
throw e;
});
} else {
// Fallback to original web-socket list apps
tracer.trace("Using scriptrunner listapps");
listAppsResponsePromise = listAppsWithManagerApi();
}
const filteredListAppsPromise = listAppsResponsePromise.then(result => {
// Empty HashData can come from apps that are not real apps (such as language packs)
// or custom applications that have been sideloaded.
return result
.filter(({ hash_code_data }) => hash_code_data !== emptyHashData)
.map(({ hash, name }) => ({ hash, name }));
});
const listAppsAndMatchesPromise = filteredListAppsPromise.then(result => {
const hashes = result.map(({ hash }) => hash);
const matches = result.length
? managerApiRepository
.getAppsByHash(hashes)
.then(matches => matches.map(appV2 => (appV2 ? mapApplicationV2ToApp(appV2) : null)))
: [];
return Promise.all([result, matches]);
});
/**
* Sequence 2: get information about current and latest firmware available
* for the device
*/
const deviceVersionPromise = managerApiRepository.getDeviceVersion({
targetId: deviceInfo.targetId,
providerId: provider,
});
const currentFirmwarePromise = deviceVersionPromise.then(deviceVersion =>
managerApiRepository.getCurrentFirmware({
deviceId: deviceVersion.id,
version: deviceInfo.version,
providerId: provider,
}),
);
const latestFirmwarePromise = currentFirmwarePromise.then(currentFirmware =>
getLatestFirmwareForDeviceUseCase(deviceInfo, managerApiRepository).then(
updateAvailable => ({
...currentFirmware,
updateAvailable,
}),
),
);
/**
* Sequence 3: get catalog of apps available for the device
*/
const catalogForDevicesPromise = managerApiRepository
.catalogForDevice({
provider,
targetId: deviceInfo.targetId,
firmwareVersion: deviceInfo.version,
})
.then(apps => apps.map(mapApplicationV2ToApp));
/**
* Sequence 4: list all currencies, sorted by market cp
*/
const sortedCryptoCurrenciesPromise = currenciesByMarketcap(
listCryptoCurrencies(managerDevModeEnabled, true),
);
/**
* Sequence 5: get language pack available for the device
*/
const languagePackForDevicePromise = managerApiRepository.getLanguagePackagesForDevice(
deviceInfo,
forceProvider,
);
/* Running all sequences 1 2 3 4 5 defined above in parallel */
const [[listApps, matches], catalogForDevice, firmware, sortedCryptoCurrencies, languages] =
await Promise.all([
listAppsAndMatchesPromise,
catalogForDevicesPromise,
latestFirmwarePromise,
sortedCryptoCurrenciesPromise,
languagePackForDevicePromise,
]);
calculateDependencies();
/**
* Associate a market cap sorting index to each app of the catalog of
* available apps.
*/
catalogForDevice.forEach(app => {
const crypto = app.currencyId && findCryptoCurrencyById(app.currencyId);
if (crypto) {
app.indexOfMarketCap = sortedCryptoCurrencies.indexOf(crypto);
}
});
/**
* Aggregate the data obtained above to build the list of installed apps
* with their full metadata.
*/
const installedList: App[] = [];
listApps.forEach((item, index) => {
if (item == null) return;
const { name: localName, hash: localHash } = item;
const matchFromHash = matches[index];
if (matchFromHash && matchFromHash.hash === localHash) {
installedList.push(matchFromHash as App);
return;
}
// If the hash is not static (ex: Fido app) we need to find the app by its name using the catalog
const matchFromCatalog = catalogForDevice.find(({ name }) => name === localName);
tracer.trace(`Falling back to catalog for ${localName}`, {
localName,
matchFromCatalog: Boolean(matchFromCatalog),
});
if (matchFromCatalog) {
installedList.push(matchFromCatalog);
}
});
tracer.trace("Installed and in catalog apps", {
installedApps: installedList.length,
inCatalogApps: catalogForDevice.length,
});
// Abused somewhere else
const appByName = catalogForDevice.reduce((result, app) => {
result[app.name] = app;
return result;
}, {});
const installedAppNames = {};
// Polyfill more data on the app installed
const installed = installedList.map(({ name, hash, bytes, version }) => {
installedAppNames[name] = true;
const appInCatalog = appByName[name];
const updateAvailable = appInCatalog?.hash !== hash;
const ignoreUpdate = appsWithDynamicHashes.includes(name);
const updated = ignoreUpdate || !updateAvailable;
const availableVersion = appInCatalog?.version || "";
const blocks = Math.ceil((bytes || appInCatalog.bytes || 0) / bytesPerBlock);
return {
name,
updated,
blocks,
hash,
version,
availableVersion,
};
});
// Used to hide apps that are dev tools if user didn't opt-in.
const appsListNames = catalogForDevice
.filter(
({ isDevTools, name }) =>
managerDevModeEnabled || !isDevTools || name in installedAppNames,
)
.map(({ name }) => name);
/**
* Obtain remaining metadata:
* - Ledger Stax/Europa custom picture: number of blocks taken in storage
* - Installed language pack
* - Device name
* */
// Stax/Europa specific, account for the size of the CLS for the storage bar.
let customImageBlocks = 0;
if (isCustomLockScreenSupported(deviceModelId) && !deviceInfo.isRecoveryMode) {
const customImageSize = await customLockScreenFetchSize(transport);
if (customImageSize) {
customImageBlocks = Math.ceil(customImageSize / bytesPerBlock);
}
}
const languageId: number = deviceInfo.languageId || languageIds.english;
const installedLanguagePack = languages.find(
lang => lang.language === idsToLanguage[languageId],
);
// Will not prompt user since we've allowed secure channel already.
const deviceName = await getDeviceName(transport);
const result: ListAppsResult = {
appByName,
appsListNames,
deviceInfo,
deviceModelId,
installed,
installedAvailable: !!installedList,
installedLanguagePack,
// Not strictly listApps content.
firmware,
customImageBlocks,
deviceName,
};
o.next({
type: "result",
result,
});
}
main().then(
function onfulfilled() {
o.complete();
},
function onrejected(e) {
o.error(e);
},
);
return () => {
if (sub) sub.unsubscribe();
};
});
};