diff --git a/packages/components/src/components/skeletons/SkeletonRectangle.tsx b/packages/components/src/components/skeletons/SkeletonRectangle.tsx index b9b8d0f82a4..51b1245e6a1 100644 --- a/packages/components/src/components/skeletons/SkeletonRectangle.tsx +++ b/packages/components/src/components/skeletons/SkeletonRectangle.tsx @@ -1,6 +1,6 @@ import styled, { css } from 'styled-components'; -import { Elevation, borders, mapElevationToBackground } from '@trezor/theme'; +import { Elevation, borders, mapElevationToBackground, nextElevation } from '@trezor/theme'; import { SkeletonBaseProps } from './types'; import { getValue, shimmerEffect } from './utils'; @@ -18,7 +18,12 @@ const StyledSkeletonRectangle = styled.div< >` width: ${({ $width }) => getValue($width) ?? '80px'}; height: ${({ $height }) => getValue($height) ?? '20px'}; - background: ${({ $background, ...props }) => $background ?? mapElevationToBackground(props)}; + background: ${({ $background, ...props }) => + $background ?? + mapElevationToBackground({ + theme: props.theme, + $elevation: props.$elevation, + })}; border-radius: ${({ $borderRadius }) => getValue($borderRadius) ?? borders.radii.xs}; background-size: 200%; diff --git a/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx b/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx index e9f49c0014b..14e19c64f69 100644 --- a/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx +++ b/packages/product-components/src/components/RotateDeviceImage/RotateDeviceImage.tsx @@ -32,9 +32,12 @@ export const RotateDeviceImage = ({ const isDeviceImageRotating = deviceModel && - [DeviceModelInternal.T2B1, DeviceModelInternal.T3B1, DeviceModelInternal.T3T1].includes( - deviceModel, - ); + [ + DeviceModelInternal.T2B1, + DeviceModelInternal.T3B1, + DeviceModelInternal.T3T1, + DeviceModelInternal.T3W1, + ].includes(deviceModel); return ( <> diff --git a/packages/suite-desktop-ui/src/Main.tsx b/packages/suite-desktop-ui/src/Main.tsx index efbc6504ea7..31c62361d4e 100644 --- a/packages/suite-desktop-ui/src/Main.tsx +++ b/packages/suite-desktop-ui/src/Main.tsx @@ -30,6 +30,7 @@ import { useDebugLanguageShortcut, useFormattersConfig } from 'src/hooks/suite'; import history from 'src/support/history'; import { ModalContextProvider } from 'src/support/suite/ModalContext'; import { desktopHandshake } from 'src/actions/suite/suiteActions'; +import { initBluetoothThunk } from 'src/actions/bluetooth/initBluetoothThunk'; import * as STORAGE from 'src/actions/suite/constants/storageConstants'; import { DesktopUpdater } from './support/DesktopUpdater'; @@ -134,6 +135,8 @@ export const init = async (container: HTMLElement) => { TrezorConnect[method] = proxy[method]; }); + store.dispatch(initBluetoothThunk()); + // finally render whole app root.render( diff --git a/packages/suite/src/actions/bluetooth/bluetoothActions.ts b/packages/suite/src/actions/bluetooth/bluetoothActions.ts new file mode 100644 index 00000000000..ce9266a9851 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothActions.ts @@ -0,0 +1,39 @@ +import { createAction } from '@reduxjs/toolkit'; + +import { BluetoothDevice } from '@trezor/transport-bluetooth'; + +import { + BluetoothScanStatus, + DeviceBluetoothStatus, +} from '../../reducers/bluetooth/bluetoothReducer'; + +export const BLUETOOTH_PREFIX = '@suite/bluetooth'; + +export const bluetoothAdapterEventAction = createAction( + `${BLUETOOTH_PREFIX}/adapter-event`, + ({ isPowered }: { isPowered: boolean }) => ({ payload: { isPowered } }), +); + +export const bluetoothDeviceListUpdate = createAction( + `${BLUETOOTH_PREFIX}/device-list-update`, + ({ devices }: { devices: BluetoothDevice[] }) => ({ payload: { devices } }), +); + +export const bluetoothConnectDeviceEventAction = createAction( + `${BLUETOOTH_PREFIX}/device-connection-status`, + ({ connectionStatus, uuid }: { uuid: string; connectionStatus: DeviceBluetoothStatus }) => ({ + payload: { uuid, connectionStatus }, + }), +); + +export const bluetoothScanStatusAction = createAction( + `${BLUETOOTH_PREFIX}/scan-status`, + ({ status }: { status: BluetoothScanStatus }) => ({ payload: { status } }), +); + +export const allBluetoothActions = { + bluetoothAdapterEventAction, + bluetoothDeviceListUpdate, + bluetoothConnectDeviceEventAction, + bluetoothScanStatusAction, +}; diff --git a/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts new file mode 100644 index 00000000000..3f523c06a5e --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothConnectDeviceThunk.ts @@ -0,0 +1,15 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +type ThunkResponse = ReturnType; + +export const bluetoothConnectDeviceThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothConnectDeviceThunk`, + async ({ uuid }, { fulfillWithValue }) => { + const result = await bluetoothManager.connectDevice(uuid); + + return fulfillWithValue(result); + }, +); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts new file mode 100644 index 00000000000..ba6d76792dc --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothStartScanningThunk.ts @@ -0,0 +1,13 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +export const bluetoothStartScanningThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothStartScanningThunk`, + _ => { + // This can fail, but if there is an error we already got it from `adapter-event` + // and user is informed about it (bluetooth turned-off, ...) + bluetoothManager.startScan(); + }, +); diff --git a/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts new file mode 100644 index 00000000000..fc8ddeead8b --- /dev/null +++ b/packages/suite/src/actions/bluetooth/bluetoothStopScanningThunk.ts @@ -0,0 +1,12 @@ +import { createThunk } from '@suite-common/redux-utils'; +import { bluetoothManager } from '@trezor/transport-bluetooth'; + +import { BLUETOOTH_PREFIX } from './bluetoothActions'; + +export const bluetoothStopScanningThunk = createThunk( + `${BLUETOOTH_PREFIX}/bluetoothStopScanningThunk`, + _ => { + // This can fail, but there is nothing we can do about it + bluetoothManager.stopScan(); + }, +); diff --git a/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts new file mode 100644 index 00000000000..e553e327ec8 --- /dev/null +++ b/packages/suite/src/actions/bluetooth/initBluetoothThunk.ts @@ -0,0 +1,44 @@ +import { createThunk } from '@suite-common/redux-utils/'; +import { bluetoothManager, DeviceConnectionStatus } from '@trezor/transport-bluetooth'; +import { Without } from '@trezor/type-utils'; + +import { + BLUETOOTH_PREFIX, + bluetoothAdapterEventAction, + bluetoothConnectDeviceEventAction, + bluetoothDeviceListUpdate, +} from './bluetoothActions'; + +type DeviceConnectionStatusWithOptionalUuid = Without & { + uuid?: string; +}; + +export const initBluetoothThunk = createThunk( + `${BLUETOOTH_PREFIX}/initBluetoothThunk`, + (_, { dispatch }) => { + bluetoothManager.on('adapter-event', isPowered => { + console.warn('adapter-event', isPowered); + dispatch(bluetoothAdapterEventAction({ isPowered })); + }); + + bluetoothManager.on('device-list-update', devices => { + console.warn('device-list-update', devices); + dispatch(bluetoothDeviceListUpdate({ devices })); + }); + + bluetoothManager.on('device-connection-status', connectionStatus => { + console.warn('device-connection-status', connectionStatus); + const copyConnectionStatus: DeviceConnectionStatusWithOptionalUuid = { + ...connectionStatus, + }; + delete copyConnectionStatus.uuid; // So we dont pollute redux store + + dispatch( + bluetoothConnectDeviceEventAction({ + uuid: connectionStatus.uuid, + connectionStatus: copyConnectionStatus, + }), + ); + }); + }, +); diff --git a/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx b/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx index 4707675ac4a..5008d0953e1 100644 --- a/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx +++ b/packages/suite/src/components/suite/PrerequisitesGuide/DeviceConnect.tsx @@ -1,3 +1,5 @@ +import { Button } from '@trezor/components'; + import { Translation, TroubleshootingTips, WebUsbButton } from 'src/components/suite'; import { TROUBLESHOOTING_TIP_BRIDGE_STATUS, @@ -10,9 +12,15 @@ import { interface DeviceConnectProps { isWebUsbTransport: boolean; + isBluetooth: boolean; + onBluetoothClick: () => void; } -export const DeviceConnect = ({ isWebUsbTransport }: DeviceConnectProps) => { +export const DeviceConnect = ({ + isWebUsbTransport, + onBluetoothClick, + isBluetooth, +}: DeviceConnectProps) => { const items = isWebUsbTransport ? [ TROUBLESHOOTING_TIP_UDEV, @@ -32,7 +40,23 @@ export const DeviceConnect = ({ isWebUsbTransport }: DeviceConnectProps) => { } items={items} - cta={isWebUsbTransport ? : undefined} + cta={ + // eslint-disable-next-line no-nested-ternary + isBluetooth ? ( + + ) : isWebUsbTransport ? ( + + ) : undefined + } data-testid="@connect-device-prompt/no-device-detected" /> ); diff --git a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx index de3421e3826..299d003d806 100644 --- a/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx +++ b/packages/suite/src/components/suite/PrerequisitesGuide/PrerequisitesGuide.tsx @@ -1,10 +1,10 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import styled from 'styled-components'; import { motion } from 'framer-motion'; import { getStatus, deviceNeedsAttention } from '@suite-common/suite-utils'; -import { Button, motionEasing } from '@trezor/components'; +import { Button, motionEasing, ElevationContext, ElevationDown, Flex } from '@trezor/components'; import { selectDevices, selectSelectedDevice } from '@suite-common/wallet-core'; import { ConnectDevicePrompt, Translation } from 'src/components/suite'; @@ -26,6 +26,7 @@ import { DeviceUpdateRequired } from './DeviceUpdateRequired'; import { DeviceDisconnectRequired } from './DeviceDisconnectRequired'; import { MultiShareBackupInProgress } from './MultiShareBackupInProgress'; import { DeviceUsedElsewhere } from './DeviceUsedElsewhere'; +import { BluetoothConnect } from '../bluetooth/BluetoothConnect'; const Wrapper = styled.div` display: flex; @@ -48,11 +49,14 @@ interface PrerequisitesGuideProps { } export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProps) => { + const [isBluetoothConnectOpen, setIsBluetoothConnectOpen] = useState(false); + const dispatch = useDispatch(); const device = useSelector(selectSelectedDevice); const devices = useSelector(selectDevices); const connectedDevicesCount = devices.filter(d => d.connected === true).length; const isWebUsbTransport = useSelector(selectHasTransportOfType('WebUsbTransport')); + const isBluetooth = useSelector(selectHasTransportOfType('BluetoothTransport')); const prerequisite = useSelector(selectPrerequisite); const TipComponent = useMemo( @@ -63,7 +67,13 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp case 'device-disconnect-required': return ; case 'device-disconnected': - return ; + return ( + setIsBluetoothConnectOpen(true)} + /> + ); case 'device-unacquired': return ; case 'device-used-elsewhere': @@ -91,7 +101,7 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp return <>; } }, - [prerequisite, isWebUsbTransport, device], + [prerequisite, isWebUsbTransport, isBluetooth, device], ); const handleSwitchDeviceClick = () => @@ -99,30 +109,46 @@ export const PrerequisitesGuide = ({ allowSwitchDevice }: PrerequisitesGuideProp return ( - - - {allowSwitchDevice && connectedDevicesCount > 1 && ( - - - - )} + {isBluetoothConnectOpen ? ( + + {/* Here we need to draw the inner card with elevation -1 (custom design) */} + + + setIsBluetoothConnectOpen(false)} + uiMode="spatial" + /> + + + + ) : ( + <> + - - - + {allowSwitchDevice && connectedDevicesCount > 1 && ( + + + + )} + + + + + + )} ); }; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx new file mode 100644 index 00000000000..cd586fc664c --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothConnect.tsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useState } from 'react'; + +import TrezorConnect from '@trezor/connect'; +import { Card, Column, ElevationUp } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { notificationsActions } from '@suite-common/toast-notifications'; +import { TimerId } from '@trezor/type-utils'; + +import { BluetoothNotEnabled } from './errors/BluetoothNotEnabled'; +import { BluetoothDeviceList } from './BluetoothDeviceList'; +import { BluetoothVersionNotCompatible } from './errors/BluetoothVersionNotCompatible'; +import { BluetoothTips } from './BluetoothTips'; +import { BluetoothScanHeader } from './BluetoothScanHeader'; +import { BluetoothScanFooter } from './BluetoothScanFooter'; +import { useDispatch, useSelector } from '../../../hooks/suite'; +import { BluetoothSelectedDevice } from './BluetoothSelectedDevice'; +import { + bluetoothConnectDeviceEventAction, + bluetoothScanStatusAction, +} from '../../../actions/bluetooth/bluetoothActions'; +import { + selectBluetoothDeviceList, + selectBluetoothEnabled, + selectBluetoothScanStatus, +} from '../../../reducers/bluetooth/bluetoothSelectors'; +import { BluetoothPairingPin } from './BluetoothPairingPin'; +import { bluetoothStartScanningThunk } from '../../../actions/bluetooth/bluetoothStartScanningThunk'; +import { bluetoothStopScanningThunk } from '../../../actions/bluetooth/bluetoothStopScanningThunk'; +import { bluetoothConnectDeviceThunk } from '../../../actions/bluetooth/bluetoothConnectDeviceThunk'; + +const SCAN_TIMEOUT = 30_000; + +type BluetoothConnectProps = { + onClose: () => void; + uiMode: 'spatial' | 'card'; +}; + +export const BluetoothConnect = ({ onClose, uiMode }: BluetoothConnectProps) => { + const dispatch = useDispatch(); + const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null); + const [scannerTimerId, setScannerTimerId] = useState(null); + + const isBluetoothEnabled = useSelector(selectBluetoothEnabled); + const scanStatus = useSelector(selectBluetoothScanStatus); + const deviceList = useSelector(selectBluetoothDeviceList); + const devices = Object.values(deviceList); + + const selectedDevice = + selectedDeviceUuid !== null ? deviceList[selectedDeviceUuid] ?? null : null; + + useEffect(() => { + dispatch(bluetoothStartScanningThunk()); + + return () => { + dispatch(bluetoothStopScanningThunk()); + }; + }, [dispatch]); + + const clearScamTimer = useCallback(() => { + if (scannerTimerId !== null) { + clearTimeout(scannerTimerId); + } + }, [scannerTimerId]); + + useEffect(() => { + // Intentionally no `clearScamTimer`, this is first run and if we use this we would create infinite re-render + const timerId = setTimeout(() => { + dispatch(bluetoothScanStatusAction({ status: 'done' })); + }, SCAN_TIMEOUT); + + setScannerTimerId(timerId); + }, [dispatch]); + + const onReScanClick = () => { + setSelectedDeviceUuid(null); + dispatch(bluetoothScanStatusAction({ status: 'running' })); + + clearScamTimer(); + const timerId = setTimeout(() => { + dispatch(bluetoothScanStatusAction({ status: 'done' })); + }, SCAN_TIMEOUT); + setScannerTimerId(timerId); + }; + + const onSelect = async (uuid: string) => { + setSelectedDeviceUuid(uuid); + + const result = await dispatch(bluetoothConnectDeviceThunk({ uuid })).unwrap(); + + if (!result.success) { + dispatch( + bluetoothConnectDeviceEventAction({ + uuid, + connectionStatus: { type: 'error', error: result.error }, + }), + ); + dispatch( + notificationsActions.addToast({ + type: 'error', + error: result.error, + }), + ); + } else { + // Todo: What to do with error in this flow? UI-Wise + + dispatch( + bluetoothConnectDeviceEventAction({ + uuid, + connectionStatus: { type: 'connected' }, + }), + ); + + // WAIT for connect event, TODO: figure out better way + const closePopupAfterConnection = () => { + TrezorConnect.off('device-connect', closePopupAfterConnection); + TrezorConnect.off('device-connect_unacquired', closePopupAfterConnection); + // setSelectedDeviceStatus({ type: 'error', uuid }); // Todo: what here? + }; + TrezorConnect.on('device-connect', closePopupAfterConnection); + TrezorConnect.on('device-connect_unacquired', closePopupAfterConnection); + } + }; + + if (!isBluetoothEnabled) { + return ; + } + + // Todo: incompatible version + // eslint-disable-next-line no-constant-condition + if (false) { + return ; + } + + console.log('selectedDevice', selectedDevice); + + // This is fake, we scan for devices all the time + const isScanning = scanStatus !== 'done'; + const scanFailed = devices.length === 0 && scanStatus === 'done'; + + const handlePairingCancel = () => { + setSelectedDeviceUuid(null); + onReScanClick(); + }; + + if ( + selectedDevice !== null && + selectedDevice.status.type === 'pairing' && + (selectedDevice.status.pin?.length ?? 0) > 0 + ) { + return ( + + ); + } + + if (selectedDevice !== null) { + return ; + } + + const content = scanFailed ? ( + + ) : ( + + ); + + return ( + + + + + + {/* Here we need to do +1 in elevation because of custom design on the welcome screen */} + {uiMode === 'spatial' ? {content} : content} + + {uiMode === 'card' && ( + + )} + + + + {uiMode === 'spatial' && ( + // Here we need to do +2 in elevation because of custom design on the welcome screen + + + + + + )} + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx new file mode 100644 index 00000000000..2c3e243ba4d --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDevice.tsx @@ -0,0 +1,54 @@ +import { Column, FlexProps, Icon, Row, Text } from '@trezor/components'; +import { RotateDeviceImage } from '@trezor/product-components'; +import { spacings } from '@trezor/theme'; +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; +import { models } from '@trezor/connect/src/data/models'; // Todo: solve this import issue +import { DeviceModelInternal } from '@trezor/connect'; + +type BluetoothDeviceProps = { + device: BluetoothDeviceType; + flex?: FlexProps['flex']; + margin?: FlexProps['margin']; +}; + +// TODO some config map number => DeviceModelInternal +const getModelEnumFromBytesUtil = (_id: number) => { + return DeviceModelInternal.T3W1; +}; + +// TODO some config map number => color id +// discuss final format of it +const getColorEnumFromVariantBytesUtil = (variant: number) => { + return variant; +}; + +export const BluetoothDevice = ({ device, flex, margin }: BluetoothDeviceProps) => { + const model = getModelEnumFromBytesUtil(device.data[2]); + const color = getColorEnumFromVariantBytesUtil(device.data[1]); + const colorName = models[model].colors[color.toString()]; + + return ( + + + + + Trezor Safe 7 + + + + {colorName} + + + + {device.name} + + + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx new file mode 100644 index 00000000000..776e08683f2 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceItem.tsx @@ -0,0 +1,25 @@ +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; +import { Button, Row } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDevice } from './BluetoothDevice'; + +type BluetoothDeviceItemProps = { + device: BluetoothDeviceType; + onClick: () => void; + isDisabled?: boolean; +}; + +export const BluetoothDeviceItem = ({ device, onClick, isDisabled }: BluetoothDeviceItemProps) => ( + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx new file mode 100644 index 00000000000..38ac7fd159d --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothDeviceList.tsx @@ -0,0 +1,42 @@ +import { Card, Column, SkeletonRectangle, Row } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDeviceItem } from './BluetoothDeviceItem'; +import { BluetoothDeviceState } from '../../../reducers/bluetooth/bluetoothReducer'; + +type BluetoothDeviceListProps = { + deviceList: BluetoothDeviceState[]; + onSelect: (uuid: string) => void; + isScanning: boolean; + isDisabled: boolean; +}; + +const SkeletonDevice = () => ( + + + + + + + + +); + +export const BluetoothDeviceList = ({ + onSelect, + deviceList, + isScanning, +}: BluetoothDeviceListProps) => ( + + + {deviceList.map(d => ( + onSelect(d.device.uuid)} + /> + ))} + {isScanning && } + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx new file mode 100644 index 00000000000..019d134b8bc --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothPairingPin.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; + +import { Row, NewModal, Card, Link, Text } from '@trezor/components'; +import { spacings, spacingsPx, typography } from '@trezor/theme'; +import { BluetoothDevice as BluetoothDeviceType } from '@trezor/transport-bluetooth'; + +import { BluetoothDevice } from './BluetoothDevice'; + +const Pin = styled.div` + display: flex; + flex: 1; + + ${typography.titleLarge} /* Amount */ margin: 0 auto; + + letter-spacing: ${spacingsPx.md}; +`; + +type BluetoothPairingPinProps = { + onCancel: () => void; + pairingPin?: string; + device: BluetoothDeviceType; +}; + +export const BluetoothPairingPin = ({ onCancel, pairingPin, device }: BluetoothPairingPinProps) => ( + + Codes don't match? + + } + > + + + {pairingPin} + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx new file mode 100644 index 00000000000..5e333d0938b --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothScanFooter.tsx @@ -0,0 +1,31 @@ +import { Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { NotTrezorYouAreLookingFor } from './NotTrezorYouAreLookingFor'; +import { BluetoothScanStatus } from '../../../reducers/bluetooth/bluetoothReducer'; + +type BluetoothScanFooterProps = { + onReScanClick: () => void; + scanStatus: BluetoothScanStatus; + numberOfDevices: number; +}; + +export const BluetoothScanFooter = ({ + onReScanClick, + scanStatus, + numberOfDevices, +}: BluetoothScanFooterProps) => { + if (scanStatus === 'running') { + return ( + + Make sure your TS7 is on and in pairing mode (hold power button) + + ); + } + + if (scanStatus === 'done' && numberOfDevices > 0) { + return ; + } + + return null; +}; diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx new file mode 100644 index 00000000000..43eb14b8ac6 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothScanHeader.tsx @@ -0,0 +1,35 @@ +import { spacings } from '@trezor/theme'; +import { Button, Row, Spinner, Text } from '@trezor/components'; + +type BluetoothScanHeaderProps = { + isScanning: boolean; + numberOfDevices: number; + onClose: () => void; +}; + +export const BluetoothScanHeader = ({ + isScanning, + onClose, + numberOfDevices, +}: BluetoothScanHeaderProps) => ( + + + {isScanning ? ( + <> + + Scanning + + ) : ( + + {numberOfDevices} Trezors Found + + )} + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx new file mode 100644 index 00000000000..e6d82017db3 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothSelectedDevice.tsx @@ -0,0 +1,66 @@ +import { Card, ElevationContext, Icon, Row, Spinner, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { BluetoothDevice } from './BluetoothDevice'; +import { BluetoothDeviceState } from '../../../reducers/bluetooth/bluetoothReducer'; +import { BluetoothTips } from './BluetoothTips'; + +const PairedComponent = () => ( + + {/* Todo: here we shall solve how to continue with Trezor Host Protocol */} + + Paired + +); + +const PairingComponent = () => ( + + + Pairing + +); + +export type OkComponentProps = { + device: BluetoothDeviceState; +}; + +const OkComponent = ({ device }: OkComponentProps) => ( + + + + {device.status.type === 'connected' ? : } + +); + +export type ErrorComponentProps = { + device: BluetoothDeviceState; + onReScanClick: () => void; +}; + +const ErrorComponent = ({ device, onReScanClick }: ErrorComponentProps) => { + if (device.status.type !== 'error') { + return null; + } + + return ; +}; + +export type BluetoothSelectedDeviceProps = { + device: BluetoothDeviceState; + onReScanClick: () => void; +}; + +export const BluetoothSelectedDevice = ({ + device, + onReScanClick, +}: BluetoothSelectedDeviceProps) => ( + + {device.status.type === 'error' ? ( + + ) : ( + + + + )} + +); diff --git a/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx b/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx new file mode 100644 index 00000000000..1b08a63b5d8 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/BluetoothTips.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from 'react'; + +import { Button, Card, Column, Divider, Icon, IconName, Row, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +type BluetoothTipProps = { + icon: IconName; + header: string; + text: string; +}; + +const BluetoothTip = ({ icon, header, text }: BluetoothTipProps) => ( + + + + {header} + + {text} + + + +); + +type BluetoothTipsProps = { + onReScanClick: () => void; + header: ReactNode; +}; + +export const BluetoothTips = ({ onReScanClick, header }: BluetoothTipsProps) => ( + + + + {header} + + + + + + + + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx b/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx new file mode 100644 index 00000000000..60c7f09573e --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/NotTrezorYouAreLookingFor.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +import { CollapsibleBox, Link, Text } from '@trezor/components'; + +import { BluetoothTips } from './BluetoothTips'; + +type NotTrezorYouAreLookingForProps = { + onReScanClick: () => void; +}; + +export const NotTrezorYouAreLookingFor = ({ onReScanClick }: NotTrezorYouAreLookingForProps) => { + const [showTips, setShowTips] = useState(false); + + return ( + setShowTips(true)}> + Not the Trezor you’re looking for? + + } + > + {showTips && ( + + )} + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx new file mode 100644 index 00000000000..b885bbc61bb --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothDeniedForSuite.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import { Text, NewModal, Column, Banner } from '@trezor/components'; +import { desktopApi } from '@trezor/suite-desktop-api'; +import { spacings } from '@trezor/theme'; + +type BluetoothDeniedForSuiteProps = { + onCancel: () => void; +}; + +export const BluetoothDeniedForSuite = ({ onCancel }: BluetoothDeniedForSuiteProps) => { + const [hasDeeplinkFailed, setHasDeeplinkFailed] = useState(false); + + const openSettings = async () => { + const opened = await desktopApi.bluetoothOpenSettings(); + + console.log('opened', opened); + + if (!opened.success || !opened.payload) { + setHasDeeplinkFailed(true); + } + }; + + return ( + + Enable bluetooth + + Cancel + + + } + > + + Enable bluetooth on your computer + + Or connect your Trezor via cable. + + {hasDeeplinkFailed && ( + + Cannot open bluetooth settings. Please enable bluetooth manually. + + )} + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx new file mode 100644 index 00000000000..591b2b8185b --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotAllowedForSuite.tsx @@ -0,0 +1,3 @@ +export const BluetoothNotAllowedForSuite = () => { + return <>; +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx new file mode 100644 index 00000000000..f0467563df8 --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothNotEnabled.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +import { Text, NewModal, Column, Banner } from '@trezor/components'; +import { desktopApi } from '@trezor/suite-desktop-api'; +import { spacings } from '@trezor/theme'; + +type BluetoothNotEnabledProps = { + onCancel: () => void; +}; + +export const BluetoothNotEnabled = ({ onCancel }: BluetoothNotEnabledProps) => { + const [hasDeeplinkFailed, setHasDeeplinkFailed] = useState(false); + + const openSettings = async () => { + const opened = await desktopApi.bluetoothOpenSettings(); + + if (!opened.success) { + setHasDeeplinkFailed(true); + } + }; + + return ( + + Enable bluetooth + + Cancel + + + } + > + + Enable bluetooth on your computer + + Or connect your Trezor via cable. + + {hasDeeplinkFailed && ( + + Cannot open bluetooth settings. Please enable bluetooth manually. + + )} + + + ); +}; diff --git a/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx b/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx new file mode 100644 index 00000000000..1c176f682cf --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/errors/BluetoothVersionNotCompatible.tsx @@ -0,0 +1,30 @@ +import { Text, NewModal, Column } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +type BluetoothVersionNotCompatibleProps = { + onCancel: () => void; +}; + +export const BluetoothVersionNotCompatible = ({ onCancel }: BluetoothVersionNotCompatibleProps) => ( + + + Cancel + + + } + > + + + Your computer’s bluetooth version is not compatible with whatever we’re using copy. + + + Use cable, buy a 5.0+ dongle. + + + +); diff --git a/packages/suite/src/components/suite/bluetooth/types.ts b/packages/suite/src/components/suite/bluetooth/types.ts new file mode 100644 index 00000000000..9c853e80dfb --- /dev/null +++ b/packages/suite/src/components/suite/bluetooth/types.ts @@ -0,0 +1,12 @@ +import type { DeviceConnectionStatus } from '@trezor/transport-bluetooth'; + +export type FakeScanStatus = 'running' | 'done'; + +export type DeviceBluetoothStatus = + | DeviceConnectionStatus + | { + uuid: string; + type: 'found' | 'error'; + }; + +export type DeviceBluetoothStatusType = DeviceBluetoothStatus['type']; diff --git a/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts b/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts new file mode 100644 index 00000000000..b294b67af93 --- /dev/null +++ b/packages/suite/src/reducers/bluetooth/bluetoothReducer.ts @@ -0,0 +1,133 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { BluetoothDevice, DeviceConnectionStatus } from '@trezor/transport-bluetooth'; +import { deviceActions } from '@suite-common/wallet-core'; +import { Without } from '@trezor/type-utils/'; + +import { + bluetoothAdapterEventAction, + bluetoothConnectDeviceEventAction, + bluetoothDeviceListUpdate, + bluetoothScanStatusAction, +} from '../../actions/bluetooth/bluetoothActions'; +import { bluetoothStartScanningThunk } from '../../actions/bluetooth/bluetoothStartScanningThunk'; +import { bluetoothStopScanningThunk } from '../../actions/bluetooth/bluetoothStopScanningThunk'; + +export type BluetoothScanStatus = 'running' | 'done'; + +export type DeviceBluetoothStatus = + | Without // We have UUID in the deviceList map in the state + | { + type: 'error'; + error: string; + } + | { + // This is state when device is fully connected and dashboard is shown to the user + // At this point we can save the device to the list of paired devices for future reconnects + type: 'connect-connected'; // Todo: Find better naming + }; + +export type DeviceBluetoothStatusType = DeviceBluetoothStatus['type']; + +export type BluetoothDeviceState = { + device: BluetoothDevice; + status: DeviceBluetoothStatus; +}; + +type BluetoothState = { + isBluetoothEnabled: boolean; + scanStatus: BluetoothScanStatus; + + // This will be persisted, those are devices we believed that are paired + // (because we already successfully paired them in the Suite) in the Operating System + pairedDevices: BluetoothDevice[]; + + // This list of devices that is union of saved-devices and device that we get from scan + deviceList: Record; +}; + +const initialState: BluetoothState = { + isBluetoothEnabled: true, // To prevent the UI from flickering when the page is loaded + scanStatus: 'running', // To prevent the UI from flickering when the page is loaded + pairedDevices: [], + deviceList: {}, +}; + +export const bluetoothReducer = createReducer(initialState, builder => + builder + .addCase(bluetoothAdapterEventAction, (state, { payload: { isPowered } }) => { + state.isBluetoothEnabled = isPowered; + if (!isPowered) { + state.deviceList = {}; + } + }) + .addCase(bluetoothDeviceListUpdate, (state, { payload: { devices } }) => { + const newList: Record = Object.fromEntries( + state.pairedDevices.map(device => [ + device.uuid, + { + device, + status: { type: 'paired' }, + }, + ]), + ); + + devices.forEach(device => { + newList[device.uuid] = { + device, + status: state.deviceList[device.uuid]?.status ?? { type: 'found' }, + }; + }); + + state.deviceList = newList; + }) + .addCase( + bluetoothConnectDeviceEventAction, + (state, { payload: { uuid, connectionStatus } }) => { + const device = state.deviceList[uuid]; + + if (device !== undefined) { + device.status = connectionStatus; + } + }, + ) + .addCase(bluetoothScanStatusAction, (state, { payload: { status } }) => { + state.scanStatus = status; + }) + .addCase(deviceActions.deviceDisconnect, (state, { payload: { bluetoothProps } }) => { + if (bluetoothProps) { + delete state.deviceList[bluetoothProps.uuid]; + } + }) + .addCase( + deviceActions.connectDevice, + ( + state, + { + payload: { + device: { bluetoothProps }, + }, + }, + ) => { + if (bluetoothProps && bluetoothProps.uuid in state.deviceList) { + const deviceState = state.deviceList[bluetoothProps.uuid]; + deviceState.status = { type: 'connect-connected' }; + + // Once device is fully connected, we save it to the list of paired devices + // so next time user opens suite + const foundPairedDevice = state.pairedDevices.find( + it => it.uuid === bluetoothProps.uuid, + ); + if (foundPairedDevice === undefined) { + state.pairedDevices.push(deviceState.device); + } + } + }, + ) + .addCase(bluetoothStartScanningThunk.fulfilled, state => { + state.scanStatus = 'running'; + }) + .addCase(bluetoothStopScanningThunk.fulfilled, state => { + state.scanStatus = 'done'; + }), +); diff --git a/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts b/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts new file mode 100644 index 00000000000..97672c10ebf --- /dev/null +++ b/packages/suite/src/reducers/bluetooth/bluetoothSelectors.ts @@ -0,0 +1,7 @@ +import { AppState } from '../store'; + +export const selectBluetoothEnabled = (state: AppState) => state.bluetooth.isBluetoothEnabled; + +export const selectBluetoothDeviceList = (state: AppState) => state.bluetooth.deviceList; + +export const selectBluetoothScanStatus = (state: AppState) => state.bluetooth.scanStatus; diff --git a/packages/suite/src/reducers/store.ts b/packages/suite/src/reducers/store.ts index 753ea90be5d..c06b4f6ce24 100644 --- a/packages/suite/src/reducers/store.ts +++ b/packages/suite/src/reducers/store.ts @@ -21,6 +21,7 @@ import walletReducers from 'src/reducers/wallet'; import onboardingReducers from 'src/reducers/onboarding'; import recoveryReducers from 'src/reducers/recovery'; import backupReducers from 'src/reducers/backup'; +import { bluetoothReducer } from 'src/reducers/bluetooth/bluetoothReducer'; // toastMiddleware can be used only in suite-desktop and suite-web // it's not included into `@suite-middlewares` index import toastMiddleware from 'src/middlewares/suite/toastMiddleware'; @@ -41,6 +42,7 @@ const rootReducer = combineReducers({ backup: backupReducers, desktop: desktopReducer, tokenDefinitions: tokenDefinitionsReducer, + bluetooth: bluetoothReducer, }); export type AppState = ReturnType; diff --git a/packages/suite/src/types/suite/index.ts b/packages/suite/src/types/suite/index.ts index d5739278aec..935feef48f6 100644 --- a/packages/suite/src/types/suite/index.ts +++ b/packages/suite/src/types/suite/index.ts @@ -25,6 +25,7 @@ import type { WalletAction } from 'src/types/wallet'; import type { BackupAction } from 'src/actions/backup/backupActions'; import type { RecoveryAction } from 'src/actions/recovery/recoveryActions'; import type { GuideAction } from 'src/actions/suite/guideActions'; +import { allBluetoothActions } from 'src/actions/bluetooth/bluetoothActions'; // reexport export type { ExtendedMessageDescriptor } from 'src/components/suite/Translation'; @@ -58,6 +59,7 @@ type DiscoveryAction = ReturnType<(typeof discoveryActions)[keyof typeof discove type DeviceAuthenticityAction = ReturnType< (typeof deviceAuthenticityActions)[keyof typeof deviceAuthenticityActions] >; +type BluetoothAction = ReturnType<(typeof allBluetoothActions)[keyof typeof allBluetoothActions]>; // all actions from all apps used to properly type Dispatch. export type Action = @@ -83,7 +85,8 @@ export type Action = | ProtocolAction | DiscoveryAction | DeviceAction - | DeviceAuthenticityAction; + | DeviceAuthenticityAction + | BluetoothAction; export type ThunkAction = TAction; @@ -111,6 +114,7 @@ export type ForegroundAppProps = { export type ToastNotificationVariant = 'success' | 'info' | 'warning' | 'error' | 'transparent'; export { TorStatus } from '@trezor/suite-desktop-api/src/enums'; + export interface TorBootstrap { current: number; total: number; diff --git a/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx b/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx index 0daa11115c8..66fb2770d5b 100644 --- a/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx +++ b/packages/suite/src/views/suite/SwitchDevice/SwitchDevice.tsx @@ -1,6 +1,8 @@ +import { useState } from 'react'; + import * as deviceUtils from '@suite-common/suite-utils'; import { selectSelectedDevice, selectDevices } from '@suite-common/wallet-core'; -import { Column } from '@trezor/components'; +import { Button, Column, Icon, Row, Text } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { ForegroundAppProps } from 'src/types/suite'; @@ -8,8 +10,11 @@ import { useSelector } from 'src/hooks/suite'; import { DeviceItem } from './DeviceItem/DeviceItem'; import { SwitchDeviceModal } from './SwitchDeviceModal'; +import { BluetoothConnect } from '../../../components/suite/bluetooth/BluetoothConnect'; export const SwitchDevice = ({ onCancel }: ForegroundAppProps) => { + const [isBluetoothMode, setIsBluetoothMode] = useState(false); + const selectedDevice = useSelector(selectSelectedDevice); const devices = useSelector(selectDevices); @@ -26,17 +31,27 @@ export const SwitchDevice = ({ onCancel }: ForegroundAppProps) => { return ( - - {sortedDevices.map((device, index) => ( - - ))} - + {isBluetoothMode ? ( + setIsBluetoothMode(false)} uiMode="card" /> + ) : ( + + {sortedDevices.map((device, index) => ( + + ))} + + + )} ); }; diff --git a/packages/transport-test/package.json b/packages/transport-test/package.json index e8bc7f282e6..9a98460e8ca 100644 --- a/packages/transport-test/package.json +++ b/packages/transport-test/package.json @@ -13,7 +13,7 @@ "test:e2e:new-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=true yarn test:e2e:bridge", "test:e2e:new-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=true yarn test:e2e:bridge", "build:e2e:api:node": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.node.js --platform=node --target=node18 --external:usb", - "build:e2e:api:browser": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.browser.js --platform=browser --external:usb && cp e2e/ui/api.test.html e2e/dist/index.html", + "build:e2e:api:browser": "yarn esbuild ./e2e/api/api.test.ts --bundle --log-level=verbose --outfile=./e2e/dist/api.test.browser.js --platform=browser --external:usb && cp e2e/ui/api.test.html e2e/dist/index.html", "test:e2e:api:node:hw": "yarn build:e2e:api:node && node ./e2e/dist/api.test.node.js", "test:e2e:api:browser:hw": "yarn build:e2e:api:browser && npx http-serve ./e2e/dist" },