Skip to content

Commit

Permalink
feat: wallet sync synchronize via qr code from mobile to desktop
Browse files Browse the repository at this point in the history
  • Loading branch information
cgrellard-ledger committed Aug 23, 2024
1 parent 4014e7c commit 90c266f
Show file tree
Hide file tree
Showing 29 changed files with 391 additions and 120 deletions.
8 changes: 8 additions & 0 deletions .changeset/dirty-drinks-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"ledger-live-desktop": minor
"live-mobile": minor
"@ledgerhq/trustchain": minor
"@ledgerhq/web-tools": minor
---

Ledger Sync - Added the synchronization of a trustchain from mobile to desktop by scanning the QR code
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({
useTrustchainSdk: () => ({
getMembers: (mockedSdk.getMembers = jest.fn()),
removeMember: (mockedSdk.removeMember = jest.fn()),
initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()),
}),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({
useTrustchainSdk: () => ({
destroyTrustchain: (mockedSdk.destroyTrustchain = jest.fn()),
getMembers: (mockedSdk.getMembers = jest.fn()),
initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()),
}),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({
useTrustchainSdk: () => ({
getMembers: (mockedSdk.getMembers = jest.fn()),
removeMember: (mockedSdk.removeMember = jest.fn()),
initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()),
}),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
import React from "react";
import { render, screen, waitFor } from "tests/testUtils";
import { initialStateWalletSync } from "~/renderer/reducers/walletSync";
import { WalletSyncTestApp } from "./shared";
import { WalletSyncTestApp, mockedSdk } from "./shared";

jest.mock("../hooks/useTrustchainSdk", () => ({
useTrustchainSdk: () => ({
getMembers: (mockedSdk.getMembers = jest.fn()),
removeMember: (mockedSdk.removeMember = jest.fn()),
initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()),
}),
}));

describe("Rendering", () => {
it("should loads and displays WalletSync Row", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Props = {
cta?: string;
onClick?: () => void;
analyticsPage?: AnalyticsPage;
ctaVariant?: "shade" | "main";
};

const Container = styled(Box)`
Expand All @@ -23,7 +24,14 @@ const Container = styled(Box)`
justify-content: center;
`;

export const Error = ({ title, description, cta, onClick, analyticsPage }: Props) => {
export const Error = ({
title,
description,
cta,
onClick,
analyticsPage,
ctaVariant = "shade",
}: Props) => {
const { colors } = useTheme();
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center" rowGap="24px">
Expand All @@ -38,7 +46,7 @@ export const Error = ({ title, description, cta, onClick, analyticsPage }: Props
{description}
</Text>
{cta && onClick && (
<ButtonV3 variant="shade" onClick={onClick}>
<ButtonV3 variant={ctaVariant} onClick={onClick}>
{cta}
</ButtonV3>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import os from "os";

const platformMap: Record<string, string | undefined> = {
darwin: "Mac",
win32: "Windows",
linux: "Linux",
};

export function useInstanceName(): string {
const platform = os.platform();
const hostname = os.hostname();
const name = `${platformMap[platform] || platform}${hostname ? " " + hostname : ""}`;
return name;
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { useCallback, useState } from "react";
import { createQRCodeHostInstance } from "@ledgerhq/trustchain/qrcode/index";
import { InvalidDigitsError } from "@ledgerhq/trustchain/errors";
import { InvalidDigitsError, NoTrustchainInitialized } from "@ledgerhq/trustchain/errors";
import { useDispatch, useSelector } from "react-redux";
import { setFlow, setQrCodePinCode } from "~/renderer/actions/walletSync";
import { Flow, Step } from "~/renderer/reducers/walletSync";
import { trustchainSelector, memberCredentialsSelector } from "@ledgerhq/trustchain/store";
import {
trustchainSelector,
memberCredentialsSelector,
setTrustchain,
} from "@ledgerhq/trustchain/store";
import { useTrustchainSdk } from "./useTrustchainSdk";
import { useFeature } from "@ledgerhq/live-common/featureFlags/index";
import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams";
import { useQueryClient } from "@tanstack/react-query";
import { QueryKey } from "./type.hooks";
import { useInstanceName } from "./useInstanceName";

export function useQRCode() {
const queryClient = useQueryClient();
Expand All @@ -24,13 +29,14 @@ export function useQRCode() {
const [isLoading, setIsLoading] = useState(false);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<Error | null>(null);
const memberName = useInstanceName();

const goToActivation = useCallback(() => {
dispatch(setFlow({ flow: Flow.Activation, step: Step.DeviceAction }));
}, [dispatch]);

const startQRCodeProcessing = useCallback(() => {
if (!trustchain || !memberCredentials) return;
if (!memberCredentials) return;

setError(null);
setIsLoading(true);
Expand All @@ -44,26 +50,37 @@ export function useQRCode() {
dispatch(setFlow({ flow: Flow.Synchronize, step: Step.PinCode }));
},
addMember: async member => {
await sdk.addMember(trustchain, memberCredentials, member);
return trustchain;
if (trustchain) {
await sdk.addMember(trustchain, memberCredentials, member);
return trustchain;
}
throw new NoTrustchainInitialized();
},
memberCredentials,
memberName,
alreadyHasATrustchain: !!trustchain,
})
.catch(e => {
if (e instanceof InvalidDigitsError) {
dispatch(setFlow({ flow: Flow.Synchronize, step: Step.PinCodeError }));
} else if (e instanceof NoTrustchainInitialized) {
dispatch(setFlow({ flow: Flow.Synchronize, step: Step.UnbackedError }));
}
setError(e);
throw e;
})
.then(() => {
.then(newTrustchain => {
if (newTrustchain) {
dispatch(setTrustchain(newTrustchain));
}
dispatch(setFlow({ flow: Flow.Synchronize, step: Step.Synchronized }));
queryClient.invalidateQueries({ queryKey: [QueryKey.getMembers] });
setUrl(null);
dispatch(setQrCodePinCode(null));
setIsLoading(false);
setError(null);
});
}, [trustchain, memberCredentials, trustchainApiBaseUrl, dispatch, sdk, queryClient]);
}, [memberCredentials, trustchainApiBaseUrl, memberName, dispatch, trustchain, sdk, queryClient]);

return {
url,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os from "os";
import { useMemo } from "react";
import { getEnv } from "@ledgerhq/live-env";
import { getSdk } from "@ledgerhq/trustchain/index";
Expand All @@ -10,12 +9,7 @@ import { walletSyncStateSelector } from "@ledgerhq/live-wallet/store";
import { TrustchainSDK } from "@ledgerhq/trustchain/types";
import { useFeature } from "@ledgerhq/live-common/featureFlags/index";
import getWalletSyncEnvironmentParams from "@ledgerhq/live-common/walletSync/getEnvironmentParams";

const platformMap: Record<string, string | undefined> = {
darwin: "Mac",
win32: "Windows",
linux: "Linux",
};
import { useInstanceName } from "./useInstanceName";

let sdkInstance: TrustchainSDK | null = null;

Expand All @@ -24,15 +18,13 @@ export function useTrustchainSdk() {
const { trustchainApiBaseUrl, cloudSyncApiBaseUrl } = getWalletSyncEnvironmentParams(
featureWalletSync?.params?.environment,
);
const name = useInstanceName();
const isMockEnv = !!getEnv("MOCK");

const defaultContext = useMemo(() => {
const applicationId = 16;
const platform = os.platform();
const hostname = os.hostname();
const name = `${platformMap[platform] || platform}${hostname ? " " + hostname : ""}`;
return { applicationId, name, apiBaseUrl: trustchainApiBaseUrl };
}, [trustchainApiBaseUrl]);
}, [trustchainApiBaseUrl, name]);

const store = useStore();
const lifecycle = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export enum AnalyticsPage {
SyncWithQR = "Sync with QR code",
PinCode = "Pin code",
PinCodeError = "Pin code error",
UnbackedError = "No trustchain initialized error",

SettingsGeneral = "Settings General",
WalletSyncSettings = "Wallet Sync Settings",
Expand Down Expand Up @@ -66,6 +67,7 @@ export const StepMappedToAnalytics: Record<Step, string> = {
[Step.PinCode]: AnalyticsPage.PinCode,
[Step.PinCodeError]: AnalyticsPage.PinCodeError,
[Step.Synchronized]: AnalyticsPage.KeyUpdated,
[Step.UnbackedError]: AnalyticsPage.UnbackedError,

//ManageInstances
[Step.SynchronizedInstances]: AnalyticsPage.ManageInstances,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import React from "react";
import { Device } from "@ledgerhq/live-common/hw/actions/types";
import OpenOrInstallTrustChainApp from "../DeviceActions/openOrInstall";
import { useInitMemberCredentials } from "../../hooks/useInitMemberCredentials";

type Props = {
goNext: (device: Device) => void;
};

export default function DeviceActionStep({ goNext }: Props) {
useInitMemberCredentials();

return <OpenOrInstallTrustChainApp goNext={goNext} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from "react";
import { Error } from "../../components/Error";
import { useTranslation } from "react-i18next";
import { AnalyticsPage } from "../../hooks/useWalletSyncAnalytics";
import { useDispatch } from "react-redux";
import { setFlow } from "~/renderer/actions/walletSync";
import { Flow, Step } from "~/renderer/reducers/walletSync";

export default function UnbackedError() {
const { t } = useTranslation();

const dispatch = useDispatch();

return (
<Error
title={t("walletSync.synchronize.unbackedError.title")}
description={t("walletSync.synchronize.unbackedError.description")}
analyticsPage={AnalyticsPage.UnbackedError}
cta={t("walletSync.synchronize.unbackedError.cta")}
onClick={() => dispatch(setFlow({ flow: Flow.Activation, step: Step.DeviceAction }))}
ctaVariant="main"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import PinCodeStep from "./03-PinCodeStep";
import SyncFinalStep from "./04-SyncFinalStep";
import { AnalyticsPage, useWalletSyncAnalytics } from "../../hooks/useWalletSyncAnalytics";
import PinCodeErrorStep from "./05-PinCodeError";
import UnbackedErrorStep from "./05-UnbackedError";
import { BackProps, BackRef } from "../router";

const SynchronizeWallet = forwardRef<BackRef, BackProps>((_props, ref) => {
Expand Down Expand Up @@ -66,23 +67,24 @@ const SynchronizeWallet = forwardRef<BackRef, BackProps>((_props, ref) => {
case Step.PinCodeError:
return <PinCodeErrorStep />;

case Step.UnbackedError:
return <UnbackedErrorStep />;

case Step.Synchronized:
return <SyncFinalStep />;
}
};

const centeredItems = [Step.Synchronized, Step.PinCodeError, Step.UnbackedError];

return (
<Flex
flexDirection="column"
height="100%"
paddingX="40px"
rowGap="48px"
alignItems={
[Step.Synchronized, Step.PinCodeError].includes(currentStep) ? "center" : undefined
}
justifyContent={
[Step.Synchronized, Step.PinCodeError].includes(currentStep) ? "center" : undefined
}
alignItems={centeredItems.includes(currentStep) ? "center" : undefined}
justifyContent={centeredItems.includes(currentStep) ? "center" : undefined}
>
{getStep()}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SynchronizeWallet from "./Synchronize";
import WalletSyncManageInstances from "./ManageInstances";
import WalletSyncActivation from "./Activation";
import WalletSyncManage from "./Manage";
import { useInitMemberCredentials } from "../hooks/useInitMemberCredentials";

export interface BackRef {
goBack: () => void;
Expand All @@ -14,6 +15,7 @@ export interface BackRef {
export interface BackProps {}

export const WalletSyncRouter = forwardRef<BackRef, BackProps>((_props, ref) => {
useInitMemberCredentials();
const walletSyncFlow = useSelector(walletSyncFlowSelector);

switch (walletSyncFlow) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export enum Step {
SynchronizeWithQRCode = "SynchronizeWithQRCode",
PinCode = "PinCode",
PinCodeError = "PinCodeError",
UnbackedError = "UnbackedError",
Synchronized = "Synchronized",

//ManageInstances
Expand Down
5 changes: 5 additions & 0 deletions apps/ledger-live-desktop/static/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -6508,6 +6508,11 @@
}
}
},
"unbackedError": {
"title": "You need to create your encryption key first",
"description": "Please make sure you’ve created an encryption key on one of your Ledger Live apps before continuing your synchronization.",
"cta": "Create your encryption key"
},
"pinCode": {
"title": "Enter the code",
"description": "Type the code displayed on the other Ledger Live you want to sync with.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.mock("../hooks/useTrustchainSdk", () => ({
useTrustchainSdk: () => ({
getMembers: (mockedSdk.getMembers = jest.fn()),
removeMember: (mockedSdk.removeMember = jest.fn()),
initMemberCredentials: (mockedSdk.initMemberCredentials = jest.fn()),
}),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const ActivationFlow = ({
onQrCodeScanned={handleQrCodeScanned}
currentOption={currentOption}
setSelectedOption={setOption}
qrCodeValue={qrProcess.url}
/>
);

Expand Down
Loading

0 comments on commit 90c266f

Please sign in to comment.