UNPKG

@ledgerhq/live-common

Version:
282 lines (243 loc) • 9.97 kB
import { Observable, throwError } from "rxjs"; import { catchError } from "rxjs/operators"; import { LocalTracer, TraceContext, trace } from "@ledgerhq/logs"; import Transport from "@ledgerhq/hw-transport"; import { BluetoothRequired, CantOpenDevice, DeviceHalted, FirmwareOrAppUpdateRequired, PairingFailed, PeerRemovedPairing, TransportInterfaceNotAvailable, TransportStatusError, TransportWebUSBGestureRequired, } from "@ledgerhq/errors"; import { open, close } from "../../hw/index"; import { DeviceQueuedJobsManager } from "../../hw/deviceAccess"; export { Transport }; const LOG_TYPE = "device-sdk-transport"; const identifyTransport = t => (typeof t.id === "string" ? t.id : ""); const needsCleanup = {}; // When a series of APDUs are interrupted, this is called // so we don't forget to cleanup on the next withTransport export const cancelDeviceAction = (transport: Transport): void => { const transportId = identifyTransport(transport); trace({ type: LOG_TYPE, message: "Cancelling device action", data: { transportId }, }); needsCleanup[identifyTransport(transport)] = true; }; export type TransportRef = { // the instance of the current opened transport current: Transport; // A function that closes and re-opens a transport refreshTransport: () => Promise<void>; // (for debug purposes) A counter that is incremented each time the transport is refreshed _refreshedCounter: number; }; export type JobArgs = { transportRef: TransportRef; }; /** * Wrapper providing a transport that can be refreshed to a job that can be executed * * This is useful for any job that may need to refresh the transport because of a device disconnected-like error * * The transportRef.current will be updated with the new transport when transportRef.refreshTransport is called * * @param deviceId the id of the device to open the transport channel with * @param options contains optional configuration * - openTimeoutMs: optional timeout that limits in time the open attempt of the matching registered transport. * @returns a function that takes a job and returns an observable on the result of the job * job: the job to execute with the transport. It takes: * - transportRef: a reference to the transport that can be updated by calling a transportRef.refreshTransport() */ export const withTransport = (deviceId: string, options?: { openTimeoutMs?: number }) => { return <T>(job: ({ transportRef }: JobArgs) => Observable<T>): Observable<T> => new Observable(subscriber => { const queuedJobManager = DeviceQueuedJobsManager.getInstance(); const previousQueuedJob = queuedJobManager.getLastQueuedJob(deviceId); // When the new job is finished, this will unlock the associated device queue of jobs let resolveQueuedJob; const jobId = queuedJobManager.setLastQueuedJob( deviceId, new Promise(resolve => { resolveQueuedJob = resolve; }), ); const tracer = new LocalTracer(LOG_TYPE, { jobId, deviceId, origin: "deviceSdk:withTransport", }); tracer.trace(`New job for device: ${deviceId || "USB"}`); // To call to cleanup the current transport const finalize = (transport, cleanups) => { tracer.trace("Closing and cleaning transport"); return close(transport, deviceId) .catch(() => {}) .then(() => { cleanups.forEach(c => c()); }); }; let unsubscribed; let sub; // Setups a given transport const setupTransport = async (transport: Transport) => { tracer.trace("Setting up the transport instance"); if (unsubscribed) { tracer.trace("Unsubscribed (1) while processing job"); // It was unsubscribed prematurely return finalize(transport, [resolveQueuedJob]); } if (needsCleanup[identifyTransport(transport)]) { delete needsCleanup[identifyTransport(transport)]; await transport.send(0, 0, 0, 0).catch(() => {}); } return transport; }; // This catch is here only for errors that might happen at open or at clean up of the transport before doing the job const onErrorDuringTransportSetup = (error: unknown) => { tracer.trace("Error while setting up a transport: ", { error }); resolveQueuedJob(); if (error instanceof BluetoothRequired) throw error; if (error instanceof TransportWebUSBGestureRequired) throw error; if (error instanceof TransportInterfaceNotAvailable) throw error; if (error instanceof PeerRemovedPairing) throw error; if (error instanceof PairingFailed) throw error; if (error instanceof Error) throw new CantOpenDevice(error.message); else throw new CantOpenDevice("Unknown error"); }; // Returns an object containing: the reference to a transport and a function to refresh it const buildRefreshableTransport = (transport: Transport): TransportRef => { tracer.trace("Creating a refreshable transport from a given instance"); const transportRef: TransportRef = { current: transport, _refreshedCounter: 0, refreshTransport: Promise.resolve, }; tracer.updateContext({ transportRefreshedCounter: transportRef._refreshedCounter }); transportRef.refreshTransport = async () => { tracer.trace("Refreshing current transport"); return ( close(transportRef.current, deviceId) // Silently ignore errors on transport close .catch(() => {}) .then(async () => open(deviceId, options?.openTimeoutMs, tracer.getContext())) .then(async newTransport => { await setupTransport(transportRef.current); transportRef.current = newTransport; transportRef._refreshedCounter++; tracer.updateContext({ transportRefreshedCounter: transportRef._refreshedCounter }); tracer.trace("Transport was refreshed"); }) .catch(onErrorDuringTransportSetup) ); }; return transportRef; }; tracer.trace("Waiting for the previous job in the queue to complete", { previousJobId: previousQueuedJob.id, }); // For any new job, we'll now wait the exec queue to be available previousQueuedJob.job .then(() => { tracer.trace("Previous queued job resolved, now trying to get a Transport instance", { previousJobId: previousQueuedJob.id, currentJobId: jobId, }); return open(deviceId, options?.openTimeoutMs, tracer.getContext()); }) .then(transport => { return buildRefreshableTransport(transport); }) .then(async (transportRef: TransportRef) => { await setupTransport(transportRef.current); return transportRef; }) .catch(onErrorDuringTransportSetup) // Executes the job .then(transportRef => { tracer.trace("Executing job", { hasTransport: !!transportRef, unsubscribed }); if (!transportRef.current) return; if (unsubscribed) { tracer.trace("Unsubscribed (2) while processing job"); // It was unsubscribed prematurely return finalize(transportRef.current, [resolveQueuedJob]); } sub = job({ transportRef }) .pipe( catchError(error => initialErrorRemapping(error, tracer.getContext())), catchError(errorRemapping), // close the transport and clean up everything transportFinally(() => { return finalize(transportRef.current, [resolveQueuedJob]); }), ) .subscribe(subscriber); }) .catch(error => { subscriber.error(error); }); // Returns function to unsubscribe from the job if we don't need it anymore. // This will prevent us from executing the job unnecessarily later on return () => { tracer.trace(`Unsubscribing withDevice flow. Ongoing job to unsubscribe from ? ${!!sub}`); unsubscribed = true; if (sub) sub.unsubscribe(); }; }); }; /** * Wrapper to pipe a "cleanup" function at then end of an Observable flow. * * The `finalize` is only called once if there is an error and a complete * (but normally an error event completes automatically the Observable pipes. Is it needed ?) */ const transportFinally = (cleanup: () => Promise<void>) => <T>(observable: Observable<T>): Observable<T> => new Observable(o => { let done = false; const finalize = () => { if (done) return Promise.resolve(); done = true; return cleanup(); }; const sub = observable.subscribe({ next: e => o.next(e), complete: () => { finalize().then(() => o.complete()); }, error: e => { finalize().then(() => o.error(e)); }, }); return () => { sub.unsubscribe(); finalize(); }; }); const initialErrorRemapping = (error: unknown, context?: TraceContext) => { let mappedError = error; if (error && error instanceof TransportStatusError) { if (error.statusCode === 0x6faa) { mappedError = new DeviceHalted(error.message); } else if (error.statusCode === 0x6b00) { mappedError = new FirmwareOrAppUpdateRequired(error.message); } } trace({ type: LOG_TYPE, message: `Initial error remapping: ${error}`, data: { error, mappedError }, context, }); return throwError(() => mappedError); }; let errorRemapping = e => throwError(() => e); export const setErrorRemapping = (f: (arg0: Error) => Observable<never>): void => { errorRemapping = f; };