@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
282 lines (243 loc) • 9.97 kB
text/typescript
import { Observable, throwError } from "rxjs";
import { catchError } from "rxjs/operators";
import { LocalTracer, TraceContext, trace } from "@ledgerhq/logs";
import Transport from "@ledgerhq/hw-transport";
import {
BluetoothRequired,
CantOpenDevice,
DeviceHalted,
FirmwareOrAppUpdateRequired,
PairingFailed,
PeerRemovedPairing,
TransportInterfaceNotAvailable,
TransportStatusError,
TransportWebUSBGestureRequired,
} from "@ledgerhq/errors";
import { open, close } from "../../hw/index";
import { DeviceQueuedJobsManager } from "../../hw/deviceAccess";
export { Transport };
const LOG_TYPE = "device-sdk-transport";
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 withTransport
export const cancelDeviceAction = (transport: Transport): void => {
const transportId = identifyTransport(transport);
trace({
type: LOG_TYPE,
message: "Cancelling device action",
data: { transportId },
});
needsCleanup[identifyTransport(transport)] = true;
};
export type TransportRef = {
// the instance of the current opened transport
current: Transport;
// A function that closes and re-opens a transport
refreshTransport: () => Promise<void>;
// (for debug purposes) A counter that is incremented each time the transport is refreshed
_refreshedCounter: number;
};
export type JobArgs = {
transportRef: TransportRef;
};
/**
* Wrapper providing a transport that can be refreshed to a job that can be executed
*
* This is useful for any job that may need to refresh the transport because of a device disconnected-like error
*
* The transportRef.current will be updated with the new transport when transportRef.refreshTransport is called
*
* @param deviceId the id of the device to open the transport channel with
* @param options contains optional configuration
* - openTimeoutMs: optional timeout that limits in time the open attempt of the matching registered transport.
* @returns a function that takes a job and returns an observable on the result of the job
* job: the job to execute with the transport. It takes:
* - transportRef: a reference to the transport that can be updated by calling a transportRef.refreshTransport()
*/
export const withTransport = (deviceId: string, options?: { openTimeoutMs?: number }) => {
return <T>(job: ({ transportRef }: JobArgs) => Observable<T>): Observable<T> =>
new Observable(subscriber => {
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 LocalTracer(LOG_TYPE, {
jobId,
deviceId,
origin: "deviceSdk:withTransport",
});
tracer.trace(`New job for device: ${deviceId || "USB"}`);
// To call to cleanup the current transport
const finalize = (transport, cleanups) => {
tracer.trace("Closing and cleaning transport");
return close(transport, deviceId)
.catch(() => {})
.then(() => {
cleanups.forEach(c => c());
});
};
let unsubscribed;
let sub;
// Setups a given transport
const setupTransport = async (transport: Transport) => {
tracer.trace("Setting up the transport instance");
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
const onErrorDuringTransportSetup = (error: unknown) => {
tracer.trace("Error while setting up a transport: ", { error });
resolveQueuedJob();
if (error instanceof BluetoothRequired) throw error;
if (error instanceof TransportWebUSBGestureRequired) throw error;
if (error instanceof TransportInterfaceNotAvailable) throw error;
if (error instanceof PeerRemovedPairing) throw error;
if (error instanceof PairingFailed) throw error;
if (error instanceof Error) throw new CantOpenDevice(error.message);
else throw new CantOpenDevice("Unknown error");
};
// Returns an object containing: the reference to a transport and a function to refresh it
const buildRefreshableTransport = (transport: Transport): TransportRef => {
tracer.trace("Creating a refreshable transport from a given instance");
const transportRef: TransportRef = {
current: transport,
_refreshedCounter: 0,
refreshTransport: Promise.resolve,
};
tracer.updateContext({ transportRefreshedCounter: transportRef._refreshedCounter });
transportRef.refreshTransport = async () => {
tracer.trace("Refreshing current transport");
return (
close(transportRef.current, deviceId)
// Silently ignore errors on transport close
.catch(() => {})
.then(async () => open(deviceId, options?.openTimeoutMs, tracer.getContext()))
.then(async newTransport => {
await setupTransport(transportRef.current);
transportRef.current = newTransport;
transportRef._refreshedCounter++;
tracer.updateContext({ transportRefreshedCounter: transportRef._refreshedCounter });
tracer.trace("Transport was refreshed");
})
.catch(onErrorDuringTransportSetup)
);
};
return transportRef;
};
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 open(deviceId, options?.openTimeoutMs, tracer.getContext());
})
.then(transport => {
return buildRefreshableTransport(transport);
})
.then(async (transportRef: TransportRef) => {
await setupTransport(transportRef.current);
return transportRef;
})
.catch(onErrorDuringTransportSetup)
// Executes the job
.then(transportRef => {
tracer.trace("Executing job", { hasTransport: !!transportRef, unsubscribed });
if (!transportRef.current) return;
if (unsubscribed) {
tracer.trace("Unsubscribed (2) while processing job");
// It was unsubscribed prematurely
return finalize(transportRef.current, [resolveQueuedJob]);
}
sub = job({ transportRef })
.pipe(
catchError(error => initialErrorRemapping(error, tracer.getContext())),
catchError(errorRemapping), // close the transport and clean up everything
transportFinally(() => {
return finalize(transportRef.current, [resolveQueuedJob]);
}),
)
.subscribe(subscriber);
})
.catch(error => {
subscriber.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();
};
});
};
/**
* 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 ?)
*/
const transportFinally =
(cleanup: () => Promise<void>) =>
<T>(observable: Observable<T>): Observable<T> =>
new Observable(o => {
let done = false;
const finalize = () => {
if (done) return Promise.resolve();
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 initialErrorRemapping = (error: unknown, context?: TraceContext) => {
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: (arg0: Error) => Observable<never>): void => {
errorRemapping = f;
};