Skip to content

Commit

Permalink
✨ (core) [DSDK-504]: Listen to known devices (WebHID for now) & displ…
Browse files Browse the repository at this point in the history
…ay them in sample app (#392)
  • Loading branch information
ofreyssinet-ledger authored Oct 23, 2024
2 parents 9dea4ba + bd19f5c commit 8774846
Show file tree
Hide file tree
Showing 28 changed files with 1,171 additions and 262 deletions.
6 changes: 6 additions & 0 deletions .changeset/twelve-snakes-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ledgerhq/device-management-kit": patch
"@ledgerhq/device-sdk-sample": patch
---

New use case listenToKnownDevices
1 change: 1 addition & 0 deletions apps/sample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-lottie": "^1.2.4",
"rxjs": "^7.8.1",
"styled-components": "^5.3.11"
},
"devDependencies": {
Expand Down
94 changes: 94 additions & 0 deletions apps/sample/src/components/AvailableDevices/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { useCallback, useState } from "react";
import { DiscoveredDevice } from "@ledgerhq/device-management-kit";
import { Flex, Icons, Text } from "@ledgerhq/react-ui";
import styled from "styled-components";

import { AvailableDevice } from "@/components/Device";
import { useAvailableDevices } from "@/hooks/useAvailableDevices";
import { useSdk } from "@/providers/DeviceSdkProvider";
import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider";

const Title = styled(Text)<{ disabled: boolean }>`
:hover {
user-select: none;
text-decoration: ${(p) => (p.disabled ? "none" : "underline")};
cursor: ${(p) => (p.disabled ? "default" : "pointer")};
}
`;

export const AvailableDevices: React.FC<Record<never, unknown>> = () => {
const discoveredDevices = useAvailableDevices();
const noDevice = discoveredDevices.length === 0;

const [unfolded, setUnfolded] = useState(false);

const toggleUnfolded = useCallback(() => {
setUnfolded((prev) => !prev);
}, []);

return (
<>
<Flex
flexDirection="row"
onClick={noDevice ? undefined : toggleUnfolded}
alignItems="center"
mt={1}
>
<Title variant="tiny" disabled={noDevice}>
Available devices ({discoveredDevices.length})
</Title>
<Flex style={{ visibility: noDevice ? "hidden" : "visible" }}>
{unfolded ? (
<Icons.ChevronUp size={"XS"} />
) : (
<Icons.ChevronDown size={"XS"} />
)}
</Flex>
</Flex>
<Flex
flexDirection="column"
rowGap={4}
alignSelf="stretch"
mt={unfolded ? 5 : 0}
mb={4}
>
{unfolded
? discoveredDevices.map((device) => (
<KnownDevice key={device.id} {...device} />
))
: null}
</Flex>
</>
);
};

const KnownDevice: React.FC<DiscoveredDevice & { connected: boolean }> = (
device,
) => {
const { deviceModel, connected } = device;
const sdk = useSdk();
const { dispatch } = useDeviceSessionsContext();
const connectToDevice = useCallback(() => {
sdk.connect({ device }).then((sessionId) => {
dispatch({
type: "add_session",
payload: {
sessionId,
connectedDevice: sdk.getConnectedDevice({ sessionId }),
},
});
});
}, [sdk, device, dispatch]);

return (
<Flex flexDirection="row" alignItems="center">
<AvailableDevice
name={deviceModel.name}
model={deviceModel.model}
type={"USB"}
connected={connected}
onConnect={connectToDevice}
/>
</Flex>
);
};
50 changes: 49 additions & 1 deletion apps/sample/src/components/Device/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import {
DeviceModelId,
DeviceSessionId,
} from "@ledgerhq/device-management-kit";
import { Box, DropdownGeneric, Flex, Icons, Text } from "@ledgerhq/react-ui";
import {
Box,
Button,
DropdownGeneric,
Flex,
Icons,
Text,
} from "@ledgerhq/react-ui";
import styled, { DefaultTheme } from "styled-components";

import { useDeviceSessionState } from "@/hooks/useDeviceSessionState";
Expand Down Expand Up @@ -115,3 +122,44 @@ export const Device: React.FC<DeviceProps> = ({
</Root>
);
};

type AvailableDeviceProps = {
model: DeviceModelId;
name: string;
type: ConnectionType;
connected: boolean;
onConnect: () => void;
};

export const AvailableDevice: React.FC<AvailableDeviceProps> = ({
model,
name,
type,
onConnect,
connected,
}) => {
const IconComponent = getIconComponent(model);
return (
<Root flex={1} mb={0} m={0}>
<IconContainer>
<IconComponent size="S" />
</IconContainer>
<Flex flexDirection="column" flex={1} rowGap={2}>
<Text variant="body">{name}</Text>
<Flex>
<Text variant="paragraph" color="neutral.c80">
{type}
</Text>
</Flex>
</Flex>
<Button
size="small"
variant="shade"
disabled={connected}
onClick={onConnect}
>
Connect
</Button>
</Root>
);
};
15 changes: 9 additions & 6 deletions apps/sample/src/components/MainView/ConnectDeviceActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const ConnectDeviceActions = ({
onError,
}: ConnectDeviceActionsProps) => {
const {
dispatch: dispatchSdkConfig,
state: { transport },
} = useSdkConfigContext();
const { dispatch: dispatchDeviceSession } = useDeviceSessionsContext();
Expand All @@ -26,10 +25,6 @@ export const ConnectDeviceActions = ({
const onSelectDeviceClicked = useCallback(
(selectedTransport: BuiltinTransports) => {
onError(null);
dispatchSdkConfig({
type: "set_transport",
payload: { transport: selectedTransport },
});
sdk.startDiscovering({ transport: selectedTransport }).subscribe({
next: (device) => {
sdk
Expand All @@ -56,9 +51,17 @@ export const ConnectDeviceActions = ({
},
});
},
[sdk, transport],
[dispatchDeviceSession, onError, sdk],
);

// This implementation gives the impression that working with the mock server
// is a special case, when in fact it's just a transport like the others
// TODO: instead of toggling between mock & regular config, we should
// just have a menu to select the active transports (where the active menu)
// and this here should be a list of one buttons for each active transport
// also we should not have a different appearance when the mock server is enabled
// we should just display the list of active transports somewhere in the sidebar, discreetly

return transport === BuiltinTransports.MOCK_SERVER ? (
<ConnectButton
onClick={() => onSelectDeviceClicked(BuiltinTransports.MOCK_SERVER)}
Expand Down
1 change: 0 additions & 1 deletion apps/sample/src/components/MainView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export const MainView: React.FC = () => {
}
};
}, [connectionError]);

return (
<Root>
<NanoLogo
Expand Down
14 changes: 7 additions & 7 deletions apps/sample/src/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Box, Flex, IconsLegacy, Link, Text } from "@ledgerhq/react-ui";
import { useRouter } from "next/navigation";
import styled, { DefaultTheme } from "styled-components";

import { AvailableDevices } from "@/components/AvailableDevices";
import { Device } from "@/components/Device";
import { Menu } from "@/components/Menu";
import { useExportLogsCallback, useSdk } from "@/providers/DeviceSdkProvider";
Expand Down Expand Up @@ -90,11 +91,10 @@ export const Sidebar: React.FC = () => {
Ledger Device Management Kit
{transport === BuiltinTransports.MOCK_SERVER && <span> (MOCKED)</span>}
</Link>
<Subtitle variant={"small"}>
SDK Version: {version ? version : "Loading..."}
</Subtitle>

<Subtitle variant={"tiny"}>Device</Subtitle>
<Subtitle variant={"tiny"}>
Device sessions ({Object.values(deviceById).length})
</Subtitle>
<div data-testid="container_devices">
{Object.entries(deviceById).map(([sessionId, device]) => (
<Device
Expand All @@ -110,6 +110,7 @@ export const Sidebar: React.FC = () => {
/>
))}
</div>
<AvailableDevices />
<MenuContainer active={!!selectedId}>
<Subtitle variant={"tiny"}>Menu</Subtitle>
<Menu />
Expand All @@ -124,10 +125,9 @@ export const Sidebar: React.FC = () => {
>
Share logs
</Link>
<VersionText variant={"body"}>
Ledger Device Management Kit version {version}
<VersionText variant={"body"} whiteSpace="pre" textAlign="center">
Ledger Device Management Kit{"\n"}version {version}
</VersionText>
<VersionText variant={"body"}>App version 0.1</VersionText>
</BottomContainer>
</Root>
);
Expand Down
45 changes: 45 additions & 0 deletions apps/sample/src/hooks/useAvailableDevices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { DiscoveredDevice } from "@ledgerhq/device-management-kit";
import { Subscription } from "rxjs";

import { useSdk } from "@/providers/DeviceSdkProvider";
import { useDeviceSessionsContext } from "@/providers/DeviceSessionsProvider";

type AvailableDevice = DiscoveredDevice & { connected: boolean };

export function useAvailableDevices(): AvailableDevice[] {
const sdk = useSdk();
const [discoveredDevices, setDiscoveredDevices] = useState<
DiscoveredDevice[]
>([]);
const { state: deviceSessionsState } = useDeviceSessionsContext();

const subscription = useRef<Subscription | null>(null);
useEffect(() => {
if (!subscription.current) {
subscription.current = sdk.listenToKnownDevices().subscribe((devices) => {
setDiscoveredDevices(devices);
});
}
return () => {
if (subscription.current) {
setDiscoveredDevices([]);
subscription.current.unsubscribe();
subscription.current = null;
}
};
}, [sdk]);

const result = useMemo(
() =>
discoveredDevices.map((device) => ({
...device,
connected: Object.values(deviceSessionsState.deviceById).some(
(connectedDevice) => connectedDevice.id === device.id,
),
})),
[discoveredDevices, deviceSessionsState],
);

return result;
}
13 changes: 13 additions & 0 deletions apps/sample/src/hooks/useHasChanged.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useRef } from "react";

/**
* A custom hook that returns whether the value has changed since the last render.
* @param value The value to compare against the previous render.
* @returns A boolean indicating whether the value has changed since the last render.
*/
export function useHasChanged<T>(value: T): boolean {
const ref = useRef<T>(value);
const hasChanged = ref.current !== value;
ref.current = value;
return hasChanged;
}
14 changes: 9 additions & 5 deletions apps/sample/src/hooks/usePrevious.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useEffect, useRef } from "react";
import { useRef } from "react";

/**
* A custom hook that returns the previous value of the provided value.
* @param value The value to compare against the previous render.
* @returns The previous value of the provided value.
*/
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value; //assign the value of ref to the argument
}, [value]); //this code will run when the value of 'value' changes
return ref.current; //in the end, return the current ref value.
const previousValue = ref.current;
ref.current = value;
return previousValue;
}
Loading

0 comments on commit 8774846

Please sign in to comment.