UNPKG

@ledgerhq/live-common

Version:
299 lines • 12.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.withDevicePolling = exports.retryWhileErrors = exports.genericCanRetryOnError = exports.withDevicePromise = exports.withDevice = exports.DeviceQueuedJobsManager = exports.cancelDeviceAction = exports.setErrorRemapping = void 0; const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const errors_1 = require("@ledgerhq/errors"); const logs_1 = require("@ledgerhq/logs"); const live_env_1 = require("@ledgerhq/live-env"); const _1 = require("."); const LOG_TYPE = "hw"; const initialErrorRemapping = (error, context) => { let mappedError = error; if (error && error instanceof errors_1.TransportStatusError) { if (error.statusCode === 0x6faa) { mappedError = new errors_1.DeviceHalted(error.message); } else if (error.statusCode === 0x6b00) { mappedError = new errors_1.FirmwareOrAppUpdateRequired(error.message); } } (0, logs_1.trace)({ type: LOG_TYPE, message: `Initial error remapping: ${error}`, data: { error, mappedError }, context, }); return (0, rxjs_1.throwError)(() => mappedError); }; let errorRemapping = e => (0, rxjs_1.throwError)(() => e); const setErrorRemapping = (f) => { errorRemapping = f; }; exports.setErrorRemapping = setErrorRemapping; 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 rxjs_1.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 const cancelDeviceAction = (transport) => { const transportId = identifyTransport(transport); (0, logs_1.trace)({ type: LOG_TYPE, message: "Cancelling device action", data: { transportId }, }); needsCleanup[transportId] = true; }; exports.cancelDeviceAction = cancelDeviceAction; /** * Manages queued jobs for each device (id) * * Careful: a USB-connected device has no unique id, and its `deviceId` will be an empty string. * * The queue object `queuedJobsByDevice` only stores, for each device, the latest void promise that will resolve * when the device is ready to be opened again. * They are scheduled to resolve whenever the job associated to the device is finished. * When calling withDevice several times, the new promise will be chained to the "then" of the previous promise: * open(device) -> execute job -> clean connection -> resolve promise -> next promise can start: open(device) -> etc. * So a queue is indeed created for each device, by creating a chain of promises, but only the end of the queue is stored for each device. */ class DeviceQueuedJobsManager { // For each device, queue of linked Promises that wait after each other, blocking any future job for a given device queuedJobsByDevice; static instance = null; // To be able to differentiate withDevice calls in our logs static jobIdCounter = -1; constructor() { this.queuedJobsByDevice = {}; } /** * Get the singleton instance */ static getInstance() { if (!this.instance) { this.instance = new DeviceQueuedJobsManager(); } return this.instance; } /** * Returns the latest queued job for a given device id * * @param deviceId * @returns the latest QueuedJob. If none, return a queued job that can be resolved directly. */ getLastQueuedJob(deviceId) { return this.queuedJobsByDevice[deviceId] || { job: Promise.resolve(), id: -1 }; } /** * Sets the latest queue job for a given device id * * Also increments the job id counter and set the newly queued job id to this new incremented value. * * Note: we should be fine on race conditions when updating the jobId in our use cases. * * @param deviceId * @param jobToQueue a Promise that resolve to void, representing an async job * @returns the id of the queued job */ setLastQueuedJob(deviceId, jobToQueue) { const id = ++DeviceQueuedJobsManager.jobIdCounter; this.queuedJobsByDevice[deviceId] = { job: jobToQueue, id }; return id; } } exports.DeviceQueuedJobsManager = DeviceQueuedJobsManager; /** * 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. */ const withDevice = (deviceId, options) => (job) => new rxjs_1.Observable(o => { 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 logs_1.LocalTracer(LOG_TYPE, { jobId, deviceId, origin: "hw:withDevice" }); tracer.trace(`New job for device: ${deviceId || "USB"}`); // To call to cleanup the current transport const finalize = async (transport, cleanups) => { tracer.trace("Closing and cleaning transport", { function: "finalize" }); try { await (0, _1.close)(transport, deviceId); } catch (error) { tracer.trace(`An error occurred when closing transport (ignoring it): ${error}`, { error, function: "finalize", }); } cleanups.forEach(c => c()); }; let unsubscribed; let sub; 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 (0, _1.open)(deviceId, options?.openTimeoutMs, tracer.getContext()); }) // open the transport .then(async (transport) => { tracer.trace("Got a Transport instance from open"); 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 .catch(e => { tracer.trace(`Error while opening Transport: ${e}`, { error: e }); resolveQueuedJob(); if (e instanceof errors_1.BluetoothRequired) throw e; if (e instanceof errors_1.TransportWebUSBGestureRequired) throw e; if (e instanceof errors_1.TransportInterfaceNotAvailable) throw e; if (e instanceof errors_1.PeerRemovedPairing) throw e; if (e instanceof errors_1.PairingFailed) throw e; console.error(e); throw new errors_1.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 (2) while processing job"); return finalize(transport, [resolveQueuedJob]); } sub = job(transport) .pipe((0, operators_1.catchError)(error => initialErrorRemapping(error, tracer.getContext())), (0, operators_1.catchError)(errorRemapping), transportFinally(() => { // Closes the transport and cleans up everything return finalize(transport, [resolveQueuedJob]); })) .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 errors_1.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(); }; }); exports.withDevice = withDevice; /** * Provides a Transport instance to the given function fn * @see withDevice */ const withDevicePromise = (deviceId, fn) => (0, rxjs_1.firstValueFrom)((0, exports.withDevice)(deviceId)(transport => (0, rxjs_1.from)(fn(transport)))); exports.withDevicePromise = withDevicePromise; const genericCanRetryOnError = (err) => { if (err instanceof errors_1.WrongAppForCurrency) return false; if (err instanceof errors_1.WrongDeviceForAccount) return false; if (err instanceof errors_1.CantOpenDevice) return false; if (err instanceof errors_1.BluetoothRequired) return false; if (err instanceof errors_1.UpdateYourApp) return false; if (err instanceof errors_1.FirmwareOrAppUpdateRequired) return false; if (err instanceof errors_1.DeviceHalted) return false; if (err instanceof errors_1.TransportWebUSBGestureRequired) return false; if (err instanceof errors_1.TransportInterfaceNotAvailable) return false; return true; }; exports.genericCanRetryOnError = genericCanRetryOnError; const retryWhileErrors = (acceptError) => (attempts) => attempts.pipe((0, operators_1.mergeMap)(error => { if (!acceptError(error)) { return (0, rxjs_1.throwError)(() => error); } return (0, rxjs_1.timer)((0, live_env_1.getEnv)("WITH_DEVICE_POLLING_DELAY")); })); exports.retryWhileErrors = retryWhileErrors; const withDevicePolling = (deviceId) => (job, acceptError = exports.genericCanRetryOnError) => (0, exports.withDevice)(deviceId)(job).pipe((0, operators_1.retryWhen)((0, exports.retryWhileErrors)(acceptError))); exports.withDevicePolling = withDevicePolling; //# sourceMappingURL=deviceAccess.js.map