Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/wallet sync synchronize via qr code from mobile to desktop #7646

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -6843,6 +6843,11 @@
"desc": "Make sure the code you type is the one displayed on the other Ledger Live instance.",
"tryAgain": "Try again"
}
},
"unbacked": {
"title": "You need to create your encryption key first",
"desc": "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"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import PinCodeDisplay from "LLM/features/WalletSync/screens/Synchronize/PinCodeD
import PinCodeInput from "LLM/features/WalletSync/screens/Synchronize/PinCodeInput";
import { useInitMemberCredentials } from "LLM/features/WalletSync/hooks/useInitMemberCredentials";
import { useSyncWithQrCode } from "LLM/features/WalletSync/hooks/useSyncWithQrCode";
import UnbackedError from "~/newArch/features/WalletSync/screens/Synchronize/UnbackedError";

type Props = {
currentStep: Steps;
Expand All @@ -21,6 +22,7 @@ type Props = {
currentOption: Options;
navigateToChooseSyncMethod: () => void;
navigateToQrCodeMethod: () => void;
onCreateKey: () => void;
onQrCodeScanned: () => void;
qrProcess: {
url: string | null;
Expand All @@ -41,6 +43,7 @@ const StepFlow = ({
onQrCodeScanned,
qrProcess,
setCurrentStep,
onCreateKey,
}: Props) => {
const { memberCredentials } = useInitMemberCredentials();

Expand Down Expand Up @@ -95,6 +98,9 @@ const StepFlow = ({
case Steps.SyncError:
return <SyncError tryAgain={navigateToQrCodeMethod} />;

case Steps.UnbackedError:
return <UnbackedError create={onCreateKey} />;

default:
return null;
}
Expand Down
Loading
Loading