@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
301 lines (264 loc) • 8.37 kB
text/typescript
// Ledger internal speculos testing framework.
// loading this file have side effects and is only for Node.
import sample from "lodash/sample";
import invariant from "invariant";
import path from "path";
import semver from "semver";
import { promises as fsp } from "fs";
import { log } from "@ledgerhq/logs";
import type { DeviceModelId } from "@ledgerhq/devices";
import type { AppCandidate } from "@ledgerhq/coin-framework/bot/types";
import { registerTransportModule } from "../hw";
import { getEnv } from "@ledgerhq/live-env";
import { getDependencies } from "../apps/polyfill";
import { findCryptoCurrencyByKeyword } from "../currencies";
import { formatAppCandidate } from "../bot/formatters";
import { mustUpgrade, shouldUpgrade } from "../apps";
import {
closeAllSpeculosDevices,
releaseSpeculosDevice,
createSpeculosDevice,
getMemorySpeculosDeviceInternal,
modelMap,
} from "@ledgerhq/speculos-transport";
import type { SpeculosTransport } from "@ledgerhq/speculos-transport";
export { closeAllSpeculosDevices, releaseSpeculosDevice, createSpeculosDevice };
export type { SpeculosTransport };
const modelMapPriority: Record<string, number> = {
nanos: 4,
"nanos+": 3,
nanox: 2,
blue: 1,
};
const defaultFirmware: Record<string, string> = {};
function hackBadSemver(str) {
const split = str.split(".");
const [x, y, , ...rest] = split;
let [, , z] = split;
if (rest.length) {
z += "-" + rest.join("-");
}
return [x, y, z].filter(Boolean).join(".");
}
// list all possible apps. sorted by latest first
export async function listAppCandidates(cwd: string): Promise<AppCandidate[]> {
let candidates: AppCandidate[] = [];
const models = <string[]>(await fsp.readdir(cwd))
.map(modelName => [modelName, modelMapPriority[modelName.toLowerCase()]])
.filter(([, priority]) => priority)
.sort((a, b) => <number>b[1] - <number>a[1])
.map(a => a[0]);
for (const modelName of models) {
const model = modelMap[modelName.toLowerCase()];
const p1 = path.join(cwd, modelName);
const firmwares = await fsp.readdir(p1);
firmwares.sort((a, b) => semver.compare(hackBadSemver(a), hackBadSemver(b)));
firmwares.reverse();
for (const firmware of firmwares) {
const p2 = path.join(p1, firmware);
const appNames = await fsp.readdir(p2);
for (const appName of appNames) {
const p3 = path.join(p2, appName);
const elfs = await fsp.readdir(p3);
const c: AppCandidate[] = [];
for (const elf of elfs) {
if (elf.startsWith("app_") && elf.endsWith(".elf")) {
const p4 = path.join(p3, elf);
const appVersion = elf.slice(4, elf.length - 4);
if (
semver.valid(appVersion) &&
!shouldUpgrade(appName, appVersion) &&
!mustUpgrade(appName, appVersion)
) {
c.push({
path: p4,
model,
firmware,
appName,
appVersion,
});
}
}
}
c.sort((a, b) => semver.compare(a.appVersion, b.appVersion));
c.reverse();
candidates = candidates.concat(c);
}
}
}
return candidates;
}
export type AppSearch = {
model?: DeviceModelId;
firmware?: string;
appName?: string;
appVersion?: string;
};
export function appCandidatesMatches(appCandidate: AppCandidate, search: AppSearch): boolean {
const searchFirmware = search.firmware || defaultFirmware[appCandidate.model];
return !!(
(!search.model || search.model === appCandidate.model) &&
(!search.appName ||
search.appName.replace(/ /g, "").toLowerCase() ===
appCandidate.appName.replace(/ /g, "").toLowerCase()) &&
((!searchFirmware && !appCandidate.firmware.includes("rc")) ||
appCandidate.firmware === searchFirmware ||
(searchFirmware && semver.satisfies(hackBadSemver(appCandidate.firmware), searchFirmware))) &&
(appCandidate.appVersion === search.appVersion ||
(!search.appVersion && !appCandidate.appVersion.includes("-")) ||
(search.appVersion && semver.satisfies(appCandidate.appVersion, search.appVersion)))
);
}
export const findLatestAppCandidate = (
appCandidates: AppCandidate[],
search: AppSearch,
): AppCandidate | null => {
let apps = appCandidates.filter(c => appCandidatesMatches(c, search));
if (apps.length === 0) {
return null;
}
apps = apps.sort((a, b) => semver.compare(b.appVersion, a.appVersion));
return apps[0];
};
export const findAppCandidate = (
appCandidates: AppCandidate[],
search: AppSearch,
picker: (arg0: AppCandidate[]) => AppCandidate = sample,
): AppCandidate | null | undefined => {
let apps = appCandidates.filter(c => appCandidatesMatches(c, search));
if (!search.appVersion && apps.length > 0) {
const appVersion = apps[0].appVersion;
apps = apps.filter(a => a.appVersion === appVersion);
}
const app = picker(apps);
if (apps.length > 1) {
log(
"speculos",
apps.length +
" app candidates (out of " +
appCandidates.length +
"):\n" +
apps.map((a, i) => " [" + i + "] " + formatAppCandidate(a)).join("\n"),
);
}
return app;
};
function eatDevice(parts: string[]): {
model?: DeviceModelId;
firmware?: string;
} {
if (parts.length > 0) {
const [modelQ, firmware] = parts[0].split("@");
const model: DeviceModelId = modelMap[(modelQ || "").toLowerCase()];
if (model) {
parts.shift();
if (firmware) {
return {
model,
firmware,
};
}
return {
model,
};
}
}
return {};
}
function parseAppSearch(query: string):
| {
search: AppSearch;
appName: string;
dependency: string | void;
}
| null
| undefined {
const parts = query.slice(9).split(":");
const { model, firmware } = eatDevice(parts);
if (parts.length === 0) return;
const [nameQ, versionQ] = parts[0].split("@");
const currency = findCryptoCurrencyByKeyword(nameQ);
const appName = currency ? currency.managerAppName : nameQ;
const appVersion = versionQ || undefined;
let dependency;
if (currency) {
dependency = getDependencies(currency.managerAppName)[0]?.replace(/ /g, "");
}
return {
search: {
model,
firmware,
appName,
appVersion,
},
appName,
dependency,
};
}
export async function createImplicitSpeculos(query: string): Promise<{
device: {
transport: SpeculosTransport;
id: string;
};
appCandidate: AppCandidate;
} | null> {
const coinapps = getEnv("COINAPPS");
invariant(coinapps, "COINAPPS folder is missing!");
const seed = getEnv("SEED");
invariant(seed, "SEED is missing!");
const apps = await listAppCandidates(coinapps);
const match = parseAppSearch(query);
invariant(
match,
"speculos: invalid format of '%s'. Usage example: speculos:nanoS:bitcoin@1.3.x",
query,
);
const { search, dependency, appName } = <
{
search: AppSearch;
appName: string;
dependency: string | undefined;
}
>match;
const appCandidate = findAppCandidate(apps, search);
invariant(appCandidate, "could not find an app that matches '%s'", query);
log("speculos", "using app " + formatAppCandidate(appCandidate as AppCandidate));
return appCandidate
? {
device: await createSpeculosDevice({
...appCandidate,
coinapps,
appName,
dependency,
seed,
}),
appCandidate,
}
: null;
}
async function openImplicitSpeculos(query: string) {
const r = await createImplicitSpeculos(query);
return r?.device.transport;
}
registerTransportModule({
id: "speculos",
open: (id): Promise<any> | null | undefined => {
if (!id) return;
if (id.startsWith("speculosID")) {
const obj = getMemorySpeculosDeviceInternal(id);
if (!obj) {
throw new Error("speculos transport was destroyed");
}
return Promise.resolve(obj.transport);
}
if (id.startsWith("speculos:")) {
return openImplicitSpeculos(id);
}
},
close: (transport, id) => {
if (id.startsWith("speculos")) {
return Promise.resolve();
} // todo close the speculos: case
},
disconnect: releaseSpeculosDevice,
});