@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
410 lines (386 loc) • 14 kB
text/typescript
import { Observable, Subject, of, share, takeUntil } from "rxjs";
import { catchError, tap, withLatestFrom } from "rxjs/operators";
import type {
DeviceManagementKit,
DeviceActionState,
InstallPlan,
ExecuteDeviceActionReturnType,
DeviceSessionState,
GetOsVersionResponse,
FirmwareUpdateContext as DmkFirmwareUpdateContext,
} from "@ledgerhq/device-management-kit";
import {
DeviceActionStatus,
DeviceSessionStateType,
UserInteractionRequired,
OutOfMemoryDAError,
DeviceModelId,
} from "@ledgerhq/device-management-kit";
import type {
ConnectAppDAOutput,
ConnectAppDAError,
ConnectAppDAIntermediateValue,
} from "@ledgerhq/live-dmk-shared";
import { DeviceInfo, FirmwareUpdateContext } from "@ledgerhq/types-live";
import {
UserRefusedAllowManager,
UserRefusedOnDevice,
LatestFirmwareVersionRequired,
UnsupportedFeatureError,
} from "@ledgerhq/errors";
import { DeviceId } from "@ledgerhq/client-ids/ids";
import type { SkippedAppOp } from "../apps/types";
import { SkipReason } from "../apps/types";
import { parseDeviceInfo } from "../deviceSDK/tasks/getDeviceInfo";
import { ConnectAppEvent } from "./connectApp";
import { NoSuchAppOnProvider } from "../errors";
export class ConnectAppEventMapper {
private openAppRequested: boolean = false;
private permissionRequested: boolean = false;
private lastSeenDeviceSent: boolean = false;
private installPlan: InstallPlan | null = null;
private deviceId: string | undefined = undefined;
private eventSubject = new Subject<ConnectAppEvent>();
constructor(
private dmk: DeviceManagementKit,
private sessionId: string,
private appName: string,
private events: ExecuteDeviceActionReturnType<
ConnectAppDAOutput,
ConnectAppDAError,
ConnectAppDAIntermediateValue
>,
) {}
map(): Observable<ConnectAppEvent> {
const cancelAction = this.events.cancel;
const unsubscribe = new Subject<void>();
// Create a shared observable for device session state
const deviceSessionState = this.dmk
.getDeviceSessionState({ sessionId: this.sessionId })
.pipe(share());
// Subscribe to device action events
this.events.observable
.pipe(
withLatestFrom(deviceSessionState),
tap(([event, deviceState]) => this.handleEvent(event, deviceState)),
takeUntil(unsubscribe),
catchError(error => this.handleError(error)),
)
.subscribe();
// Subscribe to device session state events
deviceSessionState
.pipe(
tap(deviceState => this.handleDeviceState(deviceState)),
takeUntil(unsubscribe),
)
.subscribe();
return new Observable<ConnectAppEvent>(observer => {
const sub = this.eventSubject.subscribe(observer);
return () => {
sub.unsubscribe();
cancelAction();
unsubscribe.next();
};
});
}
private handleDeviceState(deviceState: DeviceSessionState): void {
if (deviceState.sessionStateType === DeviceSessionStateType.Connected) {
return;
}
if (
deviceState.firmwareVersion?.metadata &&
deviceState.firmwareUpdateContext &&
!this.lastSeenDeviceSent
) {
this.lastSeenDeviceSent = true;
this.eventSubject.next({
type: "device-update-last-seen",
deviceInfo: this.mapDeviceInfo(deviceState.firmwareVersion.metadata),
latestFirmware: this.mapLatestFirmware(deviceState.firmwareUpdateContext),
});
}
}
private handleEvent(
event: DeviceActionState<ConnectAppDAOutput, ConnectAppDAError, ConnectAppDAIntermediateValue>,
deviceState: DeviceSessionState,
): void {
switch (event.status) {
case DeviceActionStatus.Pending:
this.handlePendingEvent(event.intermediateValue);
break;
case DeviceActionStatus.Completed:
this.handleCompletedEvent(event.output, deviceState);
break;
case DeviceActionStatus.Error:
this.handleErrorEvent(event.error, deviceState);
break;
case DeviceActionStatus.NotStarted:
case DeviceActionStatus.Stopped:
this.eventSubject.error(new Error("Unexpected device action status"));
break;
}
}
private handlePendingEvent(intermediateValue: ConnectAppDAIntermediateValue): void {
switch (intermediateValue.requiredUserInteraction) {
case UserInteractionRequired.ConfirmOpenApp:
if (!this.openAppRequested) {
this.openAppRequested = true;
this.eventSubject.next({ type: "ask-open-app", appName: this.appName });
}
break;
case UserInteractionRequired.AllowSecureConnection:
if (!this.permissionRequested) {
this.permissionRequested = true;
this.eventSubject.next({ type: "device-permission-requested" });
}
break;
case UserInteractionRequired.UnlockDevice:
this.eventSubject.next({ type: "lockedDevice" });
break;
case UserInteractionRequired.None:
if (this.openAppRequested) {
this.openAppRequested = false;
this.eventSubject.next({ type: "device-permission-granted" });
}
if (this.permissionRequested) {
this.permissionRequested = false;
this.eventSubject.next({ type: "device-permission-granted" });
// Simulate apps listing (not systematic step in LDMK)
this.eventSubject.next({ type: "listing-apps" });
}
if (intermediateValue.installPlan !== null) {
this.handleInstallPlan(intermediateValue.installPlan);
}
if (intermediateValue.deviceId) {
const deviceIdString = Buffer.from(intermediateValue.deviceId).toString("hex");
if (deviceIdString !== this.deviceId) {
this.deviceId = deviceIdString;
this.eventSubject.next({
type: "device-id",
deviceId: DeviceId.fromString(deviceIdString),
});
}
}
break;
case "device-deprecation":
if (intermediateValue.deviceDeprecation) {
this.eventSubject.next({
type: "deprecation",
deprecate: {
...intermediateValue.deviceDeprecation,
},
});
}
}
}
private handleInstallPlan(installPlan: InstallPlan): void {
// Handle install plan resolved events
if (this.installPlan === null) {
// Skipped applications
const alreadyInstalled = this.mapSkippedApps(
installPlan.alreadyInstalled,
SkipReason.AppAlreadyInstalled,
);
const missing = this.mapSkippedApps(
installPlan.missingApplications,
SkipReason.NoSuchAppOnProvider,
);
const skippedAppOps = [...alreadyInstalled, ...missing];
if (skippedAppOps.length > 0) {
this.eventSubject.next({
type: "some-apps-skipped",
skippedAppOps,
});
}
// Install queue content
this.eventSubject.next({
type: "listed-apps",
installQueue: installPlan.installPlan.map(app => app.versionName),
});
}
// Handle ongoing install events
this.eventSubject.next({
type: "inline-install",
progress: installPlan.currentProgress,
itemProgress: installPlan.currentIndex,
currentAppOp: {
type: "install",
name: installPlan.installPlan[installPlan.currentIndex]!.versionName,
},
installQueue: installPlan.installPlan
.map(app => app.versionName)
.slice(installPlan.currentIndex),
});
this.installPlan = installPlan;
}
private handleCompletedEvent(output: ConnectAppDAOutput, deviceState: DeviceSessionState): void {
if (deviceState.sessionStateType !== DeviceSessionStateType.Connected) {
// Handle opened app
const currentApp = deviceState.currentApp;
let flags: number | Buffer = 0;
if (typeof currentApp.flags === "number") {
flags = currentApp.flags;
} else if (currentApp.flags !== undefined) {
flags = Buffer.from(currentApp.flags);
}
this.eventSubject.next({
type: "opened",
app: {
name: currentApp.name,
version: currentApp.version,
flags,
},
derivation: output.derivation ? { address: output.derivation } : undefined,
});
}
this.eventSubject.complete();
}
private handleErrorEvent(error: ConnectAppDAError, deviceState: DeviceSessionState): void {
if (error instanceof OutOfMemoryDAError && this.installPlan !== null) {
const appNames = this.installPlan.installPlan.map(app => app.versionName);
this.eventSubject.next({
type: "app-not-installed",
appNames,
appName: appNames[0]!,
});
this.eventSubject.complete();
} else if (
"_tag" in error &&
error._tag === "UnsupportedFirmwareDAError" &&
deviceState.sessionStateType !== DeviceSessionStateType.Connected
) {
this.eventSubject.error(
new LatestFirmwareVersionRequired("LatestFirmwareVersionRequired", {
current: deviceState.firmwareUpdateContext!.currentFirmware.version,
latest:
deviceState.firmwareUpdateContext?.availableUpdate?.finalFirmware.version ||
deviceState.firmwareUpdateContext!.currentFirmware.version,
}),
);
} else if (
"_tag" in error &&
error._tag === "UnsupportedApplicationDAError" &&
deviceState.sessionStateType !== DeviceSessionStateType.Connected
) {
if (deviceState.deviceModelId === DeviceModelId.NANO_S) {
// This will show an error modal with upsell link
this.eventSubject.error(
new NoSuchAppOnProvider(`Ledger Nano S does not support this feature`, {
appName: this.appName,
}),
);
return;
}
// This will show an error modal with contact support link
this.eventSubject.error(
new UnsupportedFeatureError(`App ${this.appName} not supported on this device`, {
appName: this.appName,
deviceModelId: deviceState.deviceModelId,
deviceVersion: deviceState.firmwareVersion?.os,
}),
);
} else if ("_tag" in error && error._tag === "DeviceLockedError") {
this.eventSubject.next({ type: "lockedDevice" });
this.eventSubject.complete();
} else if ("_tag" in error && error._tag === "RefusedByUserDAError") {
this.eventSubject.error(new UserRefusedAllowManager());
} else if ("_tag" in error && error._tag === "DeviceDisconnectedWhileSendingError") {
this.eventSubject.next({ type: "disconnected", expected: false });
} else if ("_tag" in error && error._tag === "WebHidSendReportError") {
this.eventSubject.next({ type: "disconnected", expected: false });
this.eventSubject.complete();
} else if ("errorCode" in error && typeof error.errorCode === "string" && "_tag" in error) {
this.eventSubject.error(this.mapDeviceError(error.errorCode, error._tag));
} else {
this.eventSubject.error(error);
}
}
private handleError(error: Error): Observable<never> {
this.eventSubject.error(error);
return of();
}
private mapDeviceError(errorCode: string, defaultMessage: string): Error {
switch (errorCode) {
case "5501":
return new UserRefusedOnDevice();
default:
return new Error(defaultMessage);
}
}
private mapSkippedApps(appNames: string[], reason: SkipReason): SkippedAppOp[] {
return appNames.map(name => ({
reason,
appOp: {
type: "install",
name,
},
}));
}
private mapDeviceInfo(osVersion: GetOsVersionResponse): DeviceInfo {
return parseDeviceInfo({
isBootloader: osVersion.isBootloader,
rawVersion: osVersion.seVersion, // Always the SE version since at this step we cannot be in bootloader mode
targetId: osVersion.targetId,
seVersion: osVersion.seVersion,
seTargetId: osVersion.seTargetId,
mcuBlVersion: undefined, // We cannot be in bootloader mode at this step
mcuVersion: osVersion.mcuSephVersion,
mcuTargetId: osVersion.mcuTargetId,
flags: Buffer.from(osVersion.seFlags),
bootloaderVersion: osVersion.mcuBootloaderVersion,
hardwareVersion: parseInt(osVersion.hwVersion, 16),
languageId: osVersion.langId,
});
}
private mapLatestFirmware(updateContext: DmkFirmwareUpdateContext): FirmwareUpdateContext | null {
if (!updateContext.availableUpdate) {
return null;
}
const availableUpdate = updateContext.availableUpdate;
const osu = availableUpdate.osuFirmware;
const final = availableUpdate.finalFirmware;
return {
osu: {
id: osu.id,
perso: osu.perso,
firmware: osu.firmware,
firmware_key: osu.firmwareKey,
hash: osu.hash || "",
next_se_firmware_final_version: osu.nextFinalFirmware,
// Following fields are inherited from dto, but unused
description: undefined,
display_name: undefined,
notes: undefined,
name: "",
date_creation: "",
date_last_modified: "",
device_versions: [],
providers: [],
previous_se_firmware_final_version: [],
},
final: {
id: final.id,
name: final.version,
version: final.version,
perso: final.perso,
firmware: final.firmware || "",
firmware_key: final.firmwareKey || "",
hash: final.hash || "",
bytes: final.bytes || 0,
mcu_versions: final.mcuVersions,
// Following fields are inherited from dto, but unused
description: undefined,
display_name: undefined,
notes: undefined,
se_firmware: 0,
date_creation: "",
date_last_modified: "",
device_versions: [],
application_versions: [],
osu_versions: [],
providers: [],
},
shouldFlashMCU: availableUpdate.mcuUpdateRequired,
};
}
}