UNPKG

@ledgerhq/live-common

Version:
196 lines • 8.29 kB
import { EMPTY, Observable, TimeoutError, concat, interval, of, timer, } from "rxjs"; import { DisconnectedDevice, DisconnectedDeviceDuringOperation, LockedDeviceError, } from "@ledgerhq/errors"; import { catchError, debounce, delayWhen, filter, switchMap, tap, timeout } from "rxjs/operators"; import { DeviceModelId, getDeviceModel } from "@ledgerhq/devices"; import { ConnectManagerTimeout } from "../../errors"; import { LocalTracer } from "@ledgerhq/logs"; import { LOG_TYPE } from ".."; import { getEnv } from "@ledgerhq/live-env"; export var ImplementationType; (function (ImplementationType) { ImplementationType["event"] = "enum"; ImplementationType["polling"] = "polling"; })(ImplementationType || (ImplementationType = {})); const defaultRetryableWithDelayDisconnectedErrors = [ DisconnectedDevice, DisconnectedDeviceDuringOperation, ]; export const defaultImplementationConfig = { pollingFrequency: 2000, initialWaitTime: 5000, reconnectWaitTime: 5000, connectionTimeout: getEnv("DETOX") ? 60000 : 20000, }; const remapError = (error, deviceModelID) => { if (!(error instanceof TimeoutError)) { return error; } if (deviceModelID === DeviceModelId.nanoS) { return new LockedDeviceError(); } return new ConnectManagerTimeout(undefined, { // TODO make this configurable productName: getDeviceModel(deviceModelID).productName, }); }; /** * Returns a polling implementation function that repeatedly performs a given task * with a given request, with a specified polling frequency and wait times until a * KO or OK end state is reached. This emulates the event based connection paradigm * from the USB stack in a BLE setting. * @template EmittedEvent - Type of the events emitted by the task's observable. * @template GenericRequestType - Type of the request to be passed to the task. **/ const pollingImplementation = (params) => new Observable((subscriber) => { const { deviceSubject, task, request, config = defaultImplementationConfig } = params; const { pollingFrequency, initialWaitTime, reconnectWaitTime, connectionTimeout } = config; const tracer = new LocalTracer(LOG_TYPE, { function: "pollingImplementation" }); tracer.trace("Setting up device action polling implementation", { config }); let shouldStopPolling = false; let connectSub; let loopTimeout; let firstRound = true; let currentDevice; const deviceSubjectSub = deviceSubject.subscribe(device => { currentDevice = device || currentDevice; }); // Cover case where our device job never gets a transport instance // it will signal back to the consumer that we don't have a device. let initialTimeout = setTimeout(() => { return subscriber.next({ type: "deviceChange", device: undefined, }); }, initialWaitTime); // Runs every time we get a new device. function actionLoop() { if (currentDevice) { connectSub = concat(of({ type: "deviceChange", device: currentDevice, replaceable: !firstRound, }), task({ deviceId: currentDevice.deviceId, deviceName: currentDevice.deviceName ?? null, request, })) .pipe( // Any event should clear the initialTimeout. tap((_) => { if (initialTimeout) { clearTimeout(initialTimeout); initialTimeout = null; } }), // More than `connectionTimeout` time of silence, is considered // a failed connection. Depending on the flow this may mean we'd have // to emit an `alive` kind of event. timeout(connectionTimeout), filter((event) => { if ("type" in event) { return event.type !== "unresponsiveDevice"; } return false; }), catchError((error) => { const maybeRemappedError = remapError(error, currentDevice.modelId); tracer.trace(`Error when running task in polling implementation: ${error}`, { error, maybeRemappedError, }); return of({ type: "error", error: maybeRemappedError, }); }), debounce((event) => { if (event.type === "error" && "error" in event) { const allowedRetryableErrors = params.retryableWithDelayDisconnectedErrors || defaultRetryableWithDelayDisconnectedErrors; const error = event.error; if (allowedRetryableErrors.find(e => error instanceof e)) { // Delay emission of allowed disconnects to allow reconnection. return timer(reconnectWaitTime); } // Other errors should cancel the loop. shouldStopPolling = true; } // All other events pass through. return of(null); })) .subscribe({ next: (event) => { subscriber.next(event); }, error: (e) => { subscriber.error(e); }, complete: () => { if (shouldStopPolling) return; firstRound = false; // For proper debouncing. loopTimeout = setTimeout(actionLoop, pollingFrequency); }, }); } else { // We currently don't have a device, try again. loopTimeout = setTimeout(actionLoop, pollingFrequency); } } // Delay the first loop run in order to be async. loopTimeout = setTimeout(actionLoop, 0); // Get rid of pending timeouts and subscriptions. return function cleanup() { if (deviceSubjectSub) deviceSubjectSub.unsubscribe(); if (connectSub) connectSub.unsubscribe(); if (initialTimeout) clearTimeout(initialTimeout); if (loopTimeout) clearTimeout(loopTimeout); }; }); const eventImplementation = (params) => { const { deviceSubject, task, request, config = defaultImplementationConfig } = params; const { reconnectWaitTime } = config; return new Observable((subscriber) => { const connectSub = deviceSubject .pipe(debounce(device => timer(!device ? reconnectWaitTime : 0)), switchMap(device => { const initialEvent = of({ type: "deviceChange", device, }); return concat(initialEvent, device ? task({ deviceId: device.deviceId, deviceName: device.deviceName ?? null, request, }) : EMPTY); }), catchError((error) => of({ type: "error", error, })), // NB An error is a dead-end as far as the task is concerned, and by delaying // the emission of the event we prevent instant failures from showing flashing // UI that looks like a glitch. For instance, if the device is locked and we retry // this would allow a better UX, 800ms before a failure is totally acceptable. delayWhen((e) => e.type === "error" || e.type === "lockedDevice" ? interval(800) : interval(0))) .subscribe({ next: (event) => { subscriber.next(event); }, error: (e) => { subscriber.error(e); }, complete: () => { }, }); // Get rid of subscriptions. return function cleanup() { if (connectSub) connectSub.unsubscribe(); }; }); }; const getImplementation = (currentMode) => currentMode === "polling" ? pollingImplementation : eventImplementation; export { pollingImplementation, eventImplementation, getImplementation }; //# sourceMappingURL=implementations.js.map