@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
386 lines • 14.6 kB
JavaScript
import invariant from "invariant";
import { interval, of } from "rxjs";
import { scan, debounce, tap, takeWhile } from "rxjs/operators";
import { useEffect, useCallback, useState, useMemo, useRef } from "react";
import { log } from "@ledgerhq/logs";
import { getDerivationScheme, getDerivationModesForCurrency, runDerivationScheme, } from "@ledgerhq/ledger-wallet-framework/derivation";
import { useReplaySubject } from "../../observable";
import { shouldUpgrade } from "../../apps";
import perFamilyAccount from "../../generated/account";
import { getImplementation } from "./implementations";
import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName";
const mapResult = ({ opened, device, appAndVersion, displayUpgradeWarning, }) => opened && device && !displayUpgradeWarning
? {
device,
appAndVersion,
}
: null;
const getInitialState = (device, request) => ({
isLoading: !!device,
requestQuitApp: false,
requestOpenApp: null,
unresponsive: false,
isLocked: false,
requiresAppInstallation: null,
allowOpeningRequestedWording: null,
allowOpeningGranted: false,
allowManagerRequested: false,
allowManagerGranted: false,
device: null,
deviceInfo: null,
deviceId: null,
latestFirmware: null,
opened: false,
appAndVersion: null,
error: null,
derivation: null,
displayUpgradeWarning: false,
installingApp: false,
listingApps: false,
request,
currentAppOp: undefined,
installQueue: [],
listedApps: false, // Nb maybe expose the result
skippedAppOps: [],
itemProgress: 0,
progress: undefined,
});
const reducer = (state, e) => {
switch (e.type) {
case "deprecation":
return { ...state, deviceDeprecationRules: e.deprecate };
case "unresponsiveDevice":
return { ...state, unresponsive: true };
case "lockedDevice":
return { ...state, isLocked: true };
// This event does not set isLocked and unresponsive properties, as
// by itself it does not request anything from the device
case "device-update-last-seen":
return {
...state,
deviceInfo: e.deviceInfo,
latestFirmware: e.latestFirmware,
};
case "disconnected":
// disconnected event can happen for example:
// - when the wired device is unplugged
// - before a ask-open-app event when an other app is already open
return {
...getInitialState(null, state.request),
isLoading: !!e.expected,
};
case "deviceChange":
return { ...getInitialState(e.device, state.request), device: e.device };
case "some-apps-skipped":
return {
...state,
skippedAppOps: e.skippedAppOps,
installQueue: state.installQueue,
};
case "inline-install":
return {
...getInitialState(state.device, state.request),
isLoading: false,
allowOpeningGranted: true,
allowManagerRequested: false,
allowManagerGranted: true,
device: state.device,
installingApp: true,
progress: e.progress || 0,
deviceInfo: undefined,
latestFirmware: undefined,
request: state.request,
skippedAppOps: state.skippedAppOps,
currentAppOp: e.currentAppOp,
listedApps: state.listedApps,
itemProgress: e.itemProgress || 0,
installQueue: e.installQueue || [],
};
case "listing-apps":
return {
...state,
listedApps: false,
listingApps: true,
unresponsive: false,
isLocked: false,
};
case "error":
return {
...getInitialState(state.device, state.request),
device: state.device || null,
error: e.error,
isLoading: false,
listingApps: false,
request: state.request,
skippedAppOps: state.skippedAppOps,
};
case "ask-open-app":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
requestOpenApp: e.appName,
deviceInfo: undefined,
latestFirmware: undefined,
installingApp: undefined,
listingApps: undefined,
installQueue: undefined,
listedApps: undefined,
itemProgress: undefined,
skippedAppOps: state.skippedAppOps,
};
case "ask-quit-app":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
requestQuitApp: true,
installingApp: undefined,
listingApps: undefined,
installQueue: undefined,
listedApps: undefined,
itemProgress: undefined,
skippedAppOps: state.skippedAppOps,
};
case "device-permission-requested":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
allowManagerRequested: true,
deviceInfo: undefined,
latestFirmware: undefined,
installingApp: undefined,
listingApps: undefined,
listedApps: undefined,
itemProgress: undefined,
skippedAppOps: state.skippedAppOps,
installQueue: state.installQueue,
};
case "device-permission-granted":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
allowOpeningGranted: true,
allowManagerGranted: true,
deviceInfo: undefined,
latestFirmware: undefined,
installingApp: undefined,
listingApps: undefined,
itemProgress: undefined,
skippedAppOps: state.skippedAppOps,
installQueue: state.installQueue,
listedApps: state.listedApps,
};
case "device-id":
return {
...state,
deviceId: e.deviceId,
};
case "app-not-installed":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
requiresAppInstallation: {
appNames: e.appNames,
appName: e.appName,
},
deviceInfo: undefined,
latestFirmware: undefined,
installingApp: undefined,
listingApps: undefined,
installQueue: undefined,
listedApps: undefined,
itemProgress: undefined,
skippedAppOps: state.skippedAppOps,
};
case "listed-apps":
return {
...state,
listedApps: true,
installQueue: e.installQueue,
};
case "opened":
return {
...getInitialState(state.device, state.request),
isLoading: false,
device: state.device,
opened: true,
appAndVersion: e.app,
derivation: e.derivation,
deviceInfo: undefined,
latestFirmware: undefined,
installingApp: undefined,
listingApps: undefined,
installQueue: undefined,
itemProgress: undefined,
request: state.request,
skippedAppOps: state.skippedAppOps,
listedApps: state.listedApps,
displayUpgradeWarning: state.device && e.app ? shouldUpgrade(e.app.name, e.app.version) : false,
};
}
return state;
};
/**
* Map between an AppRequest and a ConnectAppRequest, allowing us to
* specify an account or a currency without resolving manually the actual
* applications we depend on in order to access the flow.
*/
function inferCommandParams(appRequest) {
let derivationMode;
let derivationPath;
const { account, requireLatestFirmware, allowPartialDependencies = false, dependencies: appDependencies, } = appRequest;
let { appName, currency } = appRequest;
if (!currency && account) {
currency = account.currency;
}
if (!appName && currency) {
appName = currency.managerAppName;
}
invariant(appName, "appName or currency or account is missing");
let dependencies = undefined;
if (appDependencies) {
dependencies = appDependencies.map(d => inferCommandParams(d).appName);
}
if (!currency) {
return {
appName,
dependencies,
requireLatestFirmware,
allowPartialDependencies,
};
}
let extra;
if (account) {
derivationMode = account.derivationMode;
derivationPath = account.freshAddressPath;
const m = perFamilyAccount[account.currency.family];
if (m && m.injectGetAddressParams) {
extra = m.injectGetAddressParams(account);
}
}
else {
const modes = getDerivationModesForCurrency(currency);
derivationMode = modes[modes.length - 1];
derivationPath = runDerivationScheme(getDerivationScheme({
currency,
derivationMode,
}), currency);
}
return {
appName,
dependencies,
requireLatestFirmware,
requiresDerivation: {
derivationMode,
path: derivationPath,
currencyId: currency.id,
...extra,
},
allowPartialDependencies,
};
}
export let currentMode = "event";
export function setDeviceMode(mode) {
currentMode = mode;
}
export const createAction = (connectAppExec) => {
const useHook = (device, appRequest) => {
const dependenciesResolvedRef = useRef(false);
const firmwareResolvedRef = useRef(false);
const outdatedAppRef = useRef(undefined);
const request = useMemo(() => inferCommandParams(appRequest), // for now i don't have better
// eslint-disable-next-line react-hooks/exhaustive-deps
[
appRequest.appName,
appRequest.account?.id,
appRequest.currency?.id,
appRequest.dependencies,
]);
const task = useCallback(({ deviceId, deviceName, request }) => {
//To avoid redundant checks, we remove passed checks from the request.
const { dependencies, requireLatestFirmware } = request;
return connectAppExec({
deviceId,
deviceName,
request: {
...request,
dependencies: dependenciesResolvedRef.current ? undefined : dependencies,
requireLatestFirmware: firmwareResolvedRef.current ? undefined : requireLatestFirmware,
outdatedApp: outdatedAppRef.current,
},
}).pipe(tap(e => {
// These events signal the resolution of pending checks.
if (e.type === "dependencies-resolved") {
dependenciesResolvedRef.current = true;
}
else if (e.type === "latest-firmware-resolved") {
firmwareResolvedRef.current = true;
}
else if (e.type === "has-outdated-app") {
outdatedAppRef.current = e.outdatedApp;
}
}));
}, []);
// repair modal will interrupt everything and be rendered instead of the background content
const [state, setState] = useState(() => getInitialState(device));
const [resetIndex, setResetIndex] = useState(0);
const deviceSubject = useReplaySubject(device);
useEffect(() => {
if (state.opened)
return;
const impl = getImplementation(currentMode)({
deviceSubject,
task,
request,
});
const sub = impl
.pipe(tap((e) => log("actions-app-event", e.type, e)), debounce((e) => ("replaceable" in e && e.replaceable ? interval(100) : of(null))), scan(reducer, getInitialState()), takeWhile((s) => !s.requiresAppInstallation && !s.error, true))
.subscribe(setState);
return () => {
sub.unsubscribe();
};
}, [deviceSubject, state.opened, resetIndex, task, request]);
const onRetry = useCallback(() => {
// After an error we can't guarantee resolutions.
dependenciesResolvedRef.current = false;
firmwareResolvedRef.current = false;
// The nonce change triggers a refresh.
setResetIndex(i => i + 1);
setState(getInitialState(device));
}, [device]);
const passWarning = useCallback(() => {
setState(currState => ({
...currState,
displayUpgradeWarning: false,
}));
}, []);
return {
...state,
inWrongDeviceForAccount: state.derivation && appRequest.account
? state.derivation.address !== appRequest.account.freshAddress &&
state.derivation.address !== appRequest.account.seedIdentifier // Use-case added for Hedera
? {
accountName: getDefaultAccountName(appRequest.account),
}
: null
: null,
onRetry,
passWarning,
};
};
return {
useHook,
mapResult,
};
};
export function dependenciesToAppRequests(dependencies) {
if (!dependencies) {
return [];
}
return dependencies.map(appName => ({ appName }));
}
//# sourceMappingURL=app.js.map