@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
299 lines • 12.2 kB
JavaScript
;
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