UNPKG

@ledgerhq/live-common

Version:
214 lines • 8.18 kB
import { firstValueFrom, from, Observable, throwError, timer } from "rxjs"; import { retryWhen, mergeMap, catchError } from "rxjs/operators"; import { WrongDeviceForAccount, WrongAppForCurrency, CantOpenDevice, UpdateYourApp, BluetoothRequired, TransportWebUSBGestureRequired, TransportInterfaceNotAvailable, FirmwareOrAppUpdateRequired, TransportStatusError, DeviceHalted, PeerRemovedPairing, PairingFailed, } from "@ledgerhq/errors"; import { LocalTracer, trace } from "@ledgerhq/logs"; import { getEnv } from "@ledgerhq/live-env"; import { open, close } from "."; const LOG_TYPE = "hw"; const initialErrorRemapping = (error, context) => { 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) => { errorRemapping = f; }; const never = new Promise(() => { }); /** * 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 ?) */ function transportFinally(cleanup) { return (observable) => { return new Observable(o => { let done = false; const finalize = () => { if (done) return never; 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 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 withDevice export const cancelDeviceAction = (transport) => { const transportId = identifyTransport(transport); trace({ type: LOG_TYPE, message: "Cancelling device action", data: { transportId }, }); needsCleanup[transportId] = true; }; /** * Provides a Transport instance to a given job * * @param deviceId * @param options contains optional configuration * - openTimeoutMs: optional timeout that limits in time the open attempt of the matching registered transport. * - matchDeviceByName: optional name of the device to match. */ export const withDevice = (deviceId, options) => (job) => new Observable(o => { const tracer = new LocalTracer(LOG_TYPE, { deviceId, origin: "hw:withDevice" }); tracer.trace(`New job for device: ${deviceId || "USB"}`); // To call to cleanup the current transport const finalize = async (transport) => { tracer.trace("Closing and cleaning transport", { function: "finalize" }); try { await close(transport, deviceId); } catch (error) { tracer.trace(`An error occurred when closing transport (ignoring it): ${error}`, { error, function: "finalize", }); } }; let unsubscribed; let sub; // Open the transport open(deviceId, options, tracer.getContext()) .then(async (transport) => { tracer.trace("Got a Transport instance from open"); if (unsubscribed) { tracer.trace("Unsubscribed while opening transport"); // It was unsubscribed prematurely return finalize(transport); } 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 .catch(e => { tracer.trace(`Error while opening Transport: ${e}`, { error: e }); if (e instanceof BluetoothRequired) throw e; if (e instanceof TransportWebUSBGestureRequired) throw e; if (e instanceof TransportInterfaceNotAvailable) throw e; if (e instanceof PeerRemovedPairing) throw e; if (e instanceof PairingFailed) throw e; console.error(e); throw new CantOpenDevice(e.message); }) // Executes the job .then(transport => { tracer.trace("Executing job", { hasTransport: !!transport, unsubscribed }); if (!transport) return; // It was unsubscribed prematurely if (unsubscribed) { tracer.trace("Unsubscribed before executing job"); return finalize(transport); } sub = job(transport) .pipe(catchError(error => initialErrorRemapping(error, tracer.getContext())), catchError(errorRemapping), transportFinally(() => { // Closes the transport and cleans up everything return finalize(transport); })) .subscribe({ next: event => { // This kind of log should be a "debug" level for ex // tracer.trace("Job next", { event }); o.next(event); }, error: error => { tracer.trace("Job error", { error }); if (error.statusCode) { o.error(new TransportStatusError(error.statusCode)); } else { o.error(error); } }, complete: () => { o.complete(); }, }); }) .catch(error => { tracer.trace(`Caught error on job execution step: ${error}`, { error }); o.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(); }; }); /** * Provides a Transport instance to the given function fn * @see withDevice */ export const withDevicePromise = (deviceId, fn) => firstValueFrom(withDevice(deviceId)(transport => from(fn(transport)))); export const genericCanRetryOnError = (err) => { if (err instanceof WrongAppForCurrency) return false; if (err instanceof WrongDeviceForAccount) return false; if (err instanceof CantOpenDevice) return false; if (err instanceof BluetoothRequired) return false; if (err instanceof UpdateYourApp) return false; if (err instanceof FirmwareOrAppUpdateRequired) return false; if (err instanceof DeviceHalted) return false; if (err instanceof TransportWebUSBGestureRequired) return false; if (err instanceof TransportInterfaceNotAvailable) return false; return true; }; export const retryWhileErrors = (acceptError) => (attempts) => attempts.pipe(mergeMap(error => { if (!acceptError(error)) { return throwError(() => error); } return timer(getEnv("WITH_DEVICE_POLLING_DELAY")); })); export const withDevicePolling = (deviceId) => (job, acceptError = genericCanRetryOnError) => withDevice(deviceId)(job).pipe(retryWhen(retryWhileErrors(acceptError))); //# sourceMappingURL=deviceAccess.js.map