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