From 6dbf7dac5920a82cb5eb808a66a0e110a95cc403 Mon Sep 17 00:00:00 2001 From: yanguoyu <841185308@qq.com> Date: Fri, 29 Dec 2023 11:39:00 +0800 Subject: [PATCH 1/2] feat: Show tip for first sync and show dialog when disk is not enough --- ...valid_target.yml => update_wallet_env.yml} | 10 +- package.json | 2 +- .../DataSetting/dataSetting.module.scss | 68 ----------- .../src/components/DataSetting/hooks.ts | 47 ++------ .../src/components/DataSetting/index.tsx | 70 ++---------- .../src/components/Overview/index.tsx | 48 +++++++- .../components/Overview/overview.module.scss | 5 + .../neuron-ui/src/containers/Main/hooks.ts | 62 +++++++++- .../neuron-ui/src/containers/Main/index.tsx | 39 ++++++- .../src/containers/Main/main.module.scss | 5 + packages/neuron-ui/src/locales/en.json | 12 +- packages/neuron-ui/src/locales/es.json | 12 +- packages/neuron-ui/src/locales/fr.json | 12 +- packages/neuron-ui/src/locales/zh-tw.json | 12 +- packages/neuron-ui/src/locales/zh.json | 12 +- packages/neuron-ui/src/services/remote/app.ts | 6 +- .../src/services/remote/remoteApiWrapper.ts | 2 + packages/neuron-ui/src/services/subjects.ts | 4 +- .../DataPathDialog/dataPathDialog.module.scss | 62 ++++++++++ .../src/widgets/DataPathDialog/index.tsx | 73 ++++++++++++ .../neuron-ui/src/widgets/Icons/FirstSync.png | Bin 0 -> 18917 bytes .../src/widgets/Icons/NoDiskSpaceWarn.png | Bin 0 -> 17075 bytes .../widgets/MigrateCkbDataDialog/index.tsx | 106 ++++++++++++++++++ .../migrateCkbDataDialog.module.scss | 69 ++++++++++++ packages/neuron-wallet/.env | 3 +- packages/neuron-wallet/src/controllers/api.ts | 55 +++++++-- .../src/controllers/app/subscribe.ts | 6 + .../src/models/subjects/no-disk-space.ts | 5 + .../neuron-wallet/src/services/ckb-runner.ts | 13 ++- packages/neuron-wallet/src/services/node.ts | 5 + .../neuron-wallet/src/services/settings.ts | 13 +++ .../tests/services/ckb-runner.test.ts | 4 +- .../neuron-wallet/tests/services/node.test.ts | 33 ++++++ .../tests/services/setting.test.ts | 3 + scripts/update-valid-target.js | 40 ------- scripts/update-wallet-env.js | 56 +++++++++ 36 files changed, 726 insertions(+), 248 deletions(-) rename .github/workflows/{update_valid_target.yml => update_wallet_env.yml} (89%) create mode 100644 packages/neuron-ui/src/widgets/DataPathDialog/dataPathDialog.module.scss create mode 100644 packages/neuron-ui/src/widgets/DataPathDialog/index.tsx create mode 100644 packages/neuron-ui/src/widgets/Icons/FirstSync.png create mode 100644 packages/neuron-ui/src/widgets/Icons/NoDiskSpaceWarn.png create mode 100644 packages/neuron-ui/src/widgets/MigrateCkbDataDialog/index.tsx create mode 100644 packages/neuron-ui/src/widgets/MigrateCkbDataDialog/migrateCkbDataDialog.module.scss create mode 100644 packages/neuron-wallet/src/models/subjects/no-disk-space.ts delete mode 100644 scripts/update-valid-target.js create mode 100644 scripts/update-wallet-env.js diff --git a/.github/workflows/update_valid_target.yml b/.github/workflows/update_wallet_env.yml similarity index 89% rename from .github/workflows/update_valid_target.yml rename to .github/workflows/update_wallet_env.yml index c5c40702b8..bda340a1ba 100644 --- a/.github/workflows/update_valid_target.yml +++ b/.github/workflows/update_wallet_env.yml @@ -14,13 +14,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - branch: 'chore-update-valid-target/${{github.ref_name}}' + branch: 'chore-update-wallet-env/${{github.ref_name}}' sha: '${{ github.event.create.head.sha }}' - name: Checkout uses: actions/checkout@v4 with: - ref: 'chore-update-valid-target/${{github.ref_name}}' + ref: 'chore-update-wallet-env/${{github.ref_name}}' - name: Setup Node uses: actions/setup-node@v4 @@ -29,7 +29,7 @@ jobs: - name: Write env file run: | - npm run update:valid-target + npm run update:wallet-env - name: Commit env file uses: actions/github-script@v7 @@ -41,7 +41,7 @@ jobs: const fs = require('node:fs') const { BASE, HEAD } = process.env const envFilePath = 'packages/neuron-wallet/.env' - const destinationBranch = `chore-update-valid-target/${BASE}` + const destinationBranch = `chore-update-wallet-env/${BASE}` const { data } = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, @@ -63,7 +63,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BASE: ${{github.ref}} - HEAD: chore-update-valid-target/${{github.ref_name}} + HEAD: chore-update-wallet-env/${{github.ref_name}} REPO: ${{github.repository}} with: script: | diff --git a/package.json b/package.json index d52255be37..1318cbb1a7 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "lint": "lerna run --stream lint", "postinstall": "husky install", "db:chain": "node ./node_modules/.bin/typeorm", - "update:valid-target": "node ./scripts/update-valid-target.js", + "update:wallet-env": "node ./scripts/update-wallet-env.js", "update:neuron-compatible": "node ./scripts/add-neuron-version-in-compatibility-table.js", "update:client-versions": "node ./scripts/update-ckb-client-versions.js" }, diff --git a/packages/neuron-ui/src/components/DataSetting/dataSetting.module.scss b/packages/neuron-ui/src/components/DataSetting/dataSetting.module.scss index 6bdb9c8095..e260687f30 100644 --- a/packages/neuron-ui/src/components/DataSetting/dataSetting.module.scss +++ b/packages/neuron-ui/src/components/DataSetting/dataSetting.module.scss @@ -160,71 +160,3 @@ word-break: normal; white-space: normal; } - -.dialogContainer { - max-width: 700px; - color: var(--main-text-color); - font-size: 14px; - line-height: 24px; - - .attention { - display: flex; - justify-content: center; - align-items: center; - padding: 8px; - width: 80%; - margin: 32px auto 0; - background: var(--warn-background-color); - border: 1px solid rgba(252, 136, 0, 0.2); - border-radius: 4px; - font-weight: 500; - font-size: 12px; - line-height: 24px; - color: var(--warn-text-color); - - @media (prefers-color-scheme: dark) { - border: none; - } - - svg { - margin-right: 4px; - width: 14px; - } - } -} - -.footer { - padding: 24px 0; - @include dialog-footer; - --footer-background-color: #ecf9f0; - @media (prefers-color-scheme: dark) { - --footer-background-color: #2d3534; - } - - .footerBtn { - padding: 0 20px; - background: var(--footer-background-color); - color: var(--primary-color); - &:not([disabled]) { - &:hover, - &:focus { - background-color: var(--footer-background-color); - color: color-mix(in srgb, #000000 20%, var(--primary-color)); - } - - &:active { - background-color: var(--footer-background-color); - color: color-mix(in srgb, #000000 20%, var(--primary-color)); - } - } - &:last-of-type { - margin-left: 24px; - } - svg { - g, - path { - fill: var(--primary-color); - } - } - } -} diff --git a/packages/neuron-ui/src/components/DataSetting/hooks.ts b/packages/neuron-ui/src/components/DataSetting/hooks.ts index 586ea9858f..e945e4823d 100644 --- a/packages/neuron-ui/src/components/DataSetting/hooks.ts +++ b/packages/neuron-ui/src/components/DataSetting/hooks.ts @@ -1,24 +1,15 @@ -import React, { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - getCkbNodeDataPath, - invokeShowOpenDialog, - startProcessMonitor, - stopProcessMonitor, - setCkbNodeDataPath, -} from 'services/remote' +import { getCkbNodeDataPath, invokeShowOpenDialog, startProcessMonitor, stopProcessMonitor } from 'services/remote' import { isSuccessResponse } from 'utils' const type = 'ckb' export const useDataPath = (network?: State.Network) => { const [t] = useTranslation() - const [isSaving, setIsSaving] = useState(false) - const [savingType, setSavingType] = useState() - const [prevPath, setPrevPath] = useState() - const [currentPath, setCurrentPath] = useState() + const [prevPath, setPrevPath] = useState('') + const [currentPath, setCurrentPath] = useState('') const [isDialogOpen, setIsDialogOpen] = useState(false) - const [faidMessage, setFaidMessage] = useState('') useEffect(() => { getCkbNodeDataPath().then(res => { @@ -50,29 +41,11 @@ export const useDataPath = (network?: State.Network) => { }) }, [setIsDialogOpen, type]) const onConfirm = useCallback( - (e: React.MouseEvent) => { - setFaidMessage('') - const { dataset } = e.currentTarget - setIsSaving(true) - setSavingType(dataset.syncType) - setCkbNodeDataPath({ - dataPath: currentPath!, - clearCache: type === 'ckb' && dataset?.resync === 'true', - }) - .then(res => { - if (isSuccessResponse(res)) { - setPrevPath(currentPath) - setIsDialogOpen(false) - } else { - setFaidMessage(typeof res.message === 'string' ? res.message : res.message.content!) - } - }) - .finally(() => { - setIsSaving(false) - setSavingType(null) - }) + (dataPath: string) => { + setPrevPath(dataPath) + setIsDialogOpen(false) }, - [currentPath, setIsDialogOpen, setPrevPath] + [setIsDialogOpen, setPrevPath] ) return { prevPath, @@ -80,11 +53,7 @@ export const useDataPath = (network?: State.Network) => { onSetting, onCancel, onConfirm, - isSaving, - savingType, isDialogOpen, - faidMessage, - setFaidMessage, } } diff --git a/packages/neuron-ui/src/components/DataSetting/index.tsx b/packages/neuron-ui/src/components/DataSetting/index.tsx index 19448cccd6..6c284eff8c 100644 --- a/packages/neuron-ui/src/components/DataSetting/index.tsx +++ b/packages/neuron-ui/src/components/DataSetting/index.tsx @@ -1,14 +1,12 @@ -import React, { useCallback, useRef, useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Button from 'widgets/Button' import ClearCache from 'components/ClearCache' import { useDispatch, useState as useGlobalState } from 'states' import { shell } from 'electron' import Tooltip from 'widgets/Tooltip' -import Dialog from 'widgets/Dialog' -import AlertDialog from 'widgets/AlertDialog' import { NetworkType } from 'utils/const' import { Attention } from 'widgets/Icons/icon' +import MigrateCkbDataDialog from 'widgets/MigrateCkbDataDialog' import { useDataPath } from './hooks' import styles from './dataSetting.module.scss' @@ -40,24 +38,12 @@ const PathItem = ({ const DataSetting = () => { const dispatch = useDispatch() const [t] = useTranslation() - const resyncRef = useRef(null) const { chain: { networkID }, settings: { networks = [] }, } = useGlobalState() const network = useMemo(() => networks.find(n => n.id === networkID), [networkID, networks]) - const { - onSetting, - prevPath, - currentPath, - isDialogOpen, - onCancel, - onConfirm, - isSaving, - savingType, - faidMessage, - setFaidMessage, - } = useDataPath(network) + const { onSetting, prevPath, currentPath, isDialogOpen, onCancel, onConfirm } = useDataPath(network) const openPath = useCallback(() => { if (prevPath) { @@ -100,50 +86,12 @@ const DataSetting = () => { - - -
-
{t('settings.data.remove-ckb-data-tip', { prevPath, currentPath })}
-
- - {t('settings.data.resync-ckb-node-describe')} -
- -
-
-
-
- - setFaidMessage('')} - onOk={() => { - resyncRef?.current?.click() - }} + ) diff --git a/packages/neuron-ui/src/components/Overview/index.tsx b/packages/neuron-ui/src/components/Overview/index.tsx index c637c9aae3..c9984eb15b 100644 --- a/packages/neuron-ui/src/components/Overview/index.tsx +++ b/packages/neuron-ui/src/components/Overview/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useState as useGlobalState, useDispatch, updateTransactionList } from 'states' import { shannonToCKBFormatter, @@ -34,6 +34,9 @@ import CopyZone from 'widgets/CopyZone' import { HIDE_BALANCE } from 'utils/const' import TransactionType from 'components/TransactionType' import { getLockedBalance } from 'services/remote/cellManage' +import { getFirstSyncInfo, setCkbNodeDataPath, startSync } from 'services/remote' +import FirstSync from 'widgets/Icons/FirstSync.png' +import DataPathDialog from 'widgets/DataPathDialog' import styles from './overview.module.scss' const { PAGE_SIZE, CONFIRMATION_THRESHOLD } = CONSTANTS @@ -128,6 +131,39 @@ const Overview = () => { } }) }, []) + const [isFirstSyncDialogShow, setIsFirstSyncDialogShow] = useState(false) + const [ckbNodeDataInfo, setCkbNodeDataInfo] = useState<{ size?: number; dataPath?: string } | undefined>() + useEffect(() => { + getFirstSyncInfo().then(res => { + if (isSuccessResponse(res) && res.result) { + setIsFirstSyncDialogShow(res.result.isFirstSync) + setCkbNodeDataInfo({ size: res.result.needSize, dataPath: res.result.ckbNodeDataPath }) + } + }) + }, []) + const onChangeDataPath = useCallback((newDataPath: string) => { + setCkbNodeDataPath({ + dataPath: newDataPath, + onlySetPath: true, + }).then(res => { + if (isSuccessResponse(res)) { + setCkbNodeDataInfo(v => ({ + size: v?.size, + dataPath: newDataPath, + })) + } + }) + }, []) + const onCancel = useCallback(() => { + setIsFirstSyncDialogShow(false) + }, []) + const onStartSync = useCallback(() => { + startSync().then(res => { + if (isSuccessResponse(res)) { + setIsFirstSyncDialogShow(false) + } + }) + }, []) return (
@@ -264,6 +300,16 @@ const Overview = () => { {showReceive ? setShowReceive(false)} /> : null} {showAddressBook ? setShowAddressBook(false)} /> : null} + } + text={} + dataPath={ckbNodeDataInfo?.dataPath ?? ''} + onChangeDataPath={onChangeDataPath} + onCancel={onCancel} + onConfirm={onStartSync} + /> ) } diff --git a/packages/neuron-ui/src/components/Overview/overview.module.scss b/packages/neuron-ui/src/components/Overview/overview.module.scss index 5633c78e32..4241522aa1 100644 --- a/packages/neuron-ui/src/components/Overview/overview.module.scss +++ b/packages/neuron-ui/src/components/Overview/overview.module.scss @@ -210,3 +210,8 @@ } } } + +.firstSyncIcon { + width: 64px; + height: 64px; +} diff --git a/packages/neuron-ui/src/containers/Main/hooks.ts b/packages/neuron-ui/src/containers/Main/hooks.ts index e8c04e5a65..9938e20ff3 100644 --- a/packages/neuron-ui/src/containers/Main/hooks.ts +++ b/packages/neuron-ui/src/containers/Main/hooks.ts @@ -10,7 +10,14 @@ import { showGlobalAlertDialog, } from 'states/stateProvider/actionCreators' -import { getCurrentWallet, getWinID, setCurrentNetwork, startNodeIgnoreExternal } from 'services/remote' +import { + getCkbNodeDataPath, + getCurrentWallet, + getWinID, + setCurrentNetwork, + startNodeIgnoreExternal, + startSync, +} from 'services/remote' import { DataUpdate as DataUpdateSubject, NetworkList as NetworkListSubject, @@ -19,6 +26,7 @@ import { SyncState as SyncStateSubject, Command as CommandSubject, ShowGlobalDialog as ShowGlobalDialogSubject, + NoDiskSpace, } from 'services/subjects' import { ckbCore, getTipHeader } from 'services/chain' import { @@ -327,6 +335,58 @@ export const useSubscription = ({ ]) } +export const useNoDiskSpace = (navigate: NavigateFunction) => { + const [isNoDiskSpaceDialogShow, setIsNoDiskSpaceDialogShow] = useState(false) + const [isMigrateDataDialogShow, setIsMigrateDataDialogShow] = useState(false) + const [oldCkbDataPath, setOldCkbDataPath] = useState('') + const [newCkbDataPath, setNewCkbDataPath] = useState('') + useEffect(() => { + const noDiskSpaceSubject = NoDiskSpace.subscribe(params => { + navigate(RoutePath.Overview) + setIsNoDiskSpaceDialogShow(params) + getCkbNodeDataPath().then(res => { + if (isSuccessResponse(res)) { + setOldCkbDataPath(res.result!) + } + }) + }) + return () => { + noDiskSpaceSubject.unsubscribe() + } + }, [navigate]) + const onConfirm = useCallback(() => { + startSync().then(res => { + if (isSuccessResponse(res)) { + setIsNoDiskSpaceDialogShow(false) + } + }) + }, []) + const onMigrate = useCallback(() => { + setIsMigrateDataDialogShow(true) + }, []) + const onConfirmMigrate = useCallback((dataPath: string) => { + setIsMigrateDataDialogShow(false) + setIsNoDiskSpaceDialogShow(false) + setOldCkbDataPath(dataPath) + }, []) + return { + isNoDiskSpaceDialogShow, + setNewCkbDataPath, + oldCkbDataPath, + newCkbDataPath, + onCancel: useCallback(() => { + setIsNoDiskSpaceDialogShow(false) + }, []), + onConfirm, + isMigrateDataDialogShow, + onMigrate, + onCloseMigrateDialog: useCallback(() => { + setIsMigrateDataDialogShow(false) + }, []), + onConfirmMigrate, + } +} + export const useCheckNode = (networks: State.Network[], networkID: string) => { const [isSwitchNetworkShow, setIsSwitchNetworkShow] = useState(false) const [selectNetwork, setSelectNetwork] = useState(networks[0]?.id) diff --git a/packages/neuron-ui/src/containers/Main/index.tsx b/packages/neuron-ui/src/containers/Main/index.tsx index 9d60f521aa..8078c1d5a0 100644 --- a/packages/neuron-ui/src/containers/Main/index.tsx +++ b/packages/neuron-ui/src/containers/Main/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react' import { useNavigate, useLocation, Outlet } from 'react-router-dom' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useState as useGlobalState, useDispatch, dismissGlobalAlertDialog } from 'states' import { useMigrate, useOnDefaultContextMenu, useOnLocaleChange } from 'utils' import AlertDialog from 'widgets/AlertDialog' @@ -9,8 +9,11 @@ import Button from 'widgets/Button' import RadioGroup from 'widgets/RadioGroup' import NetworkEditorDialog from 'components/NetworkEditorDialog' import { AddSimple } from 'widgets/Icons/icon' +import DataPathDialog from 'widgets/DataPathDialog' +import NoDiskSpaceWarn from 'widgets/Icons/NoDiskSpaceWarn.png' +import MigrateCkbDataDialog from 'widgets/MigrateCkbDataDialog' import styles from './main.module.scss' -import { useSubscription, useSyncChainData, useOnCurrentWalletChange, useCheckNode } from './hooks' +import { useSubscription, useSyncChainData, useOnCurrentWalletChange, useCheckNode, useNoDiskSpace } from './hooks' const MainContent = () => { const navigate = useNavigate() @@ -70,6 +73,19 @@ const MainContent = () => { dismissGlobalAlertDialog()(dispatch) }, [dispatch]) const { isMigrateDialogShow, onCancel, onBackUp, onConfirm } = useMigrate() + const { + isNoDiskSpaceDialogShow, + oldCkbDataPath, + newCkbDataPath, + setNewCkbDataPath, + onCancel: onCloseNoDiskDialog, + onConfirm: onContinueSync, + isMigrateDataDialogShow, + onMigrate, + onCloseMigrateDialog, + onConfirmMigrate, + } = useNoDiskSpace(navigate) + const needConfirm = newCkbDataPath && newCkbDataPath !== oldCkbDataPath return (
@@ -139,6 +155,25 @@ const MainContent = () => { id="new" /> ) : null} + } + confirmText={ + needConfirm ? t('main.no-disk-space-dialog.migrate-data') : t('main.no-disk-space-dialog.continue-sync') + } + dataPath={newCkbDataPath || oldCkbDataPath} + text={} + onCancel={onCloseNoDiskDialog} + onConfirm={needConfirm ? onMigrate : onContinueSync} + onChangeDataPath={setNewCkbDataPath} + /> +
) } diff --git a/packages/neuron-ui/src/containers/Main/main.module.scss b/packages/neuron-ui/src/containers/Main/main.module.scss index 0e01fceaa4..7ff69e97c3 100644 --- a/packages/neuron-ui/src/containers/Main/main.module.scss +++ b/packages/neuron-ui/src/containers/Main/main.module.scss @@ -25,3 +25,8 @@ } } } + +.noDiskSpace { + width: 64px; + height: 64px; +} diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index 509ce2726a..1e934868cc 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -150,7 +150,9 @@ "send": "Send", "receive": "Receive", "locked": "Locked Amount", - "locked-balance": "Locked Balance" + "locked-balance": "Locked Balance", + "first-sync-notice": "The initial synchronization requires at least {{size}} GB of disk space.
And the data size will increase over time. It is recommended to choose a larger disk space to store the data.", + "start-sync": "Start Sync" }, "wizard": { "welcome-to-nervos-neuron": "Welcome to Neuron", @@ -534,7 +536,8 @@ "verification-failure": "Verification Failed", "back": "Back", "switch-to-light": "Switch to Light Mode", - "switch-to-dark": "Switch to Dark Mode" + "switch-to-dark": "Switch to Dark Mode", + "ckb-node-data-path": "CKB Node Data Path" }, "notification-panel": { "title": "Notifications" @@ -1143,6 +1146,11 @@ "body-tips-with-network": "\"Internal Node\" is reserved for the built-in CKB node, please select from the following network options or add a new one for the external node.", "add-network": "Add Network", "ignore-external-node": "Ignore external node" + }, + "no-disk-space-dialog": { + "tip": "Due to insufficient disk space, synchronization has been stopped.
Please allocate more disk space or migrate the data to another disk.", + "continue-sync": "Continue Sync", + "migrate-data": "Migrate Data" } }, "cell-manage": { diff --git a/packages/neuron-ui/src/locales/es.json b/packages/neuron-ui/src/locales/es.json index 1440ab833e..9308fdfa2f 100644 --- a/packages/neuron-ui/src/locales/es.json +++ b/packages/neuron-ui/src/locales/es.json @@ -149,7 +149,9 @@ "send": "Enviar", "receive": "Recibir", "locked": "Cantidad bloqueada", - "locked-balance": "Saldo bloqueado" + "locked-balance": "Saldo bloqueado", + "first-sync-notice": "La sincronización inicial requiere al menos {{size}} GB de espacio en disco,
Además, el tamaño de los datos aumentará con el tiempo, se recomienda seleccionar un espacio en disco más grande para almacenar los datos.", + "start-sync": "Iniciar sincronización" }, "wizard": { "welcome-to-nervos-neuron": "Bienvenido a Neuron", @@ -533,7 +535,8 @@ "verification-failure": "Falló la Verificación", "back": "Atrás", "switch-to-light": "Cambiar a modo claro", - "switch-to-dark": "Cambiar a modo oscuro" + "switch-to-dark": "Cambiar a modo oscuro", + "ckb-node-data-path": "CKB Ruta de los datos del nodo" }, "notification-panel": { "title": "Notificaciones" @@ -1138,6 +1141,11 @@ "body-tips-with-network": "\"Internal Node\" está reservado para el nodo CKB incorporado, seleccione una de las siguientes opciones de red o agregue una nueva para el nodo externo.", "add-network": "Agregar red", "ignore-external-node": "Ignorar nodo externo" + }, + "no-disk-space-dialog": { + "tip": "La sincronización se ha detenido debido a falta de espacio en disco.
Asigne más espacio en disco o migre los datos a otro disco.", + "continue-sync": "Continuar sincronización", + "migrate-data": "Migrar datos" } }, "cell-manage": { diff --git a/packages/neuron-ui/src/locales/fr.json b/packages/neuron-ui/src/locales/fr.json index abf5f0883c..f64b149e37 100644 --- a/packages/neuron-ui/src/locales/fr.json +++ b/packages/neuron-ui/src/locales/fr.json @@ -150,7 +150,9 @@ "send": "Envoyer", "receive": "Recevoir", "locked": "Montant verrouillé", - "locked-balance": "Solde verrouillé" + "locked-balance": "Solde verrouillé", + "first-sync-notice": "La synchronisation initiale nécessite au moins {{size}} Go d'espace disque, la taille des données augmentera avec le temps. Il est recommandé de choisir un espace disque plus important pour stocker les données.", + "start-sync": "Démarrer la synchronisation" }, "wizard": { "welcome-to-nervos-neuron": "Bienvenue sur Neuron", @@ -534,7 +536,8 @@ "verification-failure": "Échec de la vérification", "back": "Retour", "switch-to-light": "Passer en mode clair", - "switch-to-dark": "Passer en mode sombre" + "switch-to-dark": "Passer en mode sombre", + "ckb-node-data-path": "CKB Chemin des données du nœud" }, "notification-panel": { "title": "Notifications" @@ -1143,6 +1146,11 @@ "body-tips-with-network": "\"Noeud interne\" est réservé au noeud CKB intégré, veuillez sélectionner parmi les options réseau suivantes ou en ajouter une nouvelle pour le noeud externe.", "add-network": "Ajouter un réseau", "ignore-external-node": "Ignorer le noeud externe" + }, + "no-disk-space-dialog": { + "tip": "En raison d'un espace disque insuffisant, la synchronisation a été interrompue.
Veuillez allouer plus d'espace disque ou migrer les données vers un autre disque.", + "continue-sync": "Continuer la synchronisation", + "migrate-data": "Migration des données" } }, "cell-manage": { diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index cea843f0cc..ae9018d7db 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -144,7 +144,9 @@ "send": "轉賬", "receive": "收款", "locked": "已鎖定餘額", - "locked-balance": "已鎖定余額" + "locked-balance": "已鎖定余額", + "first-sync-notice": "首次同步數據至少需要 {{size}} GB的磁盤空間,
並且數據大小會隨著時間增長,建議選擇較大磁盤空間來存儲數據。", + "start-sync": "開始同步" }, "wizard": { "welcome-to-nervos-neuron": "歡迎使用 Neuron", @@ -528,7 +530,8 @@ "verification-failure": "驗證失敗", "back": "上一步", "switch-to-light": "開啟日間模式", - "switch-to-dark": "開啟夜間模式" + "switch-to-dark": "開啟夜間模式", + "ckb-node-data-path": "CKB 節點數據路徑" }, "notification-panel": { "title": "通知中心" @@ -1114,6 +1117,11 @@ "body-tips-with-network": "\"Internal Node\" 僅用於連接內置節點,請選擇以下網絡選項或添加新的網絡選項以連接外部節點。", "add-network": "添加網絡", "ignore-external-node": "忽略外部節點" + }, + "no-disk-space-dialog": { + "tip": "由於磁盤空間不足,同步已停止。
請分配更多磁盤空間或將數據遷移到其他磁盤。", + "continue-sync": "繼續同步", + "migrate-data": "遷移數據" } }, "cell-manage": { diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index 0146f9920d..1b770169a4 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -143,7 +143,9 @@ "send": "转账", "receive": "收款", "locked": "已锁定余额", - "locked-balance": "已锁定余额" + "locked-balance": "已锁定余额", + "first-sync-notice": "首次同步数据至少需要 {{size}} GB的磁盘空间,
并且数据大小会随着时间增长,建议选择较大磁盘空间来存储数据。", + "start-sync": "开始同步" }, "wizard": { "welcome-to-nervos-neuron": "欢迎使用 Neuron", @@ -527,7 +529,8 @@ "verification-failure": "验证失败", "back": "上一步", "switch-to-light": "开启日间模式", - "switch-to-dark": "开启夜间模式" + "switch-to-dark": "开启夜间模式", + "ckb-node-data-path": "CKB 节点数据路径" }, "notification-panel": { "title": "通知中心" @@ -1135,6 +1138,11 @@ "body-tips-with-network": "\"Internal Node\" 仅用于连接内置节点,请选择以下网络选项或添加新的网络选项以连接外部节点。", "add-network": "添加网络", "ignore-external-node": "忽略外部节点" + }, + "no-disk-space-dialog": { + "tip": "由于磁盘空间不足,同步已停止。
请分配更多磁盘空间或将数据迁移到其他磁盘。", + "continue-sync": "继续同步", + "migrate-data": "迁移数据" } }, "cell-manage": { diff --git a/packages/neuron-ui/src/services/remote/app.ts b/packages/neuron-ui/src/services/remote/app.ts index 1924b6331e..ce391b86dc 100644 --- a/packages/neuron-ui/src/services/remote/app.ts +++ b/packages/neuron-ui/src/services/remote/app.ts @@ -17,12 +17,16 @@ export const requestOpenInExplorer = remoteApi('handle-view-error') export const setLocale = remoteApi<(typeof LOCALES)[number]>('set-locale') export const getCkbNodeDataPath = remoteApi('get-ckb-node-data-path') -export const setCkbNodeDataPath = remoteApi<{ dataPath: string; clearCache?: boolean }, string>( +export const setCkbNodeDataPath = remoteApi<{ dataPath: string; clearCache?: boolean; onlySetPath?: boolean }, string>( 'set-ckb-node-data-path' ) export const stopProcessMonitor = remoteApi<'ckb'>('stop-process-monitor') export const startProcessMonitor = remoteApi<'ckb'>('start-process-monitor') export const startNodeIgnoreExternal = remoteApi('start-node-ignore-external') +export const getFirstSyncInfo = remoteApi( + 'get-first-sync-info' +) +export const startSync = remoteApi('start-sync') export type VerifyExternalCkbNodeRes = | { diff --git a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts index c9ab2831e6..528910baa7 100644 --- a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts +++ b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts @@ -50,6 +50,8 @@ type Action = | 'set-theme' | 'verify-external-ckb-node' | 'start-node-ignore-external' + | 'get-first-sync-info' + | 'start-sync' // Wallets | 'get-all-wallets' | 'get-current-wallet' diff --git a/packages/neuron-ui/src/services/subjects.ts b/packages/neuron-ui/src/services/subjects.ts index a9e36665c9..2fb4037ccb 100644 --- a/packages/neuron-ui/src/services/subjects.ts +++ b/packages/neuron-ui/src/services/subjects.ts @@ -31,7 +31,8 @@ const SubjectConstructor = ( | 'device-sign-index' | 'multisig-output-update' | 'migrate' - | 'show-global-dialog', + | 'show-global-dialog' + | 'no-disk-space', isMulti?: boolean ) => { return ipcRenderer @@ -69,6 +70,7 @@ export const SetLocale = SubjectConstructor<(typeof LOCALES)[number]>('set-local export const DeviceSignIndex = SubjectConstructor('device-sign-index') export const MultisigOutputUpdate = SubjectConstructor('multisig-output-update') export const Migrate = SubjectConstructor<'need-migrate' | 'migrating' | 'failed' | 'finish'>('migrate', true) +export const NoDiskSpace = SubjectConstructor('no-disk-space') export default { DataUpdate, diff --git a/packages/neuron-ui/src/widgets/DataPathDialog/dataPathDialog.module.scss b/packages/neuron-ui/src/widgets/DataPathDialog/dataPathDialog.module.scss new file mode 100644 index 0000000000..d6208d61e5 --- /dev/null +++ b/packages/neuron-ui/src/widgets/DataPathDialog/dataPathDialog.module.scss @@ -0,0 +1,62 @@ +.dialog { + min-width: 600px; +} + +.icon { + text-align: center; +} + +.text { + margin: 8px 0 28px 0; + color: var(--main-text-color); + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 24px; +} + +.pathContainer { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + background: var(--secondary-background-color); + border: 1px solid var(--divide-line-color); + border-radius: 8px; + button { + border: none; + background: transparent; + cursor: pointer; + padding: 0 16px; + height: 100%; + } + + .btn { + color: var(--secondary-text-color); + flex-shrink: 0; + background: var(--input-disabled-color); + border-radius: 0 8px 8px 0; + border-left: 1px solid var(--divide-line-color); + } + .path { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + color: var(--input-hint-color); + &:hover { + color: var(--primary-color); + } + } +} + +.desc { + color: var(--third-text-color); + font-size: 12px; + font-weight: 400; + margin-bottom: 8px; +} + +.content { + padding: 20px 40px 0 40px; +} diff --git a/packages/neuron-ui/src/widgets/DataPathDialog/index.tsx b/packages/neuron-ui/src/widgets/DataPathDialog/index.tsx new file mode 100644 index 0000000000..576b37e139 --- /dev/null +++ b/packages/neuron-ui/src/widgets/DataPathDialog/index.tsx @@ -0,0 +1,73 @@ +import React, { useCallback } from 'react' +import Dialog from 'widgets/Dialog' +import { useTranslation } from 'react-i18next' +import { shell } from 'electron' +import { invokeShowOpenDialog } from 'services/remote' +import { isSuccessResponse } from 'utils' +import styles from './dataPathDialog.module.scss' + +const DataPathDialog = ({ + show, + confirmText, + icon, + text, + dataPath, + onChangeDataPath, + onCancel, + onConfirm, +}: { + show: boolean + confirmText: string + icon: React.ReactNode + text: React.ReactNode + dataPath: string + onChangeDataPath: (dataPath: string) => void + onCancel: () => void + onConfirm: (e: React.FormEvent) => void +}) => { + const [t] = useTranslation() + const openPath = useCallback((e: React.SyntheticEvent) => { + const { + dataset: { path }, + } = e.currentTarget + if (path) { + shell.openPath(path) + } + }, []) + const onSetDataPath = useCallback(() => { + invokeShowOpenDialog({ + buttonLabel: t('settings.data.set', { lng: navigator.language }), + properties: ['openDirectory', 'createDirectory', 'promptToCreate', 'treatPackageAsDirectory'], + }).then(res => { + if (isSuccessResponse(res) && res.result && !res.result?.canceled && res.result?.filePaths?.length) { + onChangeDataPath(res.result.filePaths[0]) + } + }) + }, []) + return ( + +
{icon}
+
{text}
+
{t('common.ckb-node-data-path')}
+
+ + +
+
+ ) +} + +DataPathDialog.displayName = 'DataPathDialog' +export default DataPathDialog diff --git a/packages/neuron-ui/src/widgets/Icons/FirstSync.png b/packages/neuron-ui/src/widgets/Icons/FirstSync.png new file mode 100644 index 0000000000000000000000000000000000000000..ecdfa7a26db1924512b0d549a37fe9adb8fdec3d GIT binary patch literal 18917 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 iucZ_6x5xkI$NvK&GRti`BD8P-0000`;IOE literal 0 HcmV?d00001 diff --git a/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/index.tsx b/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/index.tsx new file mode 100644 index 0000000000..d323c05c7e --- /dev/null +++ b/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/index.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useRef, useState } from 'react' +import Dialog from 'widgets/Dialog' +import { useTranslation } from 'react-i18next' +import { setCkbNodeDataPath } from 'services/remote' +import { isSuccessResponse } from 'utils' +import { Attention } from 'widgets/Icons/icon' +import Button from 'widgets/Button' +import AlertDialog from 'widgets/AlertDialog' +import styles from './migrateCkbDataDialog.module.scss' + +const MigrateCkbDataDialog = ({ + show, + prevPath, + currentPath, + onCancel, + onConfirm, +}: { + show: boolean + prevPath: string + currentPath: string + onCancel: () => void + onConfirm: (dataPath: string) => void +}) => { + const [t] = useTranslation() + const resyncRef = useRef(null) + const [failureMessage, setFailureMessage] = useState('') + const [savingType, setSavingType] = useState() + const startSync = useCallback( + (syncType: string) => { + setFailureMessage('') + setSavingType(syncType) + setCkbNodeDataPath({ + dataPath: currentPath, + clearCache: syncType === 'resync', + }) + .then(res => { + if (isSuccessResponse(res)) { + onConfirm(currentPath) + } else { + setFailureMessage(typeof res.message === 'string' ? res.message : res.message.content!) + } + }) + .finally(() => { + setSavingType(undefined) + }) + }, + [currentPath, onConfirm] + ) + const onActionClick = useCallback( + (e: React.MouseEvent) => { + if (e.currentTarget.dataset.syncType) { + startSync(e.currentTarget.dataset.syncType) + } + }, + [startSync] + ) + return ( + <> + +
+
{t('settings.data.remove-ckb-data-tip', { prevPath, currentPath })}
+
+ + {t('settings.data.resync-ckb-node-describe')} +
+ +
+
+
+
+ setFailureMessage('')} + onOk={() => { + startSync('resync') + }} + /> + + ) +} + +MigrateCkbDataDialog.displayName = 'MigrateCkbDataDialog' +export default MigrateCkbDataDialog diff --git a/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/migrateCkbDataDialog.module.scss b/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/migrateCkbDataDialog.module.scss new file mode 100644 index 0000000000..7a63ba0859 --- /dev/null +++ b/packages/neuron-ui/src/widgets/MigrateCkbDataDialog/migrateCkbDataDialog.module.scss @@ -0,0 +1,69 @@ +@import '../../styles/mixin.scss'; + +.dialogContainer { + max-width: 700px; + color: var(--main-text-color); + font-size: 14px; + line-height: 24px; + + .attention { + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + width: 80%; + margin: 32px auto 0; + background: var(--warn-background-color); + border: 1px solid rgba(252, 136, 0, 0.2); + border-radius: 4px; + font-weight: 500; + font-size: 12px; + line-height: 24px; + color: var(--warn-text-color); + + @media (prefers-color-scheme: dark) { + border: none; + } + + svg { + margin-right: 4px; + width: 14px; + } + } +} + +.footer { + padding: 24px 0; + @include dialog-footer; + --footer-background-color: #ecf9f0; + @media (prefers-color-scheme: dark) { + --footer-background-color: #2d3534; + } + + .footerBtn { + padding: 0 20px; + background: var(--footer-background-color); + color: var(--primary-color); + &:not([disabled]) { + &:hover, + &:focus { + background-color: var(--footer-background-color); + color: color-mix(in srgb, #000000 20%, var(--primary-color)); + } + + &:active { + background-color: var(--footer-background-color); + color: color-mix(in srgb, #000000 20%, var(--primary-color)); + } + } + &:last-of-type { + margin-left: 24px; + } + svg { + g, + path { + fill: var(--primary-color); + } + } + } +} diff --git a/packages/neuron-wallet/.env b/packages/neuron-wallet/.env index 70dda01370..b86de40909 100644 --- a/packages/neuron-wallet/.env +++ b/packages/neuron-wallet/.env @@ -117,4 +117,5 @@ DAO_CODE_HASH=0x82d76d1b75fe2fd9a27dfbaa65a039221a380d76c926f378d3f81cf3e7e13f2e MULTISIG_CODE_HASH=0x5c5069eb0857efc65e1bca0c07df34c31663b3622fd3876c876320fc9634e2a8 # CKB NODE OPTIONS -CKB_NODE_ASSUME_VALID_TARGET='0x1d46fe5bb62d19a004eadd7ba40564c45620905ab260d8a546a9e4b9d7cc0f85' +CKB_NODE_ASSUME_VALID_TARGET='0xe88730476c6ef0508f4767b233c82a0f20c6365fb1572bf020cb1dce2a0e704b' +CKB_NODE_DATA_SIZE=49 diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index 024eee26ff..6a7e13d8af 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -628,29 +628,62 @@ export default class ApiController { return { status: ResponseCode.Success, result: true } }) - handle('get-ckb-node-data-path', () => { + handle('get-first-sync-info', () => { + const currentNetwork = NetworksService.getInstance().getCurrent() return { status: ResponseCode.Success, - result: SettingsService.getInstance().getNodeDataPath(), + result: { + isFirstSync: SettingsService.getInstance().isFirstSync && currentNetwork.type === NetworkType.Default, + needSize: Math.ceil(+process.env.CKB_NODE_DATA_SIZE! * 1.2), + ckbNodeDataPath: SettingsService.getInstance().getNodeDataPath(), + }, } }) - handle('set-ckb-node-data-path', async (_, { dataPath, clearCache }: { dataPath: string; clearCache: boolean }) => { - if (!clearCache && !fs.existsSync(path.join(dataPath, 'ckb.toml'))) { - return { - status: ResponseCode.Fail, - message: t('messages.no-exist-ckb-node-data', { path: dataPath }), - } + handle('start-sync', () => { + SettingsService.getInstance().isFirstSync = false + this.#networksController.activate(this.#networksController.currentID().result) + return { + status: ResponseCode.Success, } - await cleanChain() - SettingsService.getInstance().setNodeDataPath(dataPath) - await startMonitor('ckb', true) + }) + + handle('get-ckb-node-data-path', () => { return { status: ResponseCode.Success, result: SettingsService.getInstance().getNodeDataPath(), } }) + handle( + 'set-ckb-node-data-path', + async ( + _, + { dataPath, clearCache, onlySetPath }: { dataPath: string; clearCache: boolean; onlySetPath?: boolean } + ) => { + if (onlySetPath) { + SettingsService.getInstance().setNodeDataPath(dataPath) + return { + status: ResponseCode.Success, + result: SettingsService.getInstance().getNodeDataPath(), + } + } + if (!clearCache && !fs.existsSync(path.join(dataPath, 'ckb.toml'))) { + return { + status: ResponseCode.Fail, + message: t('messages.no-exist-ckb-node-data', { path: dataPath }), + } + } + await cleanChain() + SettingsService.getInstance().setNodeDataPath(dataPath) + await startMonitor('ckb', true) + return { + status: ResponseCode.Success, + result: SettingsService.getInstance().getNodeDataPath(), + } + } + ) + handle('start-process-monitor', (_, monitorName: string) => { startMonitor(monitorName, true) return { diff --git a/packages/neuron-wallet/src/controllers/app/subscribe.ts b/packages/neuron-wallet/src/controllers/app/subscribe.ts index 8de629a2cd..84f5d8e436 100644 --- a/packages/neuron-wallet/src/controllers/app/subscribe.ts +++ b/packages/neuron-wallet/src/controllers/app/subscribe.ts @@ -16,6 +16,7 @@ import MigrateSubject from '../../models/subjects/migrate-subject' import startMonitor, { stopMonitor } from '../../services/monitor' import { clearCkbNodeCache } from '../../services/ckb-runner' import ShowGlobalDialogSubject from '../../models/subjects/show-global-dialog' +import NoDiskSpaceSubject from '../../models/subjects/no-disk-space' interface AppResponder { sendMessage: (channel: string, arg: any) => void @@ -124,4 +125,9 @@ export const subscribe = (dispatcher: AppResponder) => { ShowGlobalDialogSubject.subscribe(params => { BrowserWindow.getFocusedWindow()?.webContents.send('show-global-dialog', params) }) + + NoDiskSpaceSubject.subscribe(params => { + stopMonitor() + dispatcher.sendMessage('no-disk-space', params) + }) } diff --git a/packages/neuron-wallet/src/models/subjects/no-disk-space.ts b/packages/neuron-wallet/src/models/subjects/no-disk-space.ts new file mode 100644 index 0000000000..8db077e777 --- /dev/null +++ b/packages/neuron-wallet/src/models/subjects/no-disk-space.ts @@ -0,0 +1,5 @@ +import { Subject } from 'rxjs' + +const NoDiskSpaceSubject = new Subject() + +export default NoDiskSpaceSubject diff --git a/packages/neuron-wallet/src/services/ckb-runner.ts b/packages/neuron-wallet/src/services/ckb-runner.ts index 0439e1c9fd..aeb1128d4c 100644 --- a/packages/neuron-wallet/src/services/ckb-runner.ts +++ b/packages/neuron-wallet/src/services/ckb-runner.ts @@ -11,6 +11,7 @@ import { resetSyncTaskQueue } from '../block-sync-renderer' import { getUsablePort } from '../utils/get-usable-port' import { updateToml } from '../utils/toml' import { BUNDLED_URL_PREFIX } from '../utils/const' +import NoDiskSpaceSubject from '../models/subjects/no-disk-space' const platform = (): string => { switch (process.platform) { @@ -113,10 +114,9 @@ export const startCkbNode = async () => { network: `listen_addresses = ["/ip4/0.0.0.0/tcp/${listenPort}"]`, }) const options = ['run', '-C', SettingsService.getInstance().getNodeDataPath(), '--indexer'] - const stdio: (StdioNull | StdioPipe)[] = ['ignore', 'ignore', 'pipe'] + const stdio: (StdioNull | StdioPipe)[] = ['ignore', 'pipe', 'pipe'] if (app.isPackaged && process.env.CKB_NODE_ASSUME_VALID_TARGET) { options.push('--assume-valid-target', process.env.CKB_NODE_ASSUME_VALID_TARGET) - stdio[1] = 'pipe' } logger.info(`CKB:\tckb full node will with rpc port ${rpcPort}, listen port ${listenPort}, with options`, options) const currentProcess = spawn(ckbBinary(), options, { stdio }) @@ -130,6 +130,11 @@ export const startCkbNode = async () => { }) currentProcess.stdout?.on('data', data => { const dataString: string = data.toString() + if (/No space left/.test(dataString)) { + NoDiskSpaceSubject.next(true) + logger.error('CKB:\trun fail:', dataString) + return + } if ( dataString.includes( `can't find assume valid target temporarily, hash: Byte32(${process.env.CKB_NODE_ASSUME_VALID_TARGET})` @@ -150,8 +155,8 @@ export const startCkbNode = async () => { } }) - currentProcess.on('close', () => { - logger.info('CKB:\tprocess closed') + currentProcess.on('close', code => { + logger.info(`CKB:\tprocess closed with code ${code}`) isLookingValidTarget = false if (Object.is(ckb, currentProcess)) { ckb = null diff --git a/packages/neuron-wallet/src/services/node.ts b/packages/neuron-wallet/src/services/node.ts index 37e302e4d9..d9731d2328 100644 --- a/packages/neuron-wallet/src/services/node.ts +++ b/packages/neuron-wallet/src/services/node.ts @@ -19,6 +19,7 @@ import { rpcRequest } from '../utils/rpc-request' import { generateRPC } from '../utils/ckb-rpc' import startMonitor, { stopMonitor } from './monitor' import { CKBLightRunner } from './light-runner' +import SettingsService from './settings' export enum VerifyCkbVersionResult { Same, @@ -135,6 +136,10 @@ class NodeService { await stopMonitor('ckb') const isDefaultCKBNeedStart = await this.isDefaultCKBNeedRestart() if (isDefaultCKBNeedStart) { + if (SettingsService.getInstance().isFirstSync) { + logger.info("CKB:\tThis is the first sync, please wait for the user's confirmation") + return + } logger.info('CKB:\texternal RPC on default uri not detected, starting bundled CKB node.') const redistReady = await redistCheck() await (redistReady ? this.#startNodeSubject.next() : this.showGuideDialog()) diff --git a/packages/neuron-wallet/src/services/settings.ts b/packages/neuron-wallet/src/services/settings.ts index 719552dc60..55dd2a3e16 100644 --- a/packages/neuron-wallet/src/services/settings.ts +++ b/packages/neuron-wallet/src/services/settings.ts @@ -1,4 +1,5 @@ import { BrowserWindow, nativeTheme } from 'electron' +import fs from 'node:fs' import env from '../env' import Store from '../models/store' import { changeLanguage } from '../locales/i18n' @@ -52,6 +53,14 @@ export default class SettingsService extends Store { this.writeSync('indexerDataPath', dataPath) } + get isFirstSync(): boolean { + return this.readSync('isFirstSync') + } + + set isFirstSync(isFirstSync: boolean) { + this.writeSync('isFirstSync', isFirstSync) + } + getNodeDataPath(chain?: string) { return this.readSync( `${settingKeys.nodeDataPath}_${chain ?? NetworksService.getInstance().getCurrent().chain}` @@ -78,11 +87,15 @@ export default class SettingsService extends Store { JSON.stringify({ locale: app.getLocale(), ckbDataPath: path.resolve(app.getPath('userData'), 'chains/mainnet'), + isFirstSync: true, }) ) if (!this.getNodeDataPath(LIGHT_CLIENT_MAINNET) || !this.getNodeDataPath('ckb')) { this.migrateDataPath() } + if (this.isFirstSync === undefined) { + this.isFirstSync = !fs.existsSync(path.join(this.getNodeDataPath(), 'ckb.toml')) + } } private onLocaleChanged = (lng: Locale) => { diff --git a/packages/neuron-wallet/tests/services/ckb-runner.test.ts b/packages/neuron-wallet/tests/services/ckb-runner.test.ts index b36662f753..451d9b2462 100644 --- a/packages/neuron-wallet/tests/services/ckb-runner.test.ts +++ b/packages/neuron-wallet/tests/services/ckb-runner.test.ts @@ -145,7 +145,7 @@ describe('ckb runner', () => { expect(stubbedSpawn).toHaveBeenCalledWith( expect.stringContaining(path.join(platformPath, 'ckb')), ['run', '-C', ckbDataPath, '--indexer'], - { stdio: ['ignore', 'ignore', 'pipe'] } + { stdio: ['ignore', 'pipe', 'pipe'] } ) }) }) @@ -179,7 +179,7 @@ describe('ckb runner', () => { expect(stubbedSpawn).toHaveBeenCalledWith( expect.stringContaining(path.join(platformPath, 'ckb')), ['run', '-C', ckbDataPath, '--indexer'], - { stdio: ['ignore', 'ignore', 'pipe'] } + { stdio: ['ignore', 'pipe', 'pipe'] } ) }) }) diff --git a/packages/neuron-wallet/tests/services/node.test.ts b/packages/neuron-wallet/tests/services/node.test.ts index 12eaadb52b..29fafa3f4d 100644 --- a/packages/neuron-wallet/tests/services/node.test.ts +++ b/packages/neuron-wallet/tests/services/node.test.ts @@ -30,6 +30,7 @@ describe('NodeService', () => { const getLocalNodeInfoMock = jest.fn() const pathJoinMock = jest.fn() const redistCheckMock = jest.fn() + const isFirstSyncMock = jest.fn() const fakeHTTPUrl = 'http://fakeurl' @@ -57,6 +58,7 @@ describe('NodeService', () => { stubbedStopLightNode.mockReset() pathJoinMock.mockReset() redistCheckMock.mockReset() + isFirstSyncMock.mockReset() } beforeEach(() => { @@ -182,6 +184,16 @@ describe('NodeService', () => { jest.doMock('utils/redist-check', () => redistCheckMock) + jest.doMock('services/settings', () => ({ + getInstance() { + return { + get isFirstSync() { + return isFirstSyncMock() + }, + } + }, + })) + stubbedRxjsDebounceTime.mockReturnValue((x: any) => x) getChainMock.mockRejectedValue('no chain') }) @@ -556,4 +568,25 @@ describe('NodeService', () => { }) }) }) + describe('test start default node', () => { + beforeEach(() => { + const NodeService = require('../../src/services/node').default + stubbedNetworsServiceGet.mockReturnValue({ remote: BUNDLED_CKB_URL, readonly: true }) + getLocalNodeInfoMock.mockRejectedValue('not start') + nodeService = new NodeService() + }) + it('is first sync', async () => { + isFirstSyncMock.mockReturnValue(true) + await nodeService.tryStartNodeOnDefaultURI() + expect(stubbedLoggerInfo).toBeCalledWith(`CKB:\tThis is the first sync, please wait for the user's confirmation`) + }) + it('is not first sync', async () => { + isFirstSyncMock.mockReturnValue(false) + redistCheckMock.mockResolvedValue(true) + await nodeService.tryStartNodeOnDefaultURI() + expect(stubbedLoggerInfo).toBeCalledWith( + `CKB:\texternal RPC on default uri not detected, starting bundled CKB node.` + ) + }) + }) }) diff --git a/packages/neuron-wallet/tests/services/setting.test.ts b/packages/neuron-wallet/tests/services/setting.test.ts index 609c5dfe8e..aec2419474 100644 --- a/packages/neuron-wallet/tests/services/setting.test.ts +++ b/packages/neuron-wallet/tests/services/setting.test.ts @@ -3,6 +3,7 @@ import SettingsService from '../../src/services/settings' const readSyncMock = jest.fn() const writeSyncMock = jest.fn() const resolveMock = jest.fn() +const joinMock = jest.fn() const getLocaleMock = jest.fn() const getPathMock = jest.fn() const updateApplicationMenuMock = jest.fn() @@ -11,6 +12,7 @@ function resetMock() { readSyncMock.mockReset() writeSyncMock.mockReset() resolveMock.mockReset() + joinMock.mockReset() getLocaleMock.mockReset() getPathMock.mockReset() updateApplicationMenuMock.mockReset() @@ -45,6 +47,7 @@ jest.mock('electron', () => ({ jest.mock('path', () => ({ resolve: () => resolveMock(), + join: () => joinMock(), })) jest.mock('env', () => ({ diff --git a/scripts/update-valid-target.js b/scripts/update-valid-target.js deleted file mode 100644 index 4ca12b6e55..0000000000 --- a/scripts/update-valid-target.js +++ /dev/null @@ -1,40 +0,0 @@ -const fs = require('node:fs') -const path = require('node:path') - -async function rpcRequest(method, params = []) { - const result = await fetch( - 'https://mainnet.ckb.dev/rpc', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id: 0, - jsonrpc: '2.0', - method, - params, - }), - timeout: 10000, - } - ) - return result.json() -} - -const ESTIMATE_BLOCK_COUNT_PER_DAY = 8_000 -const envFilePath = path.resolve(__dirname, '../packages/neuron-wallet/.env') -const validTargetReg = /(CKB_NODE_ASSUME_VALID_TARGET=)[\S]*/ - -;(async function () { - try { - console.info('start update env file') - const tipBlockNumber = (await rpcRequest('get_tip_block_number')).result - const validTargetBlockNumber = `0x${(BigInt(tipBlockNumber) - BigInt(ESTIMATE_BLOCK_COUNT_PER_DAY)).toString(16)}` - const blockHash = (await rpcRequest('get_block_hash', [validTargetBlockNumber])).result - const originEnvContent = fs.readFileSync(envFilePath).toString('utf-8') - fs.writeFileSync(envFilePath, originEnvContent.replace(validTargetReg, `CKB_NODE_ASSUME_VALID_TARGET='${blockHash}'`)) - console.info('write success') - } catch (error) { - console.error('write failed', error) - } -})() diff --git a/scripts/update-wallet-env.js b/scripts/update-wallet-env.js new file mode 100644 index 0000000000..f0a9dee2c0 --- /dev/null +++ b/scripts/update-wallet-env.js @@ -0,0 +1,56 @@ +const fs = require('node:fs') +const path = require('node:path') + +async function rpcRequest(method, params = []) { + const result = await fetch( + 'https://mainnet.ckb.dev/rpc', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: 0, + jsonrpc: '2.0', + method, + params, + }), + timeout: 10000, + } + ) + return result.json() +} + +const ESTIMATE_BLOCK_COUNT_PER_DAY = 8_000 +const envFilePath = path.resolve(__dirname, '../packages/neuron-wallet/.env') +const validTargetReg = /(CKB_NODE_ASSUME_VALID_TARGET=)[\S]*/ +const ckbNodeDataSizeReg = /(CKB_NODE_DATA_SIZE=)[\S]*/ + +async function getValidTarget() { + const tipBlockNumber = (await rpcRequest('get_tip_block_number')).result + const validTargetBlockNumber = `0x${(BigInt(tipBlockNumber) - BigInt(ESTIMATE_BLOCK_COUNT_PER_DAY)).toString(16)}` + return (await rpcRequest('get_block_hash', [validTargetBlockNumber])).result +} + +async function getCKBNodeSize() { + const res = await fetch('https://ckb-node-info.magickbase.com/api') + const { data_size_g } = await res.json() + return Math.ceil(data_size_g) +} + +;(async function () { + try { + console.info('start updating env file') + const blockHash = await getValidTarget() + const ckbNodeDataSize = await getCKBNodeSize() + const originEnvContent = fs.readFileSync(envFilePath).toString('utf-8') + fs.writeFileSync( + envFilePath, + originEnvContent.replace(validTargetReg, `CKB_NODE_ASSUME_VALID_TARGET='${blockHash}'`) + .replace(ckbNodeDataSizeReg, `CKB_NODE_DATA_SIZE=${ckbNodeDataSize}`) + ) + console.info('write success') + } catch (error) { + console.error('write failed', error) + } +})() From 2df2627dac14026dc129deb3a474b0cb6d338661 Mon Sep 17 00:00:00 2001 From: yanguoyu <841185308@qq.com> Date: Tue, 30 Jan 2024 17:23:06 +0800 Subject: [PATCH 2/2] test: Skip look assume-valid-target --- packages/neuron-wallet/src/services/ckb-runner.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/neuron-wallet/src/services/ckb-runner.ts b/packages/neuron-wallet/src/services/ckb-runner.ts index aeb1128d4c..09e4abb475 100644 --- a/packages/neuron-wallet/src/services/ckb-runner.ts +++ b/packages/neuron-wallet/src/services/ckb-runner.ts @@ -115,9 +115,6 @@ export const startCkbNode = async () => { }) const options = ['run', '-C', SettingsService.getInstance().getNodeDataPath(), '--indexer'] const stdio: (StdioNull | StdioPipe)[] = ['ignore', 'pipe', 'pipe'] - if (app.isPackaged && process.env.CKB_NODE_ASSUME_VALID_TARGET) { - options.push('--assume-valid-target', process.env.CKB_NODE_ASSUME_VALID_TARGET) - } logger.info(`CKB:\tckb full node will with rpc port ${rpcPort}, listen port ${listenPort}, with options`, options) const currentProcess = spawn(ckbBinary(), options, { stdio })