@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
187 lines • 10.1 kB
JavaScript
import { Observable, from, of, throwError } from "rxjs";
import { catchError, concatMap, delay, mergeMap } from "rxjs/operators";
import { DeviceOnDashboardExpected, ManagerNotEnoughSpaceError, StatusCodes, TransportError, TransportStatusError, DisconnectedDevice, } from "@ledgerhq/errors";
import { getDeviceModel } from "@ledgerhq/devices";
import getDeviceInfo from "./getDeviceInfo";
import { ImageLoadRefusedOnDevice, ImageCommitRefusedOnDevice } from "../errors";
import getAppAndVersion from "./getAppAndVersion";
import { isDashboardName } from "./isDashboardName";
import attemptToQuitApp from "./attemptToQuitApp";
import customLockScreenFetchSize from "./customLockScreenFetchSize";
import customLockScreenFetchHash from "./customLockScreenFetchHash";
import { gzip } from "pako";
import { getScreenSpecs } from "../device/use-cases/screenSpecs";
import { DeviceDisconnectedWhileSendingError } from "@ledgerhq/device-management-kit";
import { withTransport } from "../deviceSDK/transports/core";
const MAX_APDU_SIZE = 255;
const COMPRESS_CHUNK_SIZE = 2048;
/**
* Type guard to check if the given error is a DeviceDisconnectedWhileSendingError.
* Ensures that the error is an object, is not null, and matches the expected structure.
* This is used to identify specific disconnection errors from the DMK device.
*/
const isDmkDeviceDisconnectedError = (err) => typeof err === "object" &&
err !== null &&
(err instanceof DeviceDisconnectedWhileSendingError ||
("_tag" in err && err._tag === "DeviceDisconnectedWhileSendingError"));
export default function loadImage({ deviceId, deviceName, request, }) {
const { hexImage, padImage = true, deviceModelId } = request;
const screenSpecs = getScreenSpecs(deviceModelId);
const sub = withTransport(deviceId, deviceName ? { matchDeviceByName: deviceName } : undefined)(({ transportRef }) => new Observable(subscriber => {
const timeoutSub = of({ type: "unresponsiveDevice" })
.pipe(delay(1000))
.subscribe(e => subscriber.next(e));
const sub = from(getDeviceInfo(transportRef.current))
.pipe(mergeMap(async () => {
timeoutSub.unsubscribe();
const imageData = await generateCustomLockScreenImageFormat(hexImage, true, !!padImage, screenSpecs);
const imageLength = imageData.length;
const imageSize = Buffer.alloc(4);
imageSize.writeUIntBE(imageLength, 0, 4);
subscriber.next({ type: "loadImagePermissionRequested" });
const createImageResponse = await transportRef.current.send(0xe0, 0x60, 0x00, 0x00, imageSize, [StatusCodes.NOT_ENOUGH_SPACE, StatusCodes.USER_REFUSED_ON_DEVICE, StatusCodes.OK]);
const createImageStatus = createImageResponse.readUInt16BE(createImageResponse.length - 2);
const createImageStatusStr = createImageStatus.toString(16);
// reads last 2 bytes which correspond to the status
if (createImageStatus === StatusCodes.USER_REFUSED_ON_DEVICE) {
return subscriber.error(new ImageLoadRefusedOnDevice(createImageStatusStr, {
productName: getDeviceModel(deviceModelId).productName,
}));
}
else if (createImageStatus === StatusCodes.NOT_ENOUGH_SPACE) {
return subscriber.error(new ManagerNotEnoughSpaceError());
}
else if (createImageStatus !== StatusCodes.OK) {
return subscriber.error(new TransportError("Unexpected device response", createImageStatusStr));
}
let currentOffset = 0;
// offset in number of charaters
while (currentOffset < imageLength) {
subscriber.next({
type: "progress",
progress: (currentOffset + 1) / imageLength,
});
// chunkSize in number of bytes
const chunkSize = Math.min(MAX_APDU_SIZE - 4, imageLength - currentOffset);
// we subtract 4 because the first 4 bytes of the data part of the apdu are used for
// passing the offset of the chunk
const chunkDataBuffer = imageData.slice(currentOffset, currentOffset + chunkSize);
const chunkOffsetBuffer = Buffer.alloc(4);
chunkOffsetBuffer.writeUIntBE(currentOffset, 0, 4);
const apduData = Buffer.concat([chunkOffsetBuffer, chunkDataBuffer]);
await transportRef.current.send(0xe0, 0x61, 0x00, 0x00, apduData);
currentOffset += chunkSize;
}
subscriber.next({ type: "commitImagePermissionRequested" });
const commitResponse = await transportRef.current.send(0xe0, 0x62, 0x00, 0x00, Buffer.from([]), [0x9000, 0x5501]);
const commitStatus = commitResponse.readUInt16BE(commitResponse.length - 2);
const commitStatusStr = commitStatus.toString(16);
// reads last 2 bytes which correspond to the status
if (commitStatus === 0x5501) {
return subscriber.error(new ImageCommitRefusedOnDevice(commitStatusStr, {
productName: getDeviceModel(deviceModelId).productName,
}));
}
else if (commitStatus !== 0x9000) {
return subscriber.error(new TransportError("Unexpected device response", commitStatusStr));
}
// Fetch image size
const imageBytes = await customLockScreenFetchSize(transportRef.current);
// Fetch image hash
const imageHash = await customLockScreenFetchHash(transportRef.current);
subscriber.next({
type: "imageLoaded",
imageSize: imageBytes,
imageHash,
});
subscriber.complete();
}), catchError((e) => {
if (e instanceof DeviceOnDashboardExpected ||
(e &&
e instanceof TransportStatusError &&
[0x6e00, 0x6d00, 0x6e01, 0x6d01, 0x6d02].includes(e.statusCode))) {
return from(getAppAndVersion(transportRef.current)).pipe(concatMap(appAndVersion => {
return !isDashboardName(appAndVersion.name)
? attemptToQuitApp(transportRef.current, appAndVersion)
: of({
type: "appDetected",
});
}));
}
return throwError(() => e);
}))
.subscribe(subscriber);
return () => {
timeoutSub.unsubscribe();
sub.unsubscribe();
};
})).pipe(catchError(err => {
if (err.name === "TimeoutError" || isDmkDeviceDisconnectedError(err)) {
return throwError(() => new DisconnectedDevice());
}
return throwError(() => err);
}));
return sub;
}
function padHexImage(hexImage, screenSpecs) {
// hexImage is a string that is a hex representation of the image data
// each character is a pixel (between 0 and 15) and it starts from the top right
// corner, goes down the column and then to the next column, until the bottom left
// We need to pad the image on the edges to match the screen specs.
const sourceWidth = screenSpecs.width - screenSpecs.paddingLeft - screenSpecs.paddingRight;
const sourceHeight = screenSpecs.height - screenSpecs.paddingTop - screenSpecs.paddingBottom;
const destHeight = screenSpecs.height;
let result = "";
// add right padding
result += "0".repeat(screenSpecs.paddingRight * destHeight);
// add the image data
for (let columnIndex = 0; columnIndex < sourceWidth; columnIndex++) {
const column = hexImage.slice(columnIndex * sourceHeight, (columnIndex + 1) * sourceHeight);
const topPadding = "0".repeat(screenSpecs.paddingTop);
const paddingBottom = "0".repeat(screenSpecs.paddingBottom);
result += topPadding + column + paddingBottom;
}
// add left padding
result += "0".repeat(screenSpecs.paddingLeft * destHeight);
return result;
}
const bitsPerPixelToBppIndicator = {
1: 0,
4: 2,
};
export async function generateCustomLockScreenImageFormat(hexImage, compressImage, padImage, screenSpecs) {
const width = screenSpecs.width;
const height = screenSpecs.height;
const bpp = bitsPerPixelToBppIndicator[screenSpecs.bitsPerPixel];
const compression = compressImage ? 1 : 0;
const header = Buffer.alloc(8);
header.writeUInt16LE(width, 0); // width
header.writeUInt16LE(height, 2); // height
header.writeUInt8((bpp << 4) | compression, 4);
const paddedHexImage = padImage ? padHexImage(hexImage, screenSpecs) : hexImage;
const imgData = Buffer.from(paddedHexImage, "hex");
if (!compressImage) {
const dataLength = imgData.length;
header.writeUInt8(dataLength & 0xff, 5); // lowest byte
header.writeUInt8((dataLength >> 8) & 0xff, 6); // middle byte
header.writeUInt8((dataLength >> 16) & 0xff, 7); // biggest byte
return Buffer.concat([header, imgData]);
}
const chunkedImgData = [];
for (let i = 0; i < imgData.length; i += COMPRESS_CHUNK_SIZE) {
chunkedImgData.push(imgData.slice(i, i + COMPRESS_CHUNK_SIZE));
}
const compressedChunkedImgData = await Promise.all(chunkedImgData.map(async (chunk) => {
const compressedChunk = await gzip(chunk);
const compressedChunkSize = Buffer.alloc(2);
compressedChunkSize.writeUInt16LE(compressedChunk.length);
return Buffer.concat([compressedChunkSize, compressedChunk]);
}));
const compressedData = Buffer.concat(compressedChunkedImgData);
const dataLength = compressedData.length;
header.writeUInt8(dataLength & 0xff, 5); // lowest byte
header.writeUInt8((dataLength >> 8) & 0xff, 6); // middle byte
header.writeUInt8((dataLength >> 16) & 0xff, 7); // biggest byte
return Buffer.concat([header, Buffer.concat(compressedChunkedImgData)]);
}
//# sourceMappingURL=customLockScreenLoad.js.map