From f429e7bea2def8a540ecaab608b282baf89ee6eb Mon Sep 17 00:00:00 2001 From: Yukang Date: Tue, 7 Jul 2026 14:35:31 +0800 Subject: [PATCH] feat: add CKB dependency check and dialog for Visual C++ Redistributable --- .../src/controllers/app/subscribe.ts | 17 +++ packages/neuron-wallet/src/locales/en.ts | 2 +- packages/neuron-wallet/src/locales/zh.ts | 2 +- .../src/models/subjects/ckb-dependency.ts | 5 + .../neuron-wallet/src/services/ckb-runner.ts | 8 ++ packages/neuron-wallet/src/services/node.ts | 24 +--- .../src/utils/ckb-dependency-dialog.ts | 26 ++++ .../neuron-wallet/src/utils/redist-check.ts | 34 ++++- .../tests/services/ckb-runner.test.ts | 36 ++++- .../neuron-wallet/tests/services/node.test.ts | 16 +++ .../tests/utils/redist-check/index.test.ts | 129 +++++++++++++----- 11 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 packages/neuron-wallet/src/models/subjects/ckb-dependency.ts create mode 100644 packages/neuron-wallet/src/utils/ckb-dependency-dialog.ts diff --git a/packages/neuron-wallet/src/controllers/app/subscribe.ts b/packages/neuron-wallet/src/controllers/app/subscribe.ts index a71a18661d..c87746ce0b 100644 --- a/packages/neuron-wallet/src/controllers/app/subscribe.ts +++ b/packages/neuron-wallet/src/controllers/app/subscribe.ts @@ -17,6 +17,8 @@ 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' +import CkbDependencySubject from '../../models/subjects/ckb-dependency' +import { showCkbDependencyDialog } from '../../utils/ckb-dependency-dialog' interface AppResponder { sendMessage: (channel: string, arg: any) => void @@ -25,6 +27,8 @@ interface AppResponder { updateWindowTitle: () => void } +let isShowingCkbDependencyDialog = false + /** * subscribe to events and dispatch them to the renderer process */ @@ -127,4 +131,17 @@ export const subscribe = (dispatcher: AppResponder) => { stopMonitor() dispatcher.sendMessage('no-disk-space', params) }) + + CkbDependencySubject.subscribe(async () => { + if (isShowingCkbDependencyDialog) { + return + } + isShowingCkbDependencyDialog = true + try { + stopMonitor('ckb') + await showCkbDependencyDialog() + } finally { + isShowingCkbDependencyDialog = false + } + }) } diff --git a/packages/neuron-wallet/src/locales/en.ts b/packages/neuron-wallet/src/locales/en.ts index a05c9e463b..3eea52cadc 100644 --- a/packages/neuron-wallet/src/locales/en.ts +++ b/packages/neuron-wallet/src/locales/en.ts @@ -181,7 +181,7 @@ export default { 'ckb-dependency': { title: 'Bundled CKB Node', message: 'Dependency Required', - detail: `The network nodes in Neuron rely on C++ components, so please install the latest version of Microsoft Visual C++Redistributable for x64 to ensure that the software runs properly.`, + detail: `The bundled CKB node in Neuron requires the latest Microsoft Visual C++ Redistributable for x64. The installed version is missing or too old, so please install the latest version to ensure that the software runs properly.`, buttons: { 'install-and-exit': 'Install and Exit', }, diff --git a/packages/neuron-wallet/src/locales/zh.ts b/packages/neuron-wallet/src/locales/zh.ts index 5062f023ae..33c1b3c28e 100644 --- a/packages/neuron-wallet/src/locales/zh.ts +++ b/packages/neuron-wallet/src/locales/zh.ts @@ -170,7 +170,7 @@ export default { title: '内置 CKB 节点', message: '缺少必要的依赖', detail: - 'Neuron 中的网络节点依赖C++组件,请安装 x64 最新版本的 Microsoft Visual C++Redistributable 来保证软件正常运行。', + 'Neuron 的内置 CKB 节点需要最新 x64 版本的 Microsoft Visual C++ Redistributable。当前版本缺失或过旧,请安装最新版以保证软件正常运行。', buttons: { 'install-and-exit': '安装并退出', }, diff --git a/packages/neuron-wallet/src/models/subjects/ckb-dependency.ts b/packages/neuron-wallet/src/models/subjects/ckb-dependency.ts new file mode 100644 index 0000000000..8b20009dd0 --- /dev/null +++ b/packages/neuron-wallet/src/models/subjects/ckb-dependency.ts @@ -0,0 +1,5 @@ +import { Subject } from 'rxjs' + +const CkbDependencySubject = new Subject() + +export default CkbDependencySubject diff --git a/packages/neuron-wallet/src/services/ckb-runner.ts b/packages/neuron-wallet/src/services/ckb-runner.ts index 0df5f1a3c7..d57ab048d3 100644 --- a/packages/neuron-wallet/src/services/ckb-runner.ts +++ b/packages/neuron-wallet/src/services/ckb-runner.ts @@ -12,6 +12,7 @@ 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' +import CkbDependencySubject from '../models/subjects/ckb-dependency' const platform = (): string => { switch (process.platform) { @@ -31,6 +32,10 @@ enum NeedMigrateMsg { Recommends = 'CKB recommends migrating your data into a new format', } +export const isVcredistVersionError = (message: string) => { + return /(?:VC\+\+|Visual C\+\+) Redistributable/i.test(message) && /Version is below|download\/upgrade/i.test(message) +} + const { app } = env let ckb: ChildProcess | null = null @@ -132,6 +137,9 @@ export const startCkbNode = async () => { if (dataString.includes(NeedMigrateMsg.Wants) || dataString.includes(NeedMigrateMsg.Recommends)) { MigrateSubject.next({ type: 'need-migrate' }) } + if (isVcredistVersionError(dataString)) { + CkbDependencySubject.next() + } }) currentProcess.stdout?.on('data', data => { const dataString: string = data.toString() diff --git a/packages/neuron-wallet/src/services/node.ts b/packages/neuron-wallet/src/services/node.ts index 25220fe1d1..5afbf0d65b 100644 --- a/packages/neuron-wallet/src/services/node.ts +++ b/packages/neuron-wallet/src/services/node.ts @@ -1,11 +1,9 @@ import fs from 'fs' import path from 'path' import { BI } from '@ckb-lumos/lumos' -import { app as electronApp, dialog, shell, app } from 'electron' -import { t } from 'i18next' +import { app as electronApp, app } from 'electron' import { interval, BehaviorSubject, merge, Subject } from 'rxjs' import { distinctUntilChanged, sampleTime, flatMap, delay, retry, debounceTime } from 'rxjs/operators' -import env from '../env' import { ConnectionStatusSubject } from '../models/subjects/node' import { CurrentNetworkIDSubject } from '../models/subjects/networks' import { NetworkType } from '../models/network' @@ -17,6 +15,7 @@ import logger from '../utils/logger' import redistCheck from '../utils/redist-check' import { rpcRequest } from '../utils/rpc-request' import { generateRPC } from '../utils/ckb-rpc' +import { showCkbDependencyDialog } from '../utils/ckb-dependency-dialog' import startMonitor, { stopMonitor } from './monitor' import { CKBLightRunner } from './light-runner' import SettingsService from './settings' @@ -211,24 +210,7 @@ class NodeService { } private showGuideDialog = () => { - const I18N_PATH = `messageBox.ckb-dependency` - return dialog - .showMessageBox({ - type: 'info', - buttons: ['install-and-exit'].map(label => t(`${I18N_PATH}.buttons.${label}`)), - defaultId: 0, - title: t(`${I18N_PATH}.title`), - message: t(`${I18N_PATH}.message`), - detail: t(`${I18N_PATH}.detail`), - cancelId: 0, - noLink: true, - }) - .then(() => { - const VC_REDIST_URL = `https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170` - shell.openExternal(VC_REDIST_URL) - env.app.quit() - return false - }) + return showCkbDependencyDialog() } private getInternalNodeVersion(type: CKBNodeType) { diff --git a/packages/neuron-wallet/src/utils/ckb-dependency-dialog.ts b/packages/neuron-wallet/src/utils/ckb-dependency-dialog.ts new file mode 100644 index 0000000000..c1eabbae2b --- /dev/null +++ b/packages/neuron-wallet/src/utils/ckb-dependency-dialog.ts @@ -0,0 +1,26 @@ +import { dialog, shell } from 'electron' +import { t } from 'i18next' + +import env from '../env' + +export const VC_REDIST_URL = 'https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170' + +export const showCkbDependencyDialog = () => { + const I18N_PATH = `messageBox.ckb-dependency` + return dialog + .showMessageBox({ + type: 'info', + buttons: ['install-and-exit'].map(label => t(`${I18N_PATH}.buttons.${label}`)), + defaultId: 0, + title: t(`${I18N_PATH}.title`), + message: t(`${I18N_PATH}.message`), + detail: t(`${I18N_PATH}.detail`), + cancelId: 0, + noLink: true, + }) + .then(() => { + shell.openExternal(VC_REDIST_URL) + env.app.quit() + return false + }) +} diff --git a/packages/neuron-wallet/src/utils/redist-check.ts b/packages/neuron-wallet/src/utils/redist-check.ts index 408af371d4..12d3e3b05b 100644 --- a/packages/neuron-wallet/src/utils/redist-check.ts +++ b/packages/neuron-wallet/src/utils/redist-check.ts @@ -22,7 +22,7 @@ const redistCheck = async () => { logger.error(`${query} stderr: ${stderr}`) return false } - return !!stdout + return isSupportedRedist(stdout) }) .catch(err => { logger.error(err) @@ -33,4 +33,36 @@ const redistCheck = async () => { return vcredists.includes(true) } +export const MINIMUM_REDIST_VERSION = '14.44.0.0' + +export const getRedistVersion = (stdout?: string | null) => { + return stdout + ?.split(/\r?\n/) + .find(line => /\bVersion\b/i.test(line)) + ?.match(/v?(\d+(?:\.\d+)+)/i)?.[1] +} + +export const compareVersions = (version: string, minimumVersion: string) => { + const versionParts = version.split('.').map(Number) + const minimumVersionParts = minimumVersion.split('.').map(Number) + const length = Math.max(versionParts.length, minimumVersionParts.length) + for (let idx = 0; idx < length; idx++) { + const current = versionParts[idx] ?? 0 + const minimum = minimumVersionParts[idx] ?? 0 + if (current > minimum) { + return 1 + } + if (current < minimum) { + return -1 + } + } + return 0 +} + +export const isSupportedRedist = (stdout?: string | null) => { + const installed = !/\bInstalled\s+REG_DWORD\s+0x0\b/i.test(stdout ?? '') + const version = getRedistVersion(stdout) + return installed && !!version && compareVersions(version, MINIMUM_REDIST_VERSION) >= 0 +} + export default redistCheck diff --git a/packages/neuron-wallet/tests/services/ckb-runner.test.ts b/packages/neuron-wallet/tests/services/ckb-runner.test.ts index 7e904a4637..424b0c7d4c 100644 --- a/packages/neuron-wallet/tests/services/ckb-runner.test.ts +++ b/packages/neuron-wallet/tests/services/ckb-runner.test.ts @@ -11,6 +11,7 @@ const stubbedLoggerLog = jest.fn() const resetSyncTaskQueueAsyncPushMock = jest.fn() const updateTomlMock = jest.fn() const getUsablePortMock = jest.fn() +const mockCkbDependencyNext = jest.fn() const stubbedProcess: any = {} @@ -24,6 +25,7 @@ const resetMocks = () => { resetSyncTaskQueueAsyncPushMock.mockReset() updateTomlMock.mockReset() getUsablePortMock.mockReset() + mockCkbDependencyNext.mockReset() } jest.doMock('child_process', () => { @@ -91,7 +93,16 @@ jest.doMock('../../src/utils/toml', () => ({ jest.doMock('../../src/utils/get-usable-port', () => ({ getUsablePort: getUsablePortMock, })) -const { startCkbNode, stopCkbNode, migrateCkbData, getNodeUrl } = require('../../src/services/ckb-runner') +jest.mock('../../src/models/subjects/ckb-dependency', () => ({ + next: mockCkbDependencyNext, +})) +const { + startCkbNode, + stopCkbNode, + migrateCkbData, + getNodeUrl, + isVcredistVersionError, +} = require('../../src/services/ckb-runner') describe('ckb runner', () => { let stubbedCkb: any = new EventEmitter() @@ -141,6 +152,16 @@ describe('ckb runner', () => { { stdio: ['ignore', 'pipe', 'pipe'] } ) }) + + it('notifies when ckb reports an outdated Visual C++ Redistributable', () => { + stubbedCkb.stderr.emit( + 'data', + Buffer.from( + 'Detected VC++ Redistributable version (x64): v14.29.30133.00\nVersion is below 14.44.0.0. Please download/upgrade the Visual C++ Redistributable.' + ) + ) + expect(mockCkbDependencyNext).toHaveBeenCalled() + }) }) describe('without config file', () => { @@ -238,6 +259,19 @@ describe('ckb runner', () => { }) }) }) + describe('#isVcredistVersionError', () => { + it('detects the bundled ckb Visual C++ Redistributable version error', () => { + expect( + isVcredistVersionError( + 'Detected VC++ Redistributable version (x64): v14.29.30133.00\nVersion is below 14.44.0.0. Please download/upgrade the Visual C++ Redistributable.' + ) + ).toBe(true) + }) + + it('ignores unrelated ckb stderr', () => { + expect(isVcredistVersionError('CKB wants to migrate the data into new format')).toBe(false) + }) + }) describe('#stopCkbNode', () => { beforeEach(async () => { stubbedExistsSync.mockReturnValue(true) diff --git a/packages/neuron-wallet/tests/services/node.test.ts b/packages/neuron-wallet/tests/services/node.test.ts index 549c1e95dd..737e37d09b 100644 --- a/packages/neuron-wallet/tests/services/node.test.ts +++ b/packages/neuron-wallet/tests/services/node.test.ts @@ -31,6 +31,7 @@ describe('NodeService', () => { const pathJoinMock = jest.fn() const redistCheckMock = jest.fn() const isFirstSyncMock = jest.fn() + const showCkbDependencyDialogMock = jest.fn() const fakeHTTPUrl = 'http://fakeurl' @@ -59,6 +60,7 @@ describe('NodeService', () => { pathJoinMock.mockReset() redistCheckMock.mockReset() isFirstSyncMock.mockReset() + showCkbDependencyDialogMock.mockReset() } beforeEach(() => { @@ -184,6 +186,10 @@ describe('NodeService', () => { jest.doMock('utils/redist-check', () => redistCheckMock) + jest.doMock('utils/ckb-dependency-dialog', () => ({ + showCkbDependencyDialog: showCkbDependencyDialogMock, + })) + jest.doMock('services/settings', () => ({ getInstance() { return { @@ -595,5 +601,15 @@ describe('NodeService', () => { `CKB:\texternal RPC on default uri not detected, starting bundled CKB node.` ) }) + it('shows the CKB dependency dialog when vcredist is missing or outdated', async () => { + isFirstSyncMock.mockReturnValue(false) + redistCheckMock.mockResolvedValue(false) + showCkbDependencyDialogMock.mockResolvedValue(false) + + await nodeService.tryStartNodeOnDefaultURI() + + expect(showCkbDependencyDialogMock).toHaveBeenCalled() + expect(stubbedStartCKBNode).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/neuron-wallet/tests/utils/redist-check/index.test.ts b/packages/neuron-wallet/tests/utils/redist-check/index.test.ts index 950555c026..bf0bfb62f2 100644 --- a/packages/neuron-wallet/tests/utils/redist-check/index.test.ts +++ b/packages/neuron-wallet/tests/utils/redist-check/index.test.ts @@ -1,59 +1,122 @@ -import redistCheck from '../../../src/utils/redist-check' +const mockExecPromise = jest.fn() -let originalPlatform: string - -type ExecPromiseResult = Promise<{ stdout?: null | string; stderr?: null | string }> -type ExecFunc = () => ExecPromiseResult jest.mock('util', () => ({ - promisify: jest - .fn(() => () => Promise.resolve({ stdout: 'success' })) - .mockImplementationOnce(() => () => Promise.resolve({ stdout: 'success' })) - .mockImplementationOnce(() => () => Promise.resolve({ stdout: null, stderr: 'err' })) - .mockImplementationOnce(() => () => Promise.reject({ stdout: null, stderr: 'err' })), + promisify: jest.fn(() => mockExecPromise), })) jest.mock('utils/logger', () => console) +import redistCheck, { + compareVersions, + getRedistVersion, + isSupportedRedist, + MINIMUM_REDIST_VERSION, +} from '../../../src/utils/redist-check' + +let originalPlatform: string + +const setPlatform = (platform: string) => { + Object.defineProperty(process, 'platform', { + value: platform, + }) +} + +const registryOutput = (version: string, installed = '0x1') => ` +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64 + Installed REG_DWORD ${installed} + Version REG_SZ v${version} +` + describe('redist check', () => { + beforeAll(() => { + originalPlatform = process.platform + }) + + afterAll(() => { + setPlatform(originalPlatform) + }) + + beforeEach(() => { + mockExecPromise.mockReset() + }) + + describe('version helpers', () => { + it('parses the redist version from registry output', () => { + expect(getRedistVersion(registryOutput('14.44.35208.0'))).toBe('14.44.35208.0') + }) + + it('compares version parts numerically', () => { + expect(compareVersions('14.44.0.0', MINIMUM_REDIST_VERSION)).toBe(0) + expect(compareVersions('14.44.1.0', MINIMUM_REDIST_VERSION)).toBe(1) + expect(compareVersions('14.29.30133.0', MINIMUM_REDIST_VERSION)).toBe(-1) + }) + + it('requires an installed runtime at or above the minimum version', () => { + expect(isSupportedRedist(registryOutput('14.44.0.0'))).toBe(true) + expect(isSupportedRedist(registryOutput('14.29.30133.0'))).toBe(false) + expect(isSupportedRedist(registryOutput('14.44.0.0', '0x0'))).toBe(false) + expect(isSupportedRedist('success')).toBe(false) + }) + }) + describe('win32', () => { - beforeAll(function () { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'win32', - }) + beforeEach(() => { + setPlatform('win32') }) - it('true', async () => { + + it('returns true when the x64 runtime is new enough', async () => { + mockExecPromise.mockResolvedValue({ stdout: registryOutput('14.44.0.0') }) + const redistStatus = await redistCheck() + expect(redistStatus).toBe(true) + expect(mockExecPromise).toHaveBeenCalledWith(expect.stringContaining('VisualStudio')) + expect(mockExecPromise).toHaveBeenCalledWith(expect.stringContaining('Runtimes')) + expect(mockExecPromise).toHaveBeenCalledWith(expect.stringContaining('x64')) }) - it('false', async () => { + + it('returns false when the x64 runtime is too old', async () => { + mockExecPromise.mockResolvedValue({ stdout: registryOutput('14.29.30133.0') }) + const redistStatus = await redistCheck() + expect(redistStatus).toBe(false) }) - it('false with reject', async () => { + + it('returns false when the query does not include a version', async () => { + mockExecPromise.mockResolvedValue({ stdout: 'success' }) + + const redistStatus = await redistCheck() + + expect(redistStatus).toBe(false) + }) + + it('returns false when the query writes to stderr', async () => { + mockExecPromise.mockResolvedValue({ stdout: null, stderr: 'err' }) + const redistStatus = await redistCheck() + expect(redistStatus).toBe(false) }) - afterAll(function () { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) + + it('returns false when the query rejects', async () => { + mockExecPromise.mockRejectedValue({ stdout: null, stderr: 'err' }) + + const redistStatus = await redistCheck() + + expect(redistStatus).toBe(false) }) }) + describe('not win32', () => { - beforeAll(function () { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'darwin', - }) + beforeEach(() => { + setPlatform('darwin') }) - it('true', async () => { + + it('returns true without querying the registry', async () => { const redistStatus = await redistCheck() + expect(redistStatus).toBe(true) - }) - afterAll(function () { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) + expect(mockExecPromise).not.toHaveBeenCalled() }) }) })