diff --git a/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss b/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss
index 148d7b4f99..1831f13f93 100644
--- a/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss
+++ b/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss
@@ -1,3 +1,5 @@
+@import '../../styles/mixin.scss';
+
.cellInfoDialog {
min-width: 650px;
}
@@ -270,3 +272,7 @@
top: 4px;
cursor: pointer;
}
+
+.notice {
+ @include dialog-copy-animation;
+}
diff --git a/packages/neuron-ui/src/components/CellInfoDialog/index.tsx b/packages/neuron-ui/src/components/CellInfoDialog/index.tsx
index 20bc08a669..e75da3363a 100644
--- a/packages/neuron-ui/src/components/CellInfoDialog/index.tsx
+++ b/packages/neuron-ui/src/components/CellInfoDialog/index.tsx
@@ -1,11 +1,14 @@
-import React, { useMemo, useState } from 'react'
+import React, { useCallback, useMemo, useState } from 'react'
import Dialog from 'widgets/Dialog'
-import { calculateUsedCapacity, shannonToCKBFormatter } from 'utils'
+import { calculateUsedCapacity, getExplorerUrl, shannonToCKBFormatter, truncateMiddle, useCopy } from 'utils'
import { useTranslation } from 'react-i18next'
import Tabs from 'widgets/Tabs'
import { type TFunction } from 'i18next'
import { Script } from '@ckb-lumos/base'
import Switch from 'widgets/Switch'
+import { Copy, ExplorerIcon } from 'widgets/Icons/icon'
+import Alert from 'widgets/Alert'
+import { openExternal } from 'services/remote'
import styles from './cellInfoDialog.module.scss'
type ScriptRenderType = 'table' | 'raw'
@@ -108,13 +111,26 @@ const useTabs = ({ t, output }: { t: TFunction; output?: State.DetailedOutput })
}
}
-const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: State.DetailedOutput }) => {
+const CellInfoDialog = ({
+ onCancel,
+ output,
+ isMainnet,
+}: {
+ onCancel: () => void
+ output?: State.DetailedOutput
+ isMainnet: boolean
+}) => {
const [t] = useTranslation()
const { tabs, currentTab, setCurrentTab, scriptRenderType, setScriptRenderType } = useTabs({
t,
output,
})
+ const { copied, copyTimes, onCopy } = useCopy()
+ const onOpenTx = useCallback(() => {
+ const explorerUrl = getExplorerUrl(isMainnet)
+ openExternal(`${explorerUrl}/transaction/${output?.outPoint.txHash}`)
+ }, [isMainnet, output?.outPoint.txHash])
if (!output) {
return null
}
@@ -124,6 +140,13 @@ const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: S
title={
{t('cell-manage.cell-detail-dialog.title')}
+
+ {t('cell-manage.cell-detail-dialog.transaction-hash')}
+ :
+ {truncateMiddle(output.outPoint.txHash, 10, 10)}
+ onCopy(output.outPoint.txHash)} />
+
+
}
onCancel={onCancel}
@@ -149,6 +172,11 @@ const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: S
) : null}
+ {copied ? (
+
+ {t('common.copied')}
+
+ ) : null}
)
}
diff --git a/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss b/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss
index 14ed375b5a..c286a5007a 100644
--- a/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss
+++ b/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss
@@ -163,9 +163,9 @@
@include checkbox;
.multiActions {
- position: absolute;
+ position: fixed;
bottom: 24px;
- left: 50%;
+ left: calc(50% + 80px);
transform: translateX(-50%);
padding: 12px 40px 12px 40px;
border-radius: 40px;
@@ -235,4 +235,22 @@
margin-right: 4px;
}
}
+
+ .hardWalletImg {
+ width: 88px;
+ height: 88px;
+ margin: 16px 0 24px 0;
+ }
+
+ .lockActions {
+ margin-top: 24px;
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ }
+
+ .hardwalletErr {
+ justify-content: center;
+ margin-top: 12px;
+ }
}
diff --git a/packages/neuron-ui/src/components/CellManagement/hooks.ts b/packages/neuron-ui/src/components/CellManagement/hooks.ts
index 9076750ece..e38c6cec29 100644
--- a/packages/neuron-ui/src/components/CellManagement/hooks.ts
+++ b/packages/neuron-ui/src/components/CellManagement/hooks.ts
@@ -1,12 +1,20 @@
+import { CkbAppNotFoundException, DeviceNotFoundException } from 'exceptions'
+import { TFunction } from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
+ connectDevice,
+ getDeviceCkbAppVersion,
+ getDevices,
getLiveCells,
+ getPlatform,
updateLiveCellsLocalInfo,
updateLiveCellsLockStatus as updateLiveCellsLockStatusAPI,
+ updateWallet,
} from 'services/remote'
+import { ControllerResponse } from 'services/remote/remoteApiWrapper'
import { AppActions, useDispatch } from 'states'
-import { LockScriptCategory, RoutePath, TypeScriptCategory, isSuccessResponse, outPointToStr } from 'utils'
+import { ErrorCode, LockScriptCategory, RoutePath, TypeScriptCategory, isSuccessResponse, outPointToStr } from 'utils'
import { SortType } from 'widgets/Table'
const cellTypeOrder: Record = {
@@ -204,6 +212,7 @@ export const useAction = ({
resetPassword,
setError,
password,
+ verifyDeviceStatus,
}: {
liveCells: State.LiveCellWithLocalInfo[]
currentPageLiveCells: State.LiveCellWithLocalInfo[]
@@ -212,6 +221,7 @@ export const useAction = ({
resetPassword: () => void
setError: (error: string) => void
password: string
+ verifyDeviceStatus: () => Promise
}) => {
const dispatch = useDispatch()
const navigate = useNavigate()
@@ -219,7 +229,7 @@ export const useAction = ({
const [operateCells, setOperateCells] = useState([])
const [loading, setLoading] = useState(false)
const onOpenActionDialog = useCallback(
- (e: React.SyntheticEvent) => {
+ async (e: React.SyntheticEvent) => {
e.stopPropagation()
const { action: curAction, index } = e.currentTarget.dataset as { action: Actions; index: string }
if (!curAction || index === undefined || !currentPageLiveCells[+index]) return
@@ -227,24 +237,27 @@ export const useAction = ({
setOperateCells([operateCell])
setAction(curAction)
resetPassword()
+ await verifyDeviceStatus()
},
[currentPageLiveCells, setOperateCells, dispatch, navigate]
)
const onMultiAction = useCallback(
- (e: React.SyntheticEvent) => {
+ async (e: React.SyntheticEvent) => {
e.stopPropagation()
const { action: curAction } = e.currentTarget.dataset as { action: Actions }
if (!curAction || !selectedOutPoints.size) return
setOperateCells(liveCells.filter(v => selectedOutPoints.has(outPointToStr(v.outPoint))))
setAction(curAction)
resetPassword()
+ await verifyDeviceStatus()
},
[liveCells, selectedOutPoints, setOperateCells, dispatch, navigate]
)
- const onActionConfirm = useCallback(() => {
+ const onActionConfirm = useCallback(async () => {
switch (action) {
case 'lock':
case 'unlock':
+ if (!(await verifyDeviceStatus())) return
setLoading(true)
updateLiveCellsLockStatus({
outPoints: operateCells.map(v => v.outPoint),
@@ -362,3 +375,124 @@ export const usePassword = () => {
resetPassword,
}
}
+
+export const useHardWallet = ({ wallet, t }: { wallet: State.WalletIdentity; t: TFunction }) => {
+ const isWin32 = useMemo(() => {
+ return getPlatform() === 'win32'
+ }, [])
+ const [error, setError] = useState()
+ const isNotAvailable = useMemo(() => {
+ return error === ErrorCode.DeviceNotFound || error === ErrorCode.CkbAppNotFound
+ }, [error])
+
+ const [deviceInfo, setDeviceInfo] = useState(wallet.device)
+ const [isReconnecting, setIsReconnecting] = useState(false)
+
+ const ensureDeviceAvailable = useCallback(
+ async (device: State.DeviceInfo) => {
+ try {
+ const connectionRes = await connectDevice(device)
+ let { descriptor } = device
+ if (!isSuccessResponse(connectionRes)) {
+ // for win32, opening or closing the ckb app changes the HID descriptor(deviceInfo),
+ // so if we can't connect to the device, we need to re-search device automatically.
+ // for unix, the descriptor never changes unless user plugs the device into another USB port,
+ // in that case, mannauly re-search device one time will do.
+ if (isWin32) {
+ setIsReconnecting(true)
+ const devicesRes = await getDevices(device)
+ setIsReconnecting(false)
+ if (isSuccessResponse(devicesRes) && Array.isArray(devicesRes.result) && devicesRes.result.length > 0) {
+ const [updatedDeviceInfo] = devicesRes.result
+ descriptor = updatedDeviceInfo.descriptor
+ setDeviceInfo(updatedDeviceInfo)
+ } else {
+ throw new DeviceNotFoundException()
+ }
+ } else {
+ throw new DeviceNotFoundException()
+ }
+ }
+
+ // getDeviceCkbAppVersion will halt forever while in win32 sleep mode.
+ const ckbVersionRes = await Promise.race([
+ getDeviceCkbAppVersion(descriptor),
+ new Promise((_, reject) => {
+ setTimeout(() => reject(), 1000)
+ }),
+ ]).catch(() => {
+ return { status: ErrorCode.DeviceInSleep }
+ })
+
+ if (!isSuccessResponse(ckbVersionRes)) {
+ if (ckbVersionRes.status !== ErrorCode.DeviceInSleep) {
+ throw new CkbAppNotFoundException()
+ } else {
+ throw new DeviceNotFoundException()
+ }
+ }
+ setError(undefined)
+ return true
+ } catch (err) {
+ if (err instanceof CkbAppNotFoundException || err instanceof DeviceNotFoundException) {
+ setError(err.code)
+ }
+ return false
+ }
+ },
+ [isWin32]
+ )
+
+ const reconnect = useCallback(async () => {
+ if (!deviceInfo) return
+ setError(undefined)
+ setIsReconnecting(true)
+ try {
+ const res = await getDevices(deviceInfo)
+ if (isSuccessResponse(res) && Array.isArray(res.result) && res.result.length > 0) {
+ const [device] = res.result
+ setDeviceInfo(device)
+ if (device.descriptor !== deviceInfo.descriptor) {
+ await updateWallet({
+ id: wallet.id,
+ device,
+ })
+ }
+ await ensureDeviceAvailable(device)
+ } else {
+ setError(ErrorCode.DeviceNotFound)
+ }
+ } catch (err) {
+ setError(ErrorCode.DeviceNotFound)
+ } finally {
+ setIsReconnecting(false)
+ }
+ }, [deviceInfo, ensureDeviceAvailable, wallet.id])
+
+ const verifyDeviceStatus = useCallback(async () => {
+ if (deviceInfo) {
+ return ensureDeviceAvailable(deviceInfo)
+ }
+ return true
+ }, [ensureDeviceAvailable, deviceInfo])
+
+ const errorMessage = useMemo(() => {
+ switch (error) {
+ case ErrorCode.DeviceNotFound:
+ return t('hardware-verify-address.status.disconnect')
+ case ErrorCode.CkbAppNotFound:
+ return t(CkbAppNotFoundException.message)
+ default:
+ return error
+ }
+ }, [error, t])
+ return {
+ deviceInfo,
+ isReconnecting,
+ isNotAvailable,
+ reconnect,
+ verifyDeviceStatus,
+ errorMessage,
+ setError,
+ }
+}
diff --git a/packages/neuron-ui/src/components/CellManagement/index.tsx b/packages/neuron-ui/src/components/CellManagement/index.tsx
index e8752c869c..48f94566e2 100644
--- a/packages/neuron-ui/src/components/CellManagement/index.tsx
+++ b/packages/neuron-ui/src/components/CellManagement/index.tsx
@@ -13,6 +13,7 @@ import {
outPointToStr,
LockScriptCategory,
getLockTimestamp,
+ isMainnet as isMainnetUtil,
} from 'utils'
import { HIDE_BALANCE } from 'utils/const'
import Tooltip from 'widgets/Tooltip'
@@ -23,7 +24,10 @@ import TextField from 'widgets/TextField'
import { useSearchParams } from 'react-router-dom'
import CellInfoDialog from 'components/CellInfoDialog'
import { computeScriptHash } from '@ckb-lumos/base/lib/utils'
-import { Actions, useAction, useLiveCells, usePassword, useSelect } from './hooks'
+import Hardware from 'widgets/Icons/Hardware.png'
+import Button from 'widgets/Button'
+import Alert from 'widgets/Alert'
+import { Actions, useAction, useHardWallet, useLiveCells, usePassword, useSelect } from './hooks'
import styles from './cellManagement.module.scss'
const getColumns = ({
@@ -214,11 +218,14 @@ const getColumns = ({
const CellManagement = () => {
const {
app: { epoch },
- wallet: { balance = '' },
+ wallet,
chain: {
syncState: { bestKnownBlockTimestamp },
+ networkID,
},
+ settings: { networks },
} = useGlobalState()
+ const isMainnet = useMemo(() => isMainnetUtil(networks, networkID), [networks, networkID])
const [t] = useTranslation()
const [searchParams] = useSearchParams()
const breadPages = useMemo(() => [{ label: t('cell-manage.title') }], [t])
@@ -236,6 +243,17 @@ const CellManagement = () => {
return liveCells.slice(pageSize * (pageNo - 1), pageSize * pageNo)
}, [pageNo, pageSize, liveCells])
const { onSelect, onSelectAll, isAllSelected, selectedOutPoints, hasSelectLocked, isAllLocked } = useSelect(liveCells)
+ const {
+ isReconnecting,
+ isNotAvailable,
+ reconnect,
+ verifyDeviceStatus,
+ errorMessage: hardwalletError,
+ setError: setHardwalletError,
+ } = useHardWallet({
+ wallet,
+ t,
+ })
const { password, error, onPasswordChange, setError, resetPassword } = usePassword()
const { action, operateCells, onActionCancel, onActionConfirm, onOpenActionDialog, onMultiAction, loading } =
useAction({
@@ -243,9 +261,10 @@ const CellManagement = () => {
currentPageLiveCells,
updateLiveCellsLockStatus,
selectedOutPoints,
- setError,
+ setError: wallet.device ? setHardwalletError : setError,
resetPassword,
password,
+ verifyDeviceStatus,
})
const columns = useMemo(
() =>
@@ -285,7 +304,7 @@ const CellManagement = () => {
{showBalance ? : }
{t('cell-manage.wallet-balance')}
- {`${showBalance ? shannonToCKBFormatter(balance) : HIDE_BALANCE} CKB`}
+ {`${showBalance ? shannonToCKBFormatter(wallet.balance) : HIDE_BALANCE} CKB`}
}
@@ -329,39 +348,82 @@ const CellManagement = () => {
: undefined
}
onCancel={onActionCancel}
+ isMainnet={isMainnet}
/>
-
+ {wallet.device ? (
+
+ ) : (
+
+ )}