From 4daefc5e45147a3b4d9f2b068c6986def3a9684a Mon Sep 17 00:00:00 2001 From: wabiwabo <68363074+wabicai@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:49:04 +0800 Subject: [PATCH 01/55] feat(hardware): add complete OneKey Pro 2 integration (#11814) * feat(shared): add Pro 2 device foundations Define canonical protocol V2 device state, connection and routing helpers, firmware config sources, portfolio payload/archive utilities, and Pro 2 media helpers shared across runtimes. * feat(hardware): integrate Pro 2 background services Persist canonical device state across main/background runtimes and add Pro 2 USB/BLE onboarding, hidden-wallet sessions, settings, firmware, portfolio sync, NFT transfer, network capability, and WalletConnect handling. * feat(hardware): add Pro 2 wallet user flows Expose Pro 2 onboarding, connection state, passphrase and hidden-wallet handling, device settings, brightness, wallpaper, firmware progress, portfolio display, NFT actions, and developer controls in the wallet UI. * feat(cli): adapt hardware commands for Pro 2 Use the unified hardware SDK queue and device discovery/session data in CLI login and signing flows, with focused regression coverage. * docs(hardware): document Pro 2 session and portfolio flows Describe passphrase-wallet session semantics and the implemented portfolio packaging, synchronization, and upload pipeline. * fix: address Pro 2 review findings * fix: address remaining Pro 2 review findings * fix: stabilize Pro 2 device management * fix: avoid eager hardware SDK import * fix: sync Pro 2 label validation feedback * fix: avoid duplicate pin during hardware onboarding * fix: address Pro 2 review feedback * fix: prepare Pro 2 prerelease verification * fix: preserve legacy hardware compatibility * fix: use SDK Pro2 update targets * fix: harden hardware device data handling * fix: preserve hardware settings and safe logs * fix: tighten Pro2 integration boundaries * fix: harden Pro2 hardware security flows * fix: address Pro2 review feedback * fix: stabilize Pro2 onboarding and hardware sessions * fix: stabilize hardware account creation * fix: stabilize Pro2 hardware communication * perf: lazy load hardware JPEG decoder * fix: restore Pro2 hidden wallet entry * fix: handle Pro2 Attach PIN onboarding * fix: finalize Pro2 production support * chore: exclude unrelated hotfix changes * chore: upgrade hardware SDK to alpha.53 * fix: preserve Pro2 onboarding on hotfix * fix: address Pro2 review feedback * feat: improve Pro2 device and firmware update flows * chore: update hardware sdk to 1.2.0-alpha.56 * fix: address Pro2 portfolio and lint review feedback * fix: preserve hardware device compatibility * fix: use local database schema version 20 * fix: close final Pro 2 compatibility gaps * fix: bound firmware recheck wait --- apps/cli/package.json | 6 +- .../src/__tests__/btc-signer-hardware.test.ts | 10 +- .../__tests__/device-search-command.test.ts | 36 + .../src/__tests__/evm-signer-hardware.test.ts | 57 +- .../__tests__/hardware-login-command.test.ts | 125 +- .../src/__tests__/hardware-sdk-queue.test.ts | 181 +- .../commands/auth/hardware-login-command.ts | 84 +- apps/cli/src/commands/device/device-search.ts | 46 +- apps/cli/src/commands/device/hardware-sdk.ts | 219 +- .../cli/src/signer/base/SignerHardwareBase.ts | 61 +- .../src/signer/impls/sol/SignerHardware.ts | 12 +- docs/pro2-passphrase-wallet-session.md | 95 + docs/pro2-portfolio-current-implementation.md | 587 ++++++ package.json | 37 +- packages/kit-bg/package.json | 1 + packages/kit-bg/src/apis/BackgroundApi.ts | 10 + .../kit-bg/src/apis/BackgroundApiProxy.ts | 7 + packages/kit-bg/src/apis/IBackgroundApi.ts | 2 + .../dbs/local/LocalDbBase.deviceState.test.ts | 814 ++++++++ packages/kit-bg/src/dbs/local/LocalDbBase.ts | 482 ++++- packages/kit-bg/src/dbs/local/consts.ts | 2 +- .../local/realm/schemas/RealmSchemaDevice.ts | 9 + packages/kit-bg/src/dbs/local/types.ts | 32 +- .../kit-bg/src/dbs/simple/base/SimpleDb.ts | 9 + .../src/dbs/simple/base/SimpleDbProxy.ts | 5 + .../simple/entity/SimpleDbEntityAppStatus.ts | 12 + .../SimpleDbEntityHardwarePortfolioSync.ts | 51 + .../v4local/v4localDBTypesSchema.ts | 2 +- .../ServiceAccount.deviceReset.test.ts | 213 ++ ...rviceAccount.hwWalletCreateAddress.test.ts | 221 ++ .../ServiceAccount.operationLease.test.ts | 321 +++ .../services/ServiceAccount/ServiceAccount.ts | 200 +- .../btcOnlyFirmwareCacheUtils.test.ts | 43 + .../btcOnlyFirmwareCacheUtils.ts | 28 + .../deviceStateForHwWalletCreate.test.ts | 202 ++ .../deviceStateForHwWalletCreate.ts | 82 + .../hardwarePassphraseState.test.ts | 26 + .../ServiceBatchCreateAccount.ts | 149 +- .../kit-bg/src/services/ServiceBootstrap.ts | 15 + .../kit-bg/src/services/ServiceDevSetting.ts | 4 + .../ServiceFirmwareUpdate.detect.test.ts | 136 +- .../ServiceFirmwareUpdate.ts | 198 +- .../DeviceSettingsManager.pro2.test.ts | 536 +++++ .../ServiceHardware/DeviceSettingsManager.ts | 299 +-- ...rdwareAllNetworkGetAddressResponse.test.ts | 67 + .../HardwareAllNetworkGetAddressResponse.ts | 45 + .../HardwareConnectionManager.ts | 117 +- .../ServiceHardware/HardwareVerifyManager.ts | 93 +- .../ServiceHardware.connect.test.ts | 715 +++++++ ...iceHardware.getCompatibleConnectId.test.ts | 607 +++++- .../ServiceHardware.ledgerBle.test.ts | 19 + ...ardware.preInitializeDeviceForSign.test.ts | 31 +- ...rviceHardware.pro2DeviceManagement.test.ts | 1452 +++++++++++++ .../ServiceHardware.pro2Onboarding.test.ts | 113 + .../ServiceHardware/ServiceHardware.ts | 1817 ++++++++++++++--- .../getWallpaperResourceType.ts | 7 + .../hardwareUiEventStateMachine.test.ts | 300 +++ .../hardwareUiEventStateMachine.ts | 266 +++ .../hardwareUiPayloadUtils.test.ts | 62 + .../ServiceHardware/hardwareUiPayloadUtils.ts | 21 + .../jpegRgbaStartupGraph.test.ts | 32 + .../ServiceHardware/jpegRgbaUtils.test.ts | 99 + .../services/ServiceHardware/jpegRgbaUtils.ts | 58 + .../ServiceHardwarePortfolioSync.ts | 1009 +++++++++ .../ServiceHardwarePortfolioSync.wait.test.ts | 931 +++++++++ .../serviceHardwarePortfolioSync/index.ts | 3 + .../serviceHardwarePortfolioSyncUtils.test.ts | 340 +++ .../serviceHardwarePortfolioSyncUtils.ts | 145 ++ .../serviceHardwareUtils.test.ts | 63 + .../ServiceHardware/serviceHardwareUtils.ts | 34 + .../HardwareProcessingManager.test.ts | 87 + .../HardwareProcessingManager.ts | 40 + .../ServiceHardwareUI.sendUiResponse.test.ts | 70 + .../ServiceHardwareUI/ServiceHardwareUI.ts | 182 +- .../passphraseUiResponseUtils.test.ts | 38 + .../passphraseUiResponseUtils.ts | 16 + .../src/services/ServiceNFT.pro2.test.ts | 117 ++ packages/kit-bg/src/services/ServiceNFT.ts | 41 +- .../ServiceNetwork.exportAccountKeys.test.ts | 73 + .../services/ServiceNetwork/ServiceNetwork.ts | 25 +- .../kit-bg/src/services/ServiceSetting.ts | 10 +- .../src/states/jotai/atoms/devSettings.ts | 5 + .../kit-bg/src/states/jotai/atoms/hardware.ts | 22 +- .../atoms/settings.hardwareTransport.test.ts | 24 + .../src/vaults/base/KeyringHardwareBase.ts | 4 +- .../src/vaults/impls/ada/settings.test.ts | 9 + .../kit-bg/src/vaults/impls/ada/settings.ts | 12 + .../src/vaults/impls/btc/KeyringHardware.ts | 1 - .../impls/btc/KeyringHardwareBtcBase.ts | 7 +- .../kit-bg/src/vaults/impls/ckb/settings.ts | 11 + .../kit-bg/src/vaults/impls/dot/settings.ts | 12 + .../vaults/impls/lightning/KeyringHardware.ts | 27 + .../impls/lightning/settings-testnet.ts | 11 + .../src/vaults/impls/lightning/settings.ts | 11 + .../src/vaults/impls/neurai/settings.ts | 11 + .../kit-bg/src/vaults/impls/nexa/settings.ts | 11 + .../src/vaults/impls/stellar/settings.ts | 11 + .../kit-bg/src/vaults/impls/ton/settings.ts | 1 + packages/kit-bg/src/vaults/types.ts | 1 + .../kit/src/components/Hardware/Hardware.tsx | 3 + .../components/Hardware/HardwareDialog.tsx | 9 +- .../Hardware/HardwareEnterPhase.test.ts | 45 + .../Hardware/HardwareEnterPhase.tsx | 253 ++- .../Hardware/HardwareEnterPhase.utils.ts | 22 + .../kit/src/components/RenameDialog/index.tsx | 17 + .../hooks/allNetworkRunResultUtils.test.ts | 88 + .../kit/src/hooks/allNetworkRunResultUtils.ts | 38 + packages/kit/src/hooks/useAllNetwork.ts | 38 +- ...seHardwareWalletConnectStatusUtils.test.ts | 24 + .../useHardwareWalletConnectStatusUtils.ts | 3 +- .../HardwareUiStateContainer.tsx | 19 +- .../contexts/accountSelector/actions.tsx | 63 +- .../jotai/contexts/deviceDetails/actions.ts | 300 ++- .../jotai/contexts/deviceDetails/atoms.ts | 32 +- .../deviceStateManagement.test.ts | 296 +++ .../deviceDetails/deviceStateManagement.ts | 195 ++ .../jotai/contexts/deviceDetails/index.tsx | 4 + .../kit/src/utils/passphraseUtils.test.ts | 39 +- packages/kit/src/utils/passphraseUtils.ts | 25 + .../WalletEdit/WalletEditButtonUtils.test.ts | 37 + .../WalletEdit/WalletEditButtonUtils.ts | 21 +- .../WalletRename/HardwareLabelSetDialog.tsx | 200 +- .../WalletRename/WalletRenameButton.tsx | 9 + .../hardwareLabelValidation.test.ts | 41 + .../WalletRename/hardwareLabelValidation.ts | 43 + .../AccountSelectorWalletListSideBar.tsx | 19 +- .../pages/ExportKeys/ExportPrivateKeys.tsx | 2 + .../HardwareHomeScreenModal.tsx | 10 +- .../src/views/AccountManagerStacks/testIDs.ts | 2 + .../views/AssetDetails/pages/NFTDetails.tsx | 150 +- .../Gallery/Components/stories/Hardware.tsx | 26 + .../DeviceDetailsModal/DeviceBasicInfo.tsx | 4 +- .../DeviceBrightnessSlider.test.tsx | 118 ++ .../DeviceBrightnessSlider.tsx | 149 ++ .../DeviceSectionAdvance.tsx | 6 +- .../DeviceSectionDangerZone.tsx | 38 +- .../DeviceSectionGeneral.tsx | 282 ++- .../DeviceSectionSupport.tsx | 2 +- .../dialog/DialogDeviceAbout.tsx | 113 +- .../dialog/DialogFirmwareChange.tsx | 4 +- .../pages/DeviceDetailsModal/index.tsx | 111 +- .../pages/DeviceDetailsModal/utils.test.ts | 68 + .../pages/DeviceDetailsModal/utils.ts | 43 + .../pages/DeviceGuideModal/index.tsx | 4 +- .../pages/DeviceManagementListModal/index.tsx | 5 + .../kit/src/views/DeviceManagement/testIDs.ts | 2 + .../components/FirmwareChangeLogView.tsx | 89 +- .../FirmwareUpdateProgressBarV2.tsx | 116 +- .../firmwareUpdateProgressUtils.test.ts | 63 + .../firmwareUpdateProgressUtils.ts | 34 + .../src/views/FirmwareUpdate/utils.test.ts | 62 + .../kit/src/views/FirmwareUpdate/utils.ts | 15 +- .../components/HomeSupportedWallet/index.tsx | 1 + .../TokenListBlock.portfolioSync.test.ts | 32 + .../TokenListBlock/TokenListBlock.tsx | 63 +- .../ConnectYourDevice.tsx | 160 +- .../FirmwareVerifyDialog.tsx | 16 +- .../passphraseStateUtils.test.ts | 39 + .../passphraseStateUtils.ts | 1 + .../walletCreationMode.test.ts | 134 ++ .../walletCreationMode.ts | 86 + .../Onboardingv2/hooks/useDeviceConnect.tsx | 257 +-- .../usePrepareUSBConnectForFirmwareUpdate.tsx | 45 +- .../Onboardingv2/pages/CheckAndUpdate.tsx | 158 +- .../Onboardingv2/pages/ConnectYourDevice.tsx | 92 +- .../pages/DeviceSetup.lifecycle.test.ts | 29 + .../views/Onboardingv2/pages/DeviceSetup.tsx | 411 ++-- .../pages/FinalizeWalletSetup.tsx | 16 +- .../Onboardingv2/pages/deviceSetupPro2.tsx | 468 +++++ .../pages/firmwareRecheckUtils.test.ts | 69 + .../pages/firmwareRecheckUtils.ts | 35 + .../pages/pro2OnboardingStatus.test.ts | 135 ++ .../pages/pro2OnboardingStatus.ts | 197 ++ packages/kit/src/views/Onboardingv2/utils.tsx | 29 +- .../PageFirmwareUpdatePro2DevSettings.tsx | 157 ++ .../pages/FirmwareUpdateDevSettings/index.tsx | 2 + .../views/Setting/pages/Tab/CustomElement.tsx | 11 +- .../FirmwareUpdateActions.tsx | 5 +- .../FirmwareUpdateDevSettings.tsx | 15 +- .../pages/Tab/DevSettingsSection/index.tsx | 12 + .../Setting/router/basicModalSettingRouter.ts | 10 +- .../src/errors/errors/hardwareErrors.ts | 16 + .../errors/errors/thirdPartyHardwareErrors.ts | 4 +- .../deviceErrorUtils.firmwareUpdate.test.ts | 37 + .../utils/deviceErrorUtils.pro2Nft.test.ts | 28 + .../src/errors/utils/deviceErrorUtils.ts | 8 + .../utils/thirdPartyDeviceErrorUtils.test.ts | 21 +- .../utils/thirdPartyDeviceErrorUtils.ts | 4 +- packages/shared/src/eventBus/appEventBus.ts | 22 + .../shared/src/eventBus/appEventBusNames.ts | 3 + .../src/hardware/deviceStateUtils.test.ts | 178 ++ .../shared/src/hardware/deviceStateUtils.ts | 237 +++ packages/shared/src/hardware/instance.ts | 9 +- .../protocolV2DeviceSettingsSchema.test.ts | 36 + .../logger/scopes/hardware/scenes/sdk.test.ts | 56 + .../src/logger/scopes/hardware/scenes/sdk.ts | 56 +- packages/shared/src/routes/onboardingv2.ts | 5 +- packages/shared/src/routes/setting.ts | 2 + .../src/utils/DeviceScannerUtils.test.ts | 57 + .../shared/src/utils/DeviceScannerUtils.ts | 69 +- packages/shared/src/utils/avatarUtils.ts | 1 + .../src/utils/deviceHomeScreenUtils.test.ts | 62 + .../shared/src/utils/deviceHomeScreenUtils.ts | 12 +- .../deviceUtils.hardwareTransport.test.ts | 65 + packages/shared/src/utils/deviceUtils.test.ts | 258 ++- packages/shared/src/utils/deviceUtils.ts | 390 +++- packages/shared/src/utils/nftUtils.test.ts | 32 + packages/shared/src/utils/nftUtils.ts | 39 +- .../shared/src/utils/portfolioArchive.test.ts | 73 + packages/shared/src/utils/portfolioArchive.ts | 102 + .../shared/src/utils/portfolioPayload.test.ts | 956 +++++++++ packages/shared/src/utils/portfolioPayload.ts | 495 +++++ .../shared/src/utils/portfolioTokenIcon.ts | 202 ++ .../src/utils/pro2PortfolioAmountUtils.ts | 305 +++ packages/shared/src/utils/stringUtils.test.ts | 25 +- packages/shared/src/utils/stringUtils.ts | 5 + .../src/utils/thirdPartyDeviceUtils.test.ts | 55 + .../shared/src/utils/thirdPartyDeviceUtils.ts | 46 + packages/shared/types/device.pro2.test.ts | 22 + packages/shared/types/device.ts | 54 +- yarn.lock | 335 +-- 221 files changed, 23224 insertions(+), 2247 deletions(-) create mode 100644 apps/cli/src/__tests__/device-search-command.test.ts create mode 100644 docs/pro2-passphrase-wallet-session.md create mode 100644 docs/pro2-portfolio-current-implementation.md create mode 100644 packages/kit-bg/src/dbs/local/LocalDbBase.deviceState.test.ts create mode 100644 packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHardwarePortfolioSync.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/ServiceAccount.deviceReset.test.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hwWalletCreateAddress.test.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/ServiceAccount.operationLease.test.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.test.ts create mode 100644 packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.pro2.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2DeviceManagement.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2Onboarding.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/getWallpaperResourceType.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/jpegRgbaStartupGraph.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/index.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts create mode 100644 packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.test.ts create mode 100644 packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.ts create mode 100644 packages/kit-bg/src/services/ServiceNFT.pro2.test.ts create mode 100644 packages/kit-bg/src/services/ServiceNetwork.exportAccountKeys.test.ts create mode 100644 packages/kit-bg/src/states/jotai/atoms/settings.hardwareTransport.test.ts create mode 100644 packages/kit-bg/src/vaults/impls/ada/settings.test.ts create mode 100644 packages/kit/src/components/Hardware/HardwareEnterPhase.test.ts create mode 100644 packages/kit/src/components/Hardware/HardwareEnterPhase.utils.ts create mode 100644 packages/kit/src/hooks/allNetworkRunResultUtils.test.ts create mode 100644 packages/kit/src/hooks/allNetworkRunResultUtils.ts create mode 100644 packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts create mode 100644 packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.test.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.ts create mode 100644 packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.test.tsx create mode 100644 packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.tsx create mode 100644 packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.test.ts create mode 100644 packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.ts create mode 100644 packages/kit/src/views/FirmwareUpdate/utils.test.ts create mode 100644 packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts create mode 100644 packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.test.ts create mode 100644 packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.ts create mode 100644 packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.test.ts create mode 100644 packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.ts create mode 100644 packages/kit/src/views/Onboardingv2/pages/DeviceSetup.lifecycle.test.ts create mode 100644 packages/kit/src/views/Onboardingv2/pages/deviceSetupPro2.tsx create mode 100644 packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.test.ts create mode 100644 packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.ts create mode 100644 packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.test.ts create mode 100644 packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.ts create mode 100644 packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx create mode 100644 packages/shared/src/errors/utils/deviceErrorUtils.firmwareUpdate.test.ts create mode 100644 packages/shared/src/errors/utils/deviceErrorUtils.pro2Nft.test.ts create mode 100644 packages/shared/src/hardware/deviceStateUtils.test.ts create mode 100644 packages/shared/src/hardware/deviceStateUtils.ts create mode 100644 packages/shared/src/hardware/protocolV2DeviceSettingsSchema.test.ts create mode 100644 packages/shared/src/logger/scopes/hardware/scenes/sdk.test.ts create mode 100644 packages/shared/src/utils/deviceHomeScreenUtils.test.ts create mode 100644 packages/shared/src/utils/deviceUtils.hardwareTransport.test.ts create mode 100644 packages/shared/src/utils/nftUtils.test.ts create mode 100644 packages/shared/src/utils/portfolioArchive.test.ts create mode 100644 packages/shared/src/utils/portfolioArchive.ts create mode 100644 packages/shared/src/utils/portfolioPayload.test.ts create mode 100644 packages/shared/src/utils/portfolioPayload.ts create mode 100644 packages/shared/src/utils/portfolioTokenIcon.ts create mode 100644 packages/shared/src/utils/pro2PortfolioAmountUtils.ts create mode 100644 packages/shared/types/device.pro2.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 3dabb543fb1a..56a7066e75cd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -45,9 +45,9 @@ }, "dependencies": { "@napi-rs/keyring": "^1.3.0", - "@onekeyfe/hd-common-connect-sdk": "1.1.34-alpha.0", - "@onekeyfe/hd-core": "1.1.34-alpha.0", - "@onekeyfe/hd-transport-usb": "1.1.34-alpha.0", + "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.56", + "@onekeyfe/hd-core": "1.2.0-alpha.56", + "@onekeyfe/hd-transport-usb": "1.2.0-alpha.56", "proper-lockfile": "^4.1.2" } } diff --git a/apps/cli/src/__tests__/btc-signer-hardware.test.ts b/apps/cli/src/__tests__/btc-signer-hardware.test.ts index 094dba54eb9b..35824ebd6bab 100644 --- a/apps/cli/src/__tests__/btc-signer-hardware.test.ts +++ b/apps/cli/src/__tests__/btc-signer-hardware.test.ts @@ -64,14 +64,16 @@ function makeDeps(overrides: { sdk?: Partial } = {}): { } { const device = makeDevice(); const sdk = { - getFeatures: jest.fn(async () => makeSuccess({ unlocked: true })), + getDeviceState: jest.fn(async () => + makeSuccess({ status: { unlocked: true } }), + ), deviceUnlock: jest.fn(async () => makeSuccess({})), searchDevices: jest.fn(async () => makeSuccess([ { connectId: device.connectId, deviceId: device.deviceId, - features: { device_id: device.deviceId, session_id: 'session-123' }, + sessionId: 'session-123', }, ]), ), @@ -101,8 +103,8 @@ function makeDeps(overrides: { sdk?: Partial } = {}): { async () => sdk, ) as unknown as ISignerHardwareDeps['ensureSDKReady'], installPassphraseProvider, - resolvePassphraseStateByMode: - jest.fn() as unknown as ISignerHardwareDeps['resolvePassphraseStateByMode'], + resolvePassphraseSessionByMode: + jest.fn() as unknown as ISignerHardwareDeps['resolvePassphraseSessionByMode'], keychainFactory: () => ({ get: jest.fn(async () => null), set: jest.fn(async () => undefined), diff --git a/apps/cli/src/__tests__/device-search-command.test.ts b/apps/cli/src/__tests__/device-search-command.test.ts new file mode 100644 index 000000000000..5753446e431f --- /dev/null +++ b/apps/cli/src/__tests__/device-search-command.test.ts @@ -0,0 +1,36 @@ +import { formatSearchedDevice } from '../commands/device/device-search'; + +describe('device search formatting', () => { + it('prefers the canonical display name over the transport name', () => { + expect( + formatSearchedDevice({ + connectId: 'PRO2_USB', + name: 'Pro2 6136', + displayName: 'My Pro 2', + deviceType: 'pro2', + }), + ).toMatchObject({ + name: 'My Pro 2', + model: 'pro2', + }); + }); + + it('prefers serialNo while keeping the legacy uuid fallback', () => { + expect( + formatSearchedDevice({ + serialNo: 'SERIAL-NO', + uuid: 'LEGACY-UUID', + }), + ).toMatchObject({ + serial: 'SERIAL-NO', + }); + + expect( + formatSearchedDevice({ + uuid: 'LEGACY-UUID', + }), + ).toMatchObject({ + serial: 'LEGACY-UUID', + }); + }); +}); diff --git a/apps/cli/src/__tests__/evm-signer-hardware.test.ts b/apps/cli/src/__tests__/evm-signer-hardware.test.ts index 53e25b3022ab..8a147ff51141 100644 --- a/apps/cli/src/__tests__/evm-signer-hardware.test.ts +++ b/apps/cli/src/__tests__/evm-signer-hardware.test.ts @@ -7,7 +7,7 @@ * - passphrase mode 'none' → useEmptyPassphrase branch, no keychain reads * - passphrase mode 'on_host' → keychain preload hits session cache * - passphrase mode 'on_host' with deviceWasLocked → keychain skipped, - * resolvePassphraseStateByMode fallback, result re-persisted + * resolvePassphraseSessionByMode fallback, result re-persisted * - buildHardwareEvmTransaction + buildSignedTxFromSignatureEvm wired in * for both EIP-1559 and legacy shapes * - signMessage throws when path missing @@ -45,9 +45,8 @@ const MOCK_SID_FROM_KEYCHAIN = 'sess_N1fKj3BvP4kRZ'; const MOCK_STALE_PS = 'stalePsXyz7HkLm9'; const MOCK_STALE_SID = 'staleSidAbc4JkRp'; const MOCK_RESOLVED_PS = 'freshResolveWxYz23'; -// Returned by sdk.searchDevices() after a fresh getPassphraseState — this -// is the session_id that persistPassphraseState must capture and write to -// the keychain to replace the now-invalid stale one. +// Returned by getPassphraseState after a fresh hidden-wallet resolve. The +// session_id must be written with the matching passphraseState. const MOCK_FRESH_SID_AFTER_RESOLVE = 'sess_freshAfterUnlockK7'; const DEVICE: DeviceInfo = { @@ -71,7 +70,7 @@ function makeDeps( keychainGet: jest.Mock; keychainSet: jest.Mock; installPassphraseProvider: jest.Mock; - resolvePassphraseStateByMode: jest.Mock; + resolvePassphraseSessionByMode: jest.Mock; preloadSessionCache: jest.Mock; stderrWrite: jest.Mock; }; @@ -90,21 +89,16 @@ function makeDeps( }); const sdk = { - getFeatures: jest.fn(async () => - makeSuccess({ unlocked: overrides.unlocked ?? true }), + getDeviceState: jest.fn(async () => + makeSuccess({ status: { unlocked: overrides.unlocked ?? true } }), ), deviceUnlock: jest.fn(async () => makeSuccess({})), - // searchDevices is invoked by persistPassphraseState to discover the - // session_id the device just minted for the freshly-resolved passphrase. searchDevices: jest.fn(async () => makeSuccess([ { connectId: DEVICE.connectId, deviceId: DEVICE.deviceId, - features: { - device_id: DEVICE.deviceId, - session_id: MOCK_FRESH_SID_AFTER_RESOLVE, - }, + sessionId: MOCK_FRESH_SID_AFTER_RESOLVE, }, ]), ), @@ -125,8 +119,12 @@ function makeDeps( // passphraseState from the SDK is an opaque ASCII token (base58-ish, // not hex). The default here matches the real-world shape so the // keychain utf-8 round-trip stays honest under test. - const resolvePassphraseStateByMode = - overrides.resolveByMode ?? jest.fn(async () => MOCK_RESOLVED_PS); + const resolvePassphraseSessionByMode = + overrides.resolveByMode ?? + jest.fn(async () => ({ + passphraseState: MOCK_RESOLVED_PS, + sessionId: MOCK_FRESH_SID_AFTER_RESOLVE, + })); const preloadSessionCache = overrides.preloadSessionCache ?? jest.fn(); const stderrWrite = jest.fn(() => true); @@ -134,8 +132,8 @@ function makeDeps( ensureSDKReady: ensureSDKReady as unknown as ISignerHardwareDeps['ensureSDKReady'], installPassphraseProvider, - resolvePassphraseStateByMode: - resolvePassphraseStateByMode as unknown as ISignerHardwareDeps['resolvePassphraseStateByMode'], + resolvePassphraseSessionByMode: + resolvePassphraseSessionByMode as unknown as ISignerHardwareDeps['resolvePassphraseSessionByMode'], keychainFactory: () => ({ get: keychainGet, set: keychainSet, @@ -152,7 +150,7 @@ function makeDeps( keychainGet, keychainSet, installPassphraseProvider, - resolvePassphraseStateByMode, + resolvePassphraseSessionByMode, preloadSessionCache, stderrWrite, }, @@ -229,7 +227,7 @@ describe('SignerHardware', () => { expect(addr).toEqual({ address: '0xabc', path: "m/44'/60'/0'/0/0" }); expect(mocks.keychainGet).not.toHaveBeenCalled(); - expect(mocks.resolvePassphraseStateByMode).not.toHaveBeenCalled(); + expect(mocks.resolvePassphraseSessionByMode).not.toHaveBeenCalled(); expect(mocks.preloadSessionCache).not.toHaveBeenCalled(); const callArgs = mocks.sdk.evmGetAddress.mock.calls[0]; @@ -280,7 +278,7 @@ describe('SignerHardware', () => { MOCK_PS_FROM_KEYCHAIN, MOCK_SID_FROM_KEYCHAIN, ); - expect(mocks.resolvePassphraseStateByMode).not.toHaveBeenCalled(); + expect(mocks.resolvePassphraseSessionByMode).not.toHaveBeenCalled(); const params = mocks.sdk.evmGetAddress.mock.calls[0][2]; expect(params).toMatchObject({ passphraseState: MOCK_PS_FROM_KEYCHAIN, @@ -309,8 +307,9 @@ describe('SignerHardware', () => { await signer.getAddress('evm--1'); expect(mocks.sdk.deviceUnlock).toHaveBeenCalledWith(DEVICE.connectId, {}); - expect(mocks.resolvePassphraseStateByMode).toHaveBeenCalledWith( + expect(mocks.resolvePassphraseSessionByMode).toHaveBeenCalledWith( DEVICE.connectId, + DEVICE.deviceId, 'on_host', ); @@ -328,8 +327,9 @@ describe('SignerHardware', () => { Buffer.from(MOCK_FRESH_SID_AFTER_RESOLVE, 'utf-8'), ); - // searchDevices is the source of the fresh session_id post-resolve. - expect(mocks.sdk.searchDevices).toHaveBeenCalled(); + // session_id now comes from the getPassphraseState payload directly. + // searchDevices may still run during signer init for connectId refresh, + // but it is no longer the source of the freshly resolved session_id. // After persisting, warm the SDK in-process cache with the new // (deviceId, freshPassphraseState, freshSessionId) triple so any @@ -358,8 +358,9 @@ describe('SignerHardware', () => { await signer.getAddress('evm--1'); - expect(mocks.resolvePassphraseStateByMode).toHaveBeenCalledWith( + expect(mocks.resolvePassphraseSessionByMode).toHaveBeenCalledWith( DEVICE.connectId, + DEVICE.deviceId, 'on_host', ); // Both keys must be written so the next process can preload a valid @@ -376,7 +377,7 @@ describe('SignerHardware', () => { it('throws when hidden wallet resolve returns undefined instead of silently using standard wallet', async () => { const { deps } = makeDeps({ - resolveByMode: jest.fn(async () => undefined), + resolveByMode: jest.fn(async () => ({})), }); const signer = new SignerHardware({ device: DEVICE, @@ -563,10 +564,7 @@ describe('SignerHardware', () => { { connectId: FRESH_CONNECT_ID, deviceId: DEVICE.deviceId, - features: { - device_id: DEVICE.deviceId, - session_id: MOCK_FRESH_SID_AFTER_RESOLVE, - }, + sessionId: MOCK_FRESH_SID_AFTER_RESOLVE, }, ]), ), @@ -620,7 +618,6 @@ describe('SignerHardware', () => { { connectId: 'other-device-connect', deviceId: 'other-device-id', - features: { device_id: 'other-device-id' }, }, ]), ), diff --git a/apps/cli/src/__tests__/hardware-login-command.test.ts b/apps/cli/src/__tests__/hardware-login-command.test.ts index 48e4536ca1a8..fbcf14bac360 100644 --- a/apps/cli/src/__tests__/hardware-login-command.test.ts +++ b/apps/cli/src/__tests__/hardware-login-command.test.ts @@ -7,7 +7,7 @@ jest.mock('../commands/device/hardware-sdk', () => ({ __testMocks: { mockSearchDevice: jest.fn(), mockEnsureSDKReady: jest.fn(), - mockResolvePassphraseState: jest.fn(), + mockResolvePassphraseSession: jest.fn(), mockUnwrapSDKResult: jest.fn( (result: { success: boolean; payload: T }): T => { return result.payload; @@ -22,10 +22,13 @@ jest.mock('../commands/device/hardware-sdk', () => ({ .__testMocks as IHardwareSdkTestMocks; return mocks.mockEnsureSDKReady(...args) as Promise; }, - resolvePassphraseState: (...args: unknown[]) => { + resolvePassphraseSession: (...args: unknown[]) => { const mocks = jest.requireMock('../commands/device/hardware-sdk') .__testMocks as IHardwareSdkTestMocks; - return mocks.mockResolvePassphraseState(...args) as Promise; + return mocks.mockResolvePassphraseSession(...args) as Promise<{ + passphraseState?: string; + sessionId?: string; + }>; }, searchDevice: (...args: unknown[]) => { const mocks = jest.requireMock('../commands/device/hardware-sdk') @@ -42,7 +45,7 @@ jest.mock('../commands/device/hardware-sdk', () => ({ interface IHardwareSdkTestMocks { mockSearchDevice: jest.Mock; mockEnsureSDKReady: jest.Mock; - mockResolvePassphraseState: jest.Mock; + mockResolvePassphraseSession: jest.Mock; mockUnwrapSDKResult: jest.Mock; } @@ -99,12 +102,17 @@ describe('executeHardwareLoginCommand passphrase mode selection', () => { deviceId: 'device-1', }); hardwareSdkMocks.mockEnsureSDKReady.mockResolvedValue({ - getFeatures: jest.fn(async () => ({ + getDeviceState: jest.fn(async () => ({ success: true, payload: { - label: 'OneKey', - unlocked: true, - passphrase_protection: true, + identity: { + label: 'OneKey', + deviceType: 'unknown', + }, + status: { + unlocked: true, + passphraseProtection: true, + }, }, })), evmGetAddress: jest.fn(async () => ({ @@ -137,7 +145,9 @@ describe('executeHardwareLoginCommand passphrase mode selection', () => { 'Hardware passphrase protection is enabled, but this command cannot prompt for wallet type.', }); - expect(hardwareSdkMocks.mockResolvePassphraseState).not.toHaveBeenCalled(); + expect( + hardwareSdkMocks.mockResolvePassphraseSession, + ).not.toHaveBeenCalled(); expect(persistSession).not.toHaveBeenCalled(); }); @@ -177,17 +187,102 @@ describe('executeHardwareLoginCommand passphrase mode selection', () => { expect(output.success).toHaveBeenCalled(); }); + it('uses sessionId returned by the unified wallet-session flow for hidden-wallet login', async () => { + const output = makeOutputMock(); + const getStatus = jest + .fn() + .mockResolvedValueOnce(makeUnauthenticatedStatus()) + .mockResolvedValueOnce(makeAuthenticatedStatus()); + const persistSession = jest.fn(async () => undefined); + hardwareSdkMocks.mockResolvePassphraseSession.mockResolvedValue({ + passphraseState: 'state-1', + sessionId: 'session-1', + }); + + await executeHardwareLoginCommand({ + output: output as OutputFormatter, + isTTY: false, + isHumanMode: false, + passphraseMode: 'on-device', + getStatus, + persistSession, + }); + + const sdk = await hardwareSdkMocks.mockEnsureSDKReady.mock.results[0].value; + expect(hardwareSdkMocks.mockResolvePassphraseSession).toHaveBeenCalledWith( + 'connect-1', + { expectedDeviceId: 'device-1', passphraseOnDevice: true }, + ); + expect(sdk.searchDevices).not.toHaveBeenCalled(); + expect(sdk.evmGetAddress).toHaveBeenCalledWith( + 'connect-1', + 'device-1', + expect.objectContaining({ + passphraseState: 'state-1', + }), + ); + expect(persistSession).toHaveBeenCalledWith( + expect.objectContaining({ + passphraseState: 'state-1', + sessionId: 'session-1', + }), + ); + }); + + it('allows a V2 hidden-wallet login when the SDK does not expose a V1 session id', async () => { + const output = makeOutputMock(); + const getStatus = jest + .fn() + .mockResolvedValueOnce(makeUnauthenticatedStatus()) + .mockResolvedValueOnce(makeAuthenticatedStatus()); + const persistSession = jest.fn(async () => undefined); + hardwareSdkMocks.mockResolvePassphraseSession.mockResolvedValue({ + deviceId: 'device-1', + passphraseState: 'state-1', + protocol: 'V2', + }); + + await executeHardwareLoginCommand({ + output: output as OutputFormatter, + isTTY: false, + isHumanMode: false, + passphraseMode: 'on-device', + getStatus, + persistSession, + }); + + const sdk = await hardwareSdkMocks.mockEnsureSDKReady.mock.results[0].value; + expect(sdk.evmGetAddress).toHaveBeenCalledWith( + 'connect-1', + 'device-1', + expect.objectContaining({ + passphraseState: 'state-1', + }), + ); + expect(persistSession).toHaveBeenCalledWith( + expect.objectContaining({ + passphraseState: 'state-1', + sessionId: undefined, + }), + ); + }); + it('rejects explicit hidden-wallet mode when device passphrase protection is disabled', async () => { const output = makeOutputMock(); const getStatus = jest.fn(async () => makeUnauthenticatedStatus()); const persistSession = jest.fn(async () => undefined); hardwareSdkMocks.mockEnsureSDKReady.mockResolvedValueOnce({ - getFeatures: jest.fn(async () => ({ + getDeviceState: jest.fn(async () => ({ success: true, payload: { - label: 'OneKey', - unlocked: true, - passphrase_protection: false, + identity: { + label: 'OneKey', + deviceType: 'unknown', + }, + status: { + unlocked: true, + passphraseProtection: false, + }, }, })), evmGetAddress: jest.fn(), @@ -209,7 +304,9 @@ describe('executeHardwareLoginCommand passphrase mode selection', () => { 'Device passphrase protection is disabled, so hidden-wallet passphrase mode is unavailable.', }); - expect(hardwareSdkMocks.mockResolvePassphraseState).not.toHaveBeenCalled(); + expect( + hardwareSdkMocks.mockResolvePassphraseSession, + ).not.toHaveBeenCalled(); expect(persistSession).not.toHaveBeenCalled(); }); }); diff --git a/apps/cli/src/__tests__/hardware-sdk-queue.test.ts b/apps/cli/src/__tests__/hardware-sdk-queue.test.ts index 256f0dab14f8..fd6549cf3f56 100644 --- a/apps/cli/src/__tests__/hardware-sdk-queue.test.ts +++ b/apps/cli/src/__tests__/hardware-sdk-queue.test.ts @@ -1,6 +1,13 @@ import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; -import { createQueuedHardwareSDK } from '../commands/device/hardware-sdk'; +import { + createQueuedHardwareSDK, + extractPassphraseSessionFromPayload, + extractPassphraseStateFromPayload, + openHiddenWalletSession, +} from '../commands/device/hardware-sdk'; + +import type { CoreApi } from '@onekeyfe/hd-core'; function createDeferred() { let resolve!: (value: T | PromiseLike) => void; @@ -123,3 +130,175 @@ describe('createQueuedHardwareSDK', () => { ]); }); }); + +describe('extractPassphraseStateFromPayload', () => { + it('reads passphraseState from the SDK string payload', () => { + const payload = 'state-1'; + expect(extractPassphraseStateFromPayload(payload)).toBe('state-1'); + expect(extractPassphraseSessionFromPayload(payload)).toEqual({ + passphraseState: 'state-1', + }); + }); + + it('reads passphraseState from the unified wallet-session payload', () => { + const payload = { + deviceId: 'device-1', + walletType: 'hidden', + passphraseState: 'state-2', + sessionId: 'session-2', + resumed: false, + }; + expect(extractPassphraseStateFromPayload(payload)).toBe('state-2'); + expect(extractPassphraseSessionFromPayload(payload)).toEqual({ + deviceId: 'device-1', + passphraseState: 'state-2', + sessionId: 'session-2', + }); + }); + + it('returns the hardware session pair for the standard wallet payload', () => { + expect( + extractPassphraseSessionFromPayload({ + passphraseState: 'standard-state', + sessionId: 'standard-session', + }), + ).toEqual({ + passphraseState: 'standard-state', + sessionId: 'standard-session', + }); + }); +}); + +describe('openHiddenWalletSession', () => { + it('uses the supported hidden-wallet mode and accepts the real V2 payload', async () => { + const searchDevices = jest.fn(); + const openWalletSession = jest.fn().mockResolvedValue({ + success: true, + payload: { + protocol: 'V2', + walletType: 'hidden', + deviceId: 'device-1', + passphraseState: 'state-1', + resumed: false, + }, + }); + + await expect( + openHiddenWalletSession({ + sdk: { + openWalletSession, + searchDevices, + } as unknown as CoreApi, + connectId: 'connect-1', + expectedDeviceId: 'device-1', + }), + ).resolves.toEqual({ + deviceId: 'device-1', + passphraseState: 'state-1', + protocol: 'V2', + }); + expect(openWalletSession).toHaveBeenCalledWith('connect-1', { + mode: 'select-hidden', + }); + expect(searchDevices).not.toHaveBeenCalled(); + }); + + it('reads the V1 compatibility session id from the refreshed device features', async () => { + const sdk = { + openWalletSession: jest.fn().mockResolvedValue({ + success: true, + payload: { + protocol: 'V1', + walletType: 'hidden', + deviceId: 'device-1', + passphraseState: 'state-1', + resumed: false, + }, + }), + searchDevices: jest.fn().mockResolvedValue({ + success: true, + payload: [ + { + connectId: 'connect-1', + deviceId: 'device-1', + features: { sessionId: 'session-1' }, + }, + ], + }), + }; + + await expect( + openHiddenWalletSession({ + sdk: sdk as unknown as CoreApi, + connectId: 'connect-1', + expectedDeviceId: 'device-1', + }), + ).resolves.toEqual({ + deviceId: 'device-1', + passphraseState: 'state-1', + protocol: 'V1', + sessionId: 'session-1', + }); + expect(sdk.searchDevices).toHaveBeenCalledTimes(1); + }); + + it('rejects a hidden-wallet session returned by another device', async () => { + const sdk = { + openWalletSession: jest.fn().mockResolvedValue({ + success: true, + payload: { + protocol: 'V2', + walletType: 'hidden', + deviceId: 'unexpected-device', + passphraseState: 'state-1', + }, + }), + searchDevices: jest.fn(), + }; + + await expect( + openHiddenWalletSession({ + sdk: sdk as unknown as CoreApi, + connectId: 'connect-1', + expectedDeviceId: 'device-1', + }), + ).rejects.toMatchObject({ code: 'AUTH_SESSION_INVALID' }); + }); + + it('resolves a V1 compatibility session only by the expected stable device id', async () => { + const sdk = { + openWalletSession: jest.fn().mockResolvedValue({ + success: true, + payload: { + protocol: 'V1', + walletType: 'hidden', + deviceId: 'device-1', + passphraseState: 'state-1', + }, + }), + searchDevices: jest.fn().mockResolvedValue({ + success: true, + payload: [ + { + connectId: 'connect-1', + deviceId: 'other-device', + features: { sessionId: 'wrong-session' }, + }, + { + connectId: 'reconnected-alias', + deviceId: 'device-1', + features: { sessionId: 'expected-session' }, + }, + ], + }), + }; + + await expect( + openHiddenWalletSession({ + sdk: sdk as unknown as CoreApi, + connectId: 'connect-1', + expectedDeviceId: 'device-1', + }), + ).resolves.toMatchObject({ sessionId: 'expected-session' }); + }); +}); diff --git a/apps/cli/src/commands/auth/hardware-login-command.ts b/apps/cli/src/commands/auth/hardware-login-command.ts index b609c2d73858..4b2a7559c33d 100644 --- a/apps/cli/src/commands/auth/hardware-login-command.ts +++ b/apps/cli/src/commands/auth/hardware-login-command.ts @@ -13,7 +13,7 @@ import { promptPassphraseViaPinentry } from '../../utils/pinentry'; import { CoreSDKLoader, ensureSDKReady, - resolvePassphraseState, + resolvePassphraseSession, searchDevice, unwrapSDKResult, } from '../device/hardware-sdk'; @@ -179,26 +179,26 @@ export async function executeHardwareLoginCommand({ output.info('Searching for OneKey hardware device...'); const { connectId, deviceId } = await searchDevice({ deviceIdHint }); - // Get device features for label + // Read identity and dynamic status from the unified device state. const sdk = await ensureSDKReady(); - const featuresResult = await sdk.getFeatures(connectId); - let features = unwrapSDKResult(featuresResult, 'getFeatures') as { - label?: string; - device_id?: string; - model?: string; - unlocked?: boolean | null; - passphrase_protection?: boolean | null; + const getDeviceState = async () => { + const deviceStateResult = await sdk.getDeviceState(connectId); + return unwrapSDKResult(deviceStateResult, 'getDeviceState'); }; + let deviceState = await getDeviceState(); - // Unlock if locked (matches app-monorepo ServiceHardware.getFeaturesWithUnlock) - if (features.unlocked === false) { + // Unlock if locked, then refresh the canonical state. + if (deviceState.status?.unlocked === false) { output.info('Device is locked. Please enter PIN on device...'); const unlockResult = await sdk.deviceUnlock(connectId, {}); - features = unwrapSDKResult(unlockResult, 'deviceUnlock') as typeof features; + unwrapSDKResult(unlockResult, 'deviceUnlock'); + deviceState = await getDeviceState(); } const deviceLabel = - features.label || features.model || `OneKey-${deviceId.slice(0, 8)}`; + deviceState.identity.label || + deviceState.identity.deviceType || + `OneKey-${deviceId.slice(0, 8)}`; output.info(`Found device: ${deviceLabel} (${deviceId})`); @@ -207,11 +207,10 @@ export async function executeHardwareLoginCommand({ // Only offer the hidden-wallet choice when the device has passphrase // protection turned on. If it's off, a hidden wallet cannot be derived on // this device — prompting would just trap the user into invalid choices. - // Mirrors app-monorepo's `Boolean(features.passphrase_protection)` gate in - // DeviceSettingsManager. + // Keep passphrase-protection detection aligned with DeviceSettingsManager. let passphraseMode: PassphraseMode = PASSPHRASE_MODE_NONE; let passphraseState: string | undefined; - const passphraseEnabled = Boolean(features.passphrase_protection); + const passphraseEnabled = Boolean(deviceState.status?.passphraseProtection); const requestedPassphraseMode = assertValidExplicitPassphraseMode( normalizeExplicitPassphraseMode(explicitPassphraseMode), ); @@ -244,27 +243,34 @@ export async function executeHardwareLoginCommand({ ); } - // Step 3: Resolve passphraseState in memory (never persisted) + // Step 3: Resolve passphrase session in memory (never persisted) + let resolvedSessionId: string | undefined; if (passphraseMode === PASSPHRASE_MODE_ON_HOST) { // Use pinentry for secure passphrase input — no terminal echo, no shell history const passphrase = await promptPassphraseViaPinentry(); output.info('Resolving passphrase state on device...'); - passphraseState = await resolvePassphraseState(connectId, { + const session = await resolvePassphraseSession(connectId, { + expectedDeviceId: deviceId, passphrase, }); + passphraseState = session.passphraseState; + resolvedSessionId = session.sessionId; // passphrase string is now eligible for GC — we only keep passphraseState in memory } else if (passphraseMode === PASSPHRASE_MODE_ON_DEVICE) { output.info('Please enter passphrase on device screen...'); - passphraseState = await resolvePassphraseState(connectId, { + const session = await resolvePassphraseSession(connectId, { + expectedDeviceId: deviceId, passphraseOnDevice: true, }); + passphraseState = session.passphraseState; + resolvedSessionId = session.sessionId; } // passphraseMode === PASSPHRASE_MODE_NONE → no passphrase needed if (passphraseMode !== PASSPHRASE_MODE_NONE && !passphraseState) { throw new AppError( ERROR_CODES.AUTH_SESSION_INVALID.code, - `Failed to resolve passphrase state for mode "${passphraseMode}".`, + `Failed to resolve wallet session for mode "${passphraseMode}".`, 'Retry hardware login and confirm passphrase entry.', ); } @@ -274,37 +280,13 @@ export async function executeHardwareLoginCommand({ // Keychain persistence is deferred to Step 7 (after session.json is // saved) so a failure in getAddress or session write doesn't leave // orphaned keychain entries. - let resolvedSessionId: string | undefined; - if (passphraseState) { - // Get session_id from device features (set by resolvePassphraseState). - // Match by the `connectId` captured in Step 1 — never `refreshedDevices[0]`, - // which would write another device's session into this login's keychain - // when multiple OneKeys are plugged in. - const refreshResult = await sdk.searchDevices(); - const refreshedDevices = unwrapSDKResult( - refreshResult, - 'searchDevices', - ) as Array<{ - connectId?: string; - features?: { session_id?: string; device_id?: string }; - }>; - const targetDevice = refreshedDevices.find( - (d) => d.connectId === connectId, - ); - resolvedSessionId = targetDevice?.features?.session_id; - const resolvedDeviceId = targetDevice?.features?.device_id || deviceId; - if (resolvedSessionId) { - // In-memory only — no keychain write yet - try { - const { preloadSessionCache } = await CoreSDKLoader(); - preloadSessionCache( - resolvedDeviceId, - passphraseState, - resolvedSessionId, - ); - } catch { - // non-fatal - } + if (passphraseState && resolvedSessionId) { + // In-memory only — no keychain write yet + try { + const { preloadSessionCache } = await CoreSDKLoader(); + preloadSessionCache(deviceId, passphraseState, resolvedSessionId); + } catch { + // non-fatal } } diff --git a/apps/cli/src/commands/device/device-search.ts b/apps/cli/src/commands/device/device-search.ts index 6559645bb17e..9547c355efd9 100644 --- a/apps/cli/src/commands/device/device-search.ts +++ b/apps/cli/src/commands/device/device-search.ts @@ -6,20 +6,43 @@ import type { OutputFormatter } from '../../output'; import type { Command } from 'commander'; /** Minimal shape of a device returned by sdk.searchDevices() */ -interface ISearchedDevice { +export interface ISearchedDevice { connectId?: string; deviceId?: string; + deviceType?: string; + serialNo?: string | null; + uuid?: string; name?: string; + displayName?: string; label?: string; + firmwareVersion?: [number, number, number] | null; features?: { - onekey_device_type?: string; - onekey_serial?: string; - onekey_firmware_version?: string; + deviceType?: string; + serialNo?: string; + firmwareVersion?: string; unlocked?: boolean; - passphrase_protection?: boolean; + passphraseProtection?: boolean; }; } +export function formatSearchedDevice(d: ISearchedDevice) { + return { + connectId: d.connectId, + deviceId: d.deviceId ?? '', + name: d.displayName ?? d.name ?? d.label ?? 'Unknown', + model: d.deviceType ?? d.features?.deviceType ?? 'Unknown', + serial: d.serialNo ?? d.uuid ?? d.features?.serialNo ?? '', + firmware: + formatVersion(d.firmwareVersion) || d.features?.firmwareVersion || '', + unlocked: d.features?.unlocked ?? null, + passphraseProtection: d.features?.passphraseProtection ?? false, + }; +} + +function formatVersion(version?: [number, number, number] | null): string { + return Array.isArray(version) ? version.join('.') : ''; +} + export function registerDeviceSearchCommand(parent: Command): void { parent .command('search') @@ -38,16 +61,9 @@ export function registerDeviceSearchCommand(parent: Command): void { return; } - const formatted = (devices as ISearchedDevice[]).map((d) => ({ - connectId: d.connectId, - deviceId: d.deviceId, - name: d.name ?? d.label ?? 'Unknown', - model: d.features?.onekey_device_type ?? 'Unknown', - serial: d.features?.onekey_serial ?? '', - firmware: d.features?.onekey_firmware_version ?? '', - unlocked: d.features?.unlocked ?? null, - passphraseProtection: d.features?.passphrase_protection ?? false, - })); + const formatted = (devices as ISearchedDevice[]).map( + formatSearchedDevice, + ); output.success({ devices: formatted, count: formatted.length }); } catch (error) { diff --git a/apps/cli/src/commands/device/hardware-sdk.ts b/apps/cli/src/commands/device/hardware-sdk.ts index c16365b43669..8f26c22e9d66 100644 --- a/apps/cli/src/commands/device/hardware-sdk.ts +++ b/apps/cli/src/commands/device/hardware-sdk.ts @@ -11,6 +11,123 @@ import { AppError, ERROR_CODES } from '../../errors'; import type { PassphraseMode } from '../../core/auth/auth-types'; import type { CoreApi } from '@onekeyfe/hd-core'; +export type IResolvedPassphraseSession = { + deviceId?: string; + passphraseState?: string; + protocol?: 'V1' | 'V2'; + sessionId?: string; +}; + +export function extractPassphraseSessionFromPayload( + payload: + | string + | { + deviceId?: string | null; + passphraseState?: string | null; + protocol?: 'V1' | 'V2'; + sessionId?: string | null; + [key: string]: unknown; + } + | undefined, +): IResolvedPassphraseSession { + const objectPayload = + typeof payload === 'object' && payload ? payload : undefined; + return { + ...(objectPayload?.deviceId ? { deviceId: objectPayload.deviceId } : {}), + passphraseState: + typeof payload === 'string' + ? payload || undefined + : payload?.passphraseState || undefined, + ...(objectPayload?.protocol ? { protocol: objectPayload.protocol } : {}), + sessionId: + typeof payload === 'object' && payload + ? payload.sessionId || undefined + : undefined, + }; +} + +export function extractPassphraseStateFromPayload( + payload: + | string + | { + passphraseState?: string | null; + [key: string]: unknown; + } + | undefined, +): string | undefined { + return extractPassphraseSessionFromPayload(payload).passphraseState; +} + +export async function openHiddenWalletSession({ + sdk, + connectId, + expectedDeviceId, +}: { + sdk: CoreApi; + connectId: string; + expectedDeviceId: string; +}): Promise { + const result = await sdk.openWalletSession(connectId, { + mode: 'select-hidden', + }); + if (!result.success) { + const err = result.payload as { error?: string; code?: string | number }; + throw new AppError( + ERROR_CODES.BIZ_UNKNOWN.code, + `openWalletSession failed: ${err.error ?? 'unknown'} (code ${ + err.code ?? '?' + })`, + 'Check device connection and passphrase, then retry', + ); + } + + if (result.payload.walletType !== 'hidden') { + throw new AppError( + ERROR_CODES.AUTH_SESSION_INVALID.code, + 'openWalletSession did not select a hidden wallet', + 'Update the hardware SDK and retry', + ); + } + + const session = extractPassphraseSessionFromPayload(result.payload); + if (session.deviceId !== expectedDeviceId) { + throw new AppError( + ERROR_CODES.AUTH_SESSION_INVALID.code, + 'openWalletSession returned a session for an unexpected device', + 'Reconnect the expected hardware device and retry', + ); + } + if (!session.passphraseState) { + throw new AppError( + ERROR_CODES.AUTH_SESSION_INVALID.code, + 'openWalletSession returned an incomplete wallet session', + 'Update the hardware SDK and retry', + ); + } + + if (session.protocol === 'V1') { + const refreshedDevices = await sdk.searchDevices(); + if (refreshedDevices.success) { + const devices = refreshedDevices.payload as unknown as Array<{ + connectId?: string | null; + deviceId?: string | null; + features?: { + sessionId?: string | null; + session_id?: string | null; + }; + }>; + const targetDevice = devices.find( + (device) => device.deviceId === expectedDeviceId, + ); + session.sessionId = + targetDevice?.features?.sessionId ?? + targetDevice?.features?.session_id ?? + undefined; + } + } + return session; +} + /** * CLI-local analogue of `@onekeyhq/shared` `CoreSDKLoader`. * @@ -119,7 +236,7 @@ export async function disposeSDK(): Promise { if (!sdkReadyPromise) return; try { const sdk = await sdkReadyPromise; - sdk.dispose(); + await sdk.dispose(); } catch { // ignore errors during cleanup } finally { @@ -284,7 +401,9 @@ export function unwrapSDKResult( const err = result.payload as { error?: string; code?: string | number }; throw new AppError( ERROR_CODES.BIZ_UNKNOWN.code, - `Hardware ${operation} failed: ${err.error ?? 'unknown'} (code ${err.code ?? '?'})`, + `Hardware ${operation} failed: ${err.error ?? 'unknown'} (code ${ + err.code ?? '?' + })`, 'Check device connection and try again', ); } @@ -345,19 +464,20 @@ export async function searchDevice(opts?: { deviceIdHint?: string }): Promise<{ } /** - * Obtain a passphraseState session token from the device. + * Obtain a passphrase session token pair from the device. * - * Matches the app-monorepo pattern (ServiceHardware.getPassphraseStateBase): - * - Calls sdk.getPassphraseState with initSession=true so the device - * prompts for passphrase entry (host input or on-device input). - * - Returns the session token that must be passed in all subsequent - * SDK calls for this hidden wallet (replaces re-sending the passphrase). - * - Returns undefined for standard wallets (no passphrase). + * Uses the unified V1/V2 wallet-session API and consumes the exact + * passphraseState/sessionId pair returned by the hardware SDK. + * - Returns an empty object for standard wallets (no passphrase). */ -export async function resolvePassphraseState( +export async function resolvePassphraseSession( connectId: string, - opts: { passphrase?: string; passphraseOnDevice?: boolean }, -): Promise { + opts: { + expectedDeviceId: string; + passphrase?: string; + passphraseOnDevice?: boolean; + }, +): Promise { // BIP-39 treats an empty-string passphrase as a distinct hidden wallet // from the standard (no-passphrase) wallet. A falsy check would silently // map `{ passphrase: '' }` onto the standard wallet and derive the wrong @@ -371,7 +491,7 @@ export async function resolvePassphraseState( ); } if (opts.passphrase === undefined && !opts.passphraseOnDevice) { - return undefined; // standard wallet — no passphrase needed + return {}; // standard wallet — no passphrase needed } const sdk = await ensureSDKReady(); @@ -382,32 +502,32 @@ export async function resolvePassphraseState( })); try { - // Matches app-monorepo ServiceHardware.getPassphraseStateBase: - // initSession: true → force device to prompt for passphrase - // useEmptyPassphrase: false → this IS a hidden wallet session - const result = await sdk.getPassphraseState(connectId, { - initSession: true, - useEmptyPassphrase: false, + return await openHiddenWalletSession({ + sdk, + connectId, + expectedDeviceId: opts.expectedDeviceId, }); - if (!result.success) { - const err = result.payload as { error?: string; code?: string | number }; - throw new AppError( - ERROR_CODES.BIZ_UNKNOWN.code, - `getPassphraseState failed: ${err.error ?? 'unknown'} (code ${err.code ?? '?'})`, - 'Check device connection and passphrase, then retry', - ); - } - // SDK returns the passphraseState token (a short hex string like "abc12345") - return typeof result.payload === 'string' ? result.payload : undefined; } finally { setPassphraseProvider(undefined); } } +export async function resolvePassphraseState( + connectId: string, + opts: { + expectedDeviceId: string; + passphrase?: string; + passphraseOnDevice?: boolean; + }, +): Promise { + const session = await resolvePassphraseSession(connectId, opts); + return session.passphraseState; +} + /** - * Resolve passphraseState based on session mode. + * Resolve passphrase session based on session mode. * - * Unlike resolvePassphraseState(), this function does NOT require the + * Unlike resolvePassphraseSession(), this function does NOT require the * passphrase value upfront. Instead, it sets up a lazy provider: * * - 'none': standard wallet → useEmptyPassphrase, no prompt ever @@ -418,14 +538,16 @@ export async function resolvePassphraseState( * provider tells device to show passphrase input on its screen. * * SECURITY: passphrase exists only in memory during provider callback. - * passphraseState is returned in memory, never persisted to disk. + * passphraseState is returned in memory; persistence is handled by the caller + * only after the downstream operation succeeds. */ -export async function resolvePassphraseStateByMode( +export async function resolvePassphraseSessionByMode( connectId: string, + expectedDeviceId: string, mode: PassphraseMode, -): Promise { +): Promise { if (mode === 'none') { - return undefined; + return {}; } const sdk = await ensureSDKReady(); @@ -447,19 +569,7 @@ export async function resolvePassphraseStateByMode( } try { - const result = await sdk.getPassphraseState(connectId, { - initSession: true, - useEmptyPassphrase: false, - }); - if (!result.success) { - const err = result.payload as { error?: string; code?: string | number }; - throw new AppError( - ERROR_CODES.BIZ_UNKNOWN.code, - `getPassphraseState failed: ${err.error ?? 'unknown'} (code ${err.code ?? '?'})`, - 'Check device connection and passphrase, then retry', - ); - } - return typeof result.payload === 'string' ? result.payload : undefined; + return await openHiddenWalletSession({ sdk, connectId, expectedDeviceId }); } finally { // Don't clear provider here — keep it active for subsequent SDK calls. // The SDK fires REQUEST_PASSPHRASE on every new USB connection, so the @@ -467,6 +577,19 @@ export async function resolvePassphraseStateByMode( } } +export async function resolvePassphraseStateByMode( + connectId: string, + expectedDeviceId: string, + mode: PassphraseMode, +): Promise { + const session = await resolvePassphraseSessionByMode( + connectId, + expectedDeviceId, + mode, + ); + return session.passphraseState; +} + /** * Install a persistent passphrase provider for the current process. * diff --git a/apps/cli/src/signer/base/SignerHardwareBase.ts b/apps/cli/src/signer/base/SignerHardwareBase.ts index 918ab0728d12..f061f10a891e 100644 --- a/apps/cli/src/signer/base/SignerHardwareBase.ts +++ b/apps/cli/src/signer/base/SignerHardwareBase.ts @@ -8,7 +8,7 @@ import { CoreSDKLoader, ensureSDKReady, installPassphraseProvider, - resolvePassphraseStateByMode, + resolvePassphraseSessionByMode, } from '../../commands/device/hardware-sdk'; import { PASSPHRASE_MODE_NONE } from '../../core/auth/auth-types'; import { AppError, ERROR_CODES } from '../../errors'; @@ -31,7 +31,7 @@ import type { CoreApi } from '@onekeyfe/hd-core'; export interface ISignerHardwareDeps { ensureSDKReady: typeof ensureSDKReady; installPassphraseProvider: typeof installPassphraseProvider; - resolvePassphraseStateByMode: typeof resolvePassphraseStateByMode; + resolvePassphraseSessionByMode: typeof resolvePassphraseSessionByMode; keychainFactory: () => { get(key: string): Promise; set(key: string, value: Buffer): Promise; @@ -55,7 +55,7 @@ export function createDefaultSignerHardwareDeps(): ISignerHardwareDeps { return { ensureSDKReady, installPassphraseProvider, - resolvePassphraseStateByMode, + resolvePassphraseSessionByMode, keychainFactory: () => new KeychainStorage(), preloadSessionCache: async (deviceId, passphraseState, sessionId) => { const { preloadSessionCache } = await CoreSDKLoader(); @@ -168,15 +168,16 @@ export abstract class SignerHardwareBase implements ISigner { return fromKeychain; } - const fresh = await this.deps.resolvePassphraseStateByMode( + const fresh = await this.deps.resolvePassphraseSessionByMode( this.device.connectId, + this.device.deviceId, this.passphraseMode, ); - if (fresh) { - this.cachedPassphraseState = fresh; - await this.persistPassphraseState(fresh); + if (fresh.passphraseState) { + this.cachedPassphraseState = fresh.passphraseState; + await this.persistPassphraseState(fresh.passphraseState, fresh.sessionId); } - return fresh || undefined; + return fresh.passphraseState || undefined; } private async readPassphraseStateFromKeychain(): Promise { @@ -198,27 +199,16 @@ export abstract class SignerHardwareBase implements ISigner { // session-id, which is now invalid on the device. We must refresh BOTH // keys atomically — persistKeychainSessionPair enforces this invariant. // Mirrors hardware-login-command.ts' post-resolve persistence step. - private async persistPassphraseState(state: string): Promise { + private async persistPassphraseState( + state: string, + resolvedSessionId?: string, + ): Promise { try { - const sdk = await this.deps.ensureSDKReady(); - const search = await sdk.searchDevices(); - if (!search?.success) return; - const devices = search.payload as Array<{ - deviceId?: string | null; - features?: { device_id?: string; session_id?: string }; - }>; - // Match on the stable deviceId (device UUID) rather than connectId — - // USB connectId is a per-session transport handle that may be reassigned - // across CLI invocations, so connectId-based matching breaks session - // reuse after a process restart. Mirrors the app-monorepo strategy of - // `localDb.getDeviceByQuery({ featuresDeviceId })`. - const match = devices.find((d) => d.deviceId === this.device.deviceId); - const sessionId = match?.features?.session_id; - if (!sessionId) return; + if (!resolvedSessionId) return; // Write both keys as a pair — never one without the other. const keychain = this.deps.keychainFactory(); - await persistKeychainSessionPair(keychain, state, sessionId); + await persistKeychainSessionPair(keychain, state, resolvedSessionId); // Warm the in-process SDK cache too. Idempotent — getPassphraseState // already populated it for this run, but doing it here keeps the path @@ -226,7 +216,7 @@ export abstract class SignerHardwareBase implements ISigner { await this.deps.preloadSessionCache( this.device.deviceId, state, - sessionId, + resolvedSessionId, ); } catch { // non-fatal — in-memory state still works this run; next run will @@ -253,15 +243,10 @@ export abstract class SignerHardwareBase implements ISigner { const devices = result.payload as Array<{ connectId?: string | null; deviceId?: string | null; - features?: { device_id?: string }; }>; if (!Array.isArray(devices)) return; // Match on stable deviceId (device UUID), not connectId. - const match = devices.find( - (d) => - d.deviceId === this.device.deviceId || - d.features?.device_id === this.device.deviceId, - ); + const match = devices.find((d) => d.deviceId === this.device.deviceId); if (match?.connectId) { this.device.connectId = match.connectId; } @@ -273,11 +258,15 @@ export abstract class SignerHardwareBase implements ISigner { private async ensureDeviceUnlocked(): Promise { try { const sdk = await this.deps.ensureSDKReady(); - const featResult = await sdk.getFeatures(this.device.connectId); + const deviceStateResult = await sdk.getDeviceState(this.device.connectId); if ( - featResult?.success && - featResult.payload && - (featResult.payload as { unlocked?: boolean }).unlocked === false + deviceStateResult?.success && + deviceStateResult.payload && + ( + deviceStateResult.payload as { + status?: { unlocked?: boolean | null }; + } + ).status?.unlocked === false ) { this.deviceWasLocked = true; this.deps.stderr.write( diff --git a/apps/cli/src/signer/impls/sol/SignerHardware.ts b/apps/cli/src/signer/impls/sol/SignerHardware.ts index ccafb6aa9645..346c0f91fa16 100644 --- a/apps/cli/src/signer/impls/sol/SignerHardware.ts +++ b/apps/cli/src/signer/impls/sol/SignerHardware.ts @@ -243,6 +243,13 @@ export class SignerHardware extends SignerHardwareBase { if (unsignedMsg.type === EMessageTypesSolana.SIGN_OFFCHAIN_MESSAGE) { const applicationDomain = unsignedMsg.payload?.applicationDomain; + const guessedMessageFormat = OffchainMessage.guessMessageFormat( + Buffer.from(unsignedMsg.message ?? ''), + ); + const messageFormat = + guessedMessageFormat === 0 || guessedMessageFormat === 1 + ? guessedMessageFormat + : undefined; const result = await sdk.solSignOffchainMessage( this.device.connectId, this.device.deviceId, @@ -255,10 +262,7 @@ export class SignerHardware extends SignerHardwareBase { Buffer.from(applicationDomain).toString('hex'), } : {}), - // @ts-expect-error firmware SDK accepts the format hint without typing it - messageFormat: OffchainMessage.guessMessageFormat( - Buffer.from(unsignedMsg.message ?? ''), - ), + messageFormat, ...commonParams, }, ); diff --git a/docs/pro2-passphrase-wallet-session.md b/docs/pro2-passphrase-wallet-session.md new file mode 100644 index 000000000000..46eea5d2e57f --- /dev/null +++ b/docs/pro2-passphrase-wallet-session.md @@ -0,0 +1,95 @@ +# Pro 2 Passphrase 与钱包会话 + +## 1. 范围 + +本文记录 App 对 OneKey Pro 2 Protocol V2 钱包会话的调用约束。它覆盖标准钱包、Host 输入隐藏钱包、设备输入隐藏钱包、Attach PIN、会话恢复,以及后续地址派生和签名调用。 + +Protocol V1 和第三方硬件继续使用原有 passphrase 流程,不得根据设备名称、PID 或型号推断 Protocol V2。App 只能使用 SDK 已协商并返回的 `protocol`。 + +## 2. 钱包选择入口 + +App 的 `ServiceHardware.getPassphraseStateBase()` 按协议分流: + +- Protocol V2 调用 `openWalletSession()`; +- 标准钱包使用 `{ mode: 'standard' }`; +- 隐藏钱包选择使用 `{ mode: 'select-hidden' }`; +- Protocol V1 保留 `getPassphraseState()`; +- 已确认是 Protocol V2,但运行时 SDK 没有 `openWalletSession()` 时必须抛错,禁止退回 Protocol V1 或把结果当作标准钱包。 + +Protocol V2 的一次选择只能返回以下三种结果之一: + +- `{ passphrase }`:Host 输入; +- `{ passphraseOnDevice: true }`:设备输入; +- `{ attachPinOnDevice: true }`:Attach PIN。 + +App 不得在同一响应中同时设置两个选择字段,也不得用空 Host passphrase 表示标准钱包。标准钱包只能通过显式的 `mode: 'standard'` 打开。 + +## 3. Host 输入边界 + +Protocol V2 Host passphrase 在提交前执行 NFKD 规范化,并满足: + +- 非空; +- 不包含 NUL; +- 不包含孤立 UTF-16 surrogate; +- NFKD 后最多 50 UTF-8 字节; +- Unicode 合法,字节长度不能用 JavaScript `string.length` 代替。 + +Protocol V1 和第三方硬件仍保留各自的 ASCII 兼容规则。共享表单只有在 UI 事件来源为 `wallet-session-coordinator` 时启用 Protocol V2 UTF-8 规则。 + +## 4. 会话恢复 + +地址派生或签名方法携带钱包保存的 `passphraseState`。SDK 检测到当前设备会话与预期钱包不一致时,恢复流程必须: + +1. 明确标记 `reason: 'session-recovery'`; +2. 将 `expectedPassphraseState` 传给 App; +3. 禁止空 Host 提交; +4. 恢复后比较设备返回的钱包标识; +5. 标识不一致、取消、超时或断连时失败,不执行原业务命令; +6. 只有恢复成功后才允许继续地址派生或签名。 + +App 不保存明文 passphrase。钱包数据库只保存 `passphraseState`,业务调用通过 `deviceCommonParams` 传递: + +```ts +{ + passphraseState: wallet.passphraseState, + useEmptyPassphrase: !wallet.passphraseState, + connectProtocol +} +``` + +SDK 对标准钱包和隐藏钱包都会返回设备生成的 `passphraseState`。App 不得再用 +`passphraseState` 是否为空推断钱包类型:`openWalletSession()` 的 `walletType` 是唯一分类依据。 +为保持现有数据库语义,标准钱包的设备状态只用于当前 SDK 会话,不写入隐藏钱包字段;只有 +`walletType: 'hidden'` 的非空 `passphraseState` 才保存到钱包记录。 + +## 5. 链调用约束 + +所有 OneKey Hardware Keyring 的地址派生、交易签名、消息签名和 Typed Data 签名,都必须把 `deviceCommonParams` 传入 Hardware SDK。批量建账户的 `allNetworkGetAddress()` 也遵守相同约束。 + +隐藏钱包状态不得在链实现中被删除、改为空字符串或静默替换为 `useEmptyPassphrase: true`。设备重置、物理身份变化或钱包被标记为废弃后,App 必须停止使用旧钱包会话。 + +## 6. 日志与错误处理 + +- 禁止记录 passphrase、`passphraseState`、`expectedPassphraseState` 或完整 Hardware UI payload; +- Hardware UI 日志只能记录事件类型、设备类型、来源、原因和选择能力等白名单字段; +- 取消、超时、断连、会话失效、恢复钱包不匹配均向上返回错误; +- 不得自动重放可能产生副作用的签名命令; +- 不得把 SDK API 不可用、协议未知或设备状态读取失败解释为标准钱包。 + +## 7. 验收矩阵 + +发布前至少验证: + +- 标准钱包; +- Host 输入:ASCII、Unicode、NFKD 等价输入、50 字节边界、51 字节拒绝、NUL 拒绝; +- 设备输入; +- Attach PIN 存在与不存在; +- 创建隐藏钱包与恢复已有隐藏钱包; +- 错误 passphrase 导致钱包标识不匹配; +- 用户取消、输入超时、原子会话请求失败; +- USB/BLE 断连与重连; +- 同设备并发请求和不同设备隔离; +- 恢复失败后业务命令没有重放; +- Protocol V1 行为保持不变; +- SDK 缺少 Protocol V2 API 时失败关闭; +- 日志中不存在 passphrase 或钱包会话标识。 diff --git a/docs/pro2-portfolio-current-implementation.md b/docs/pro2-portfolio-current-implementation.md new file mode 100644 index 000000000000..a51430148b87 --- /dev/null +++ b/docs/pro2-portfolio-current-implementation.md @@ -0,0 +1,587 @@ +# Pro 2 Portfolio 当前实现 + +## 1. 文档范围 + +本文描述 OneKey App、Portfolio 打包服务和 Pro 2 Firmware 之间的当前数据契约与同步流程,重点覆盖: + +- App 生成 Portfolio 展示数据的规则; +- App 与服务端之间的 JSON 接口; +- 服务端签包后的硬件上传流程; +- 金额字符串、Unicode、字体范围和 UTF-8 字节限制; +- 内容去重、冷却、设备忙碌和失败处理。 + +本文以以下实现为依据: + +- App 当前分支中的 `portfolioPayload.ts` 和 Hardware Portfolio Sync 服务; +- `firmware-pro2` 远端 `dev` 分支的展示字符串协议; +- `@onekeyfe/hd-core` 当前 `uploadPortfolio()` 实现。 + +服务端源码不在本仓库中。本文中的服务端行为是 App 与 Firmware 对服务端的接口约束,不代表已审计服务端内部实现。 + +## 2. 核心结论 + +Portfolio 金额采用“App 格式化、Firmware 原样显示”的协议: + +- App 决定 Token 选择、顺序、金额格式、法币前缀、标准名称和资产占比; +- 服务端校验数据、补齐可信 Token 元数据、生成并签名 Portfolio 包; +- Firmware 将金额和余额作为受长度限制的 UTF-8 展示字符串; +- Firmware 不解析金额、不添加币种符号、不重新格式化,也不根据金额排序; +- Firmware 使用独立的 `portfolioPercentage` 绘制环图和进度条。 + +因此以下值都是合法的展示字符串: + +```text +$27,112.11 +< $0.01 +0.0₅41 +EUR 1.00 +``` + +`0.0₅41` 是 App 的前导零下标压缩表示,不是传统的 `4.1e-6` 指数表示。 + +## 3. Runtime 范围 + +Portfolio 构建、服务端提交和硬件上传由 `kit-bg` 执行。 + +### 3.1 iOS、Android 和浏览器扩展 + +- Runtime 范围:`bg`; +- `main` 与 `bg` 是隔离的 JS Runtime,不能假设共享 JS 对象或初始化顺序; +- Portfolio 事件从主业务状态进入后台服务后,在 `bg` 中构建和上传; +- 硬件 SDK 调用由后台 Hardware Service 管理。 + +### 3.2 Desktop 和 Web + +- App 代码运行在单一 JS Runtime; +- Portfolio 仍通过后台 Service 接口执行,以保持跨平台调用模型一致。 + +## 4. 同步触发流程 + +当前流程监听 `AllNetworksTokenListSettled` 事件: + +1. 全网络 Token 列表完成计算; +2. 后台服务对连续事件执行 1 秒防抖; +3. 检查 Portfolio 调试功能是否开启; +4. 检查账户是否为硬件钱包; +5. 根据当前账户、Token、法币和汇率构建 Portfolio; +6. 计算不包含 `ts` 的内容哈希; +7. 检查目标设备的重复内容、连接状态、硬件忙碌状态和 20 秒冷却; +8. 将 Portfolio JSON 提交给服务端签包; +9. 将服务端返回的包交给 Hardware SDK; +10. 文件写入完成后发送 `PortfolioUpdate`; +11. 只有设备返回 `Success` 才记录为上传成功。 + +同步仅面向已连接的硬件钱包。目标键使用硬件 `connectId`,去重和冷却状态按目标设备隔离;软件钱包或缺少连接 ID 的事件不会构建数据,也不会提交服务端。 + +## 5. App 生成的数据结构 + +App 构建的根对象固定包含 7 个字段: + +```ts +type IPortfolioPayload = { + v: 1; + ts: number; + account: { + label: string; + addressMasked: string; + }; + totalFiat: string; + tokenCount: number; + tokens: IPortfolioPayloadToken[]; + otherTokens: { + count: number; + fiat: string; + portfolioPercentage: number; + }; +}; +``` + +App 侧 Token 包含: + +```ts +type IPortfolioPayloadToken = { + symbol: string; + name: string; + contractAddress: string; + iconName: string | null; + isAllNetworks: boolean; + isNative: boolean; + balance: string; + fiatValue: string; + portfolioPercentage: number; + networkId: string; +}; +``` + +服务端提交前会将所有 `iconName` 设置为 `null`。服务端必须根据可信白名单生成最终 `iconName`,并补齐 Firmware 要求的 `color`。 + +## 6. 根字段规则 + +| 字段 | App 规则 | +| --- | --- | +| `v` | 固定为整数 `1` | +| `ts` | 毫秒时间戳;App 预先按当前时区调整展示语义 | +| `account.label` | 优先使用索引账户名称或账户名称;否则使用 `Account #N` 或缩短地址 | +| `account.addressMasked` | 索引账户使用 `Account #N`,否则使用缩短地址 | +| `totalFiat` | App 格式化后的完整法币展示字符串 | +| `tokenCount` | `tokens.length`,当前最大为 5 | +| `tokens` | 保持 App 已确定的顺序 | +| `otherTokens` | 未进入详细列表的资产汇总,固定排在最后 | + +`currency` 和 `currencySymbol` 已从当前协议删除。法币展示信息直接包含在 `totalFiat`、`tokens[].fiatValue` 和 `otherTokens.fiat` 中。 + +## 7. Token 选择与顺序 + +App 使用上游 UI Token 顺序并取前 5 个: + +```text +tokens.slice(0, 5) +``` + +Firmware 不再根据 `fiatValue` 重新排序,设备顺序与 App 传入顺序一致。 + +`otherTokens.count` 的计算方式为: + +```text +max(trunc(totalTokenCount) - tokens.length, 0) +``` + +## 8. 金额格式化 + +### 8.1 首页总资产 + +`totalFiat` 沿用 App 首页总资产规则:使用当前货币单位、本地化分组符和小数符,固定保留两位小数并四舍五入。`0 < value < 0.01` 显示 `< {currency}0.01`,零值显示 `{currency}0.00`。 + +App 按 Pro 2 的 16dp Roobert Regular 字体和 350dp 可用宽度预估完整字符串。完整字符串不超过 47 UTF-8 字节且能够放下时直接下传;否则改为保留 4 位有效数字、使用 ASCII `e` 的科学计数法。Firmware 仍只接收原有 `totalFiat` 单字段,不解析或重新格式化。 + +示例: + +```text +75.247 → $75.25 +123456789012.34 → $123,456,789,012.34 +123456789012345678901234567890.12 → $1.235e+29 +0.009 → < $0.01 +``` + +### 8.2 详情法币金额 + +`tokens[].fiatValue` 和 `otherTokens.fiat` 使用 Pro 2 紧凑法币格式:保留两位小数,超过 1,000 后使用 `K/M/B/T/Q`,并在单位边界四舍五入后自动提升。 + +### 8.3 Token 余额 + +`tokens[].balance` 使用 App 的 `formatBalance()`: + +- 大于等于 1 时沿用 App 单位和精度规则; +- 小于 1 时保留前导零后的 4 位有效小数; +- 前导零数量大于 4 时使用下标压缩形式。 + +示例: + +```text +0.41308123 → 0.4131 +0.00001234567 → 0.00001235 +0.0000041 → 0.0₅41 +``` + +### 8.3 Unicode 下标序列化 + +`formatDisplayNumber()` 对极小数返回结构化片段: + +```ts +['0.0', { type: 'sub', value: 5 }, '41'] +``` + +Portfolio 在进入 JSON 前将下标数字序列化为真实 Unicode: + +```text +0 → ₀ +1 → ₁ +2 → ₂ +3 → ₃ +4 → ₄ +5 → ₅ +6 → ₆ +7 → ₇ +8 → ₈ +9 → ₉ +``` + +多位下标逐位转换,例如: + +```text +12 → ₁₂ +``` + +不得把 `{ type: "sub", value: 5 }` 直接转换为普通字符串 `"5"`,否则 `0.0000041` 会被错误转换成 `0.0541`。 + +## 9. 法币符号兼容 + +Portfolio 仅发送 Firmware 字体资源能够显示的字符。 + +当前 App 按以下 Firmware 字体区间判断法币符号: + +```text +U+0020–U+007E +U+00A0–U+024F +U+1E00–U+1EFF +U+2000–U+206F +U+2080–U+2089 +``` + +如果法币符号为空,或任意字符不在支持范围内,则使用大写 ISO Currency Code,并在 Code 后增加一个 ASCII 空格: + +```text +€ → EUR +₹ → INR +未知新符号 → 对应 currency id 的大写形式 +``` + +最终示例: + +```text +EUR 1.00 +< EUR 0.01 +``` + +ISO Code 本身也必须位于 Firmware 支持范围内,否则停止构建,避免生成设备无法显示的 Portfolio。 + +Firmware 字体资源需要包含 `U+2080–U+2089`,才能正确显示 App 发送的下标数字。 + +## 10. UTF-8 字节限制 + +以下单个金额字段必须是非空字符串,且不得超过 47 UTF-8 字节: + +- `totalFiat` +- `tokens[].balance` +- `tokens[].fiatValue` +- `otherTokens.fiat` + +校验发生在以下步骤全部完成之后: + +1. App 数字格式化; +2. Unicode 下标序列化; +3. ASCII `<` 规范化; +4. 法币符号或 ISO Code 选择; +5. 最终字符串拼接。 + +App 使用 UTF-8 字节长度,不使用 JavaScript UTF-16 `string.length`: + +```ts +Buffer.byteLength(value, 'utf8') +``` + +示例: + +```text +0.0000041 → 9 UTF-8 字节 +0.0₅41 → 8 UTF-8 字节 +``` + +其中 `₅` 占 3 个 UTF-8 字节。 + +`totalFiat` 的完整格式超过限制时,App 改用科学计数法;其他字段超过限制时终止本次 Portfolio 构建。禁止直接截断字节,因为截断可能破坏 UTF-8 字符或改变金额语义。 + +## 11. 法币换算 + +Token 的原始法币金额会转换为 App 当前展示法币: + +```text +目标金额 = 原始金额 / 原始法币汇率 × 目标法币汇率 +``` + +以下情况视为不可用: + +- 金额为 `null`、`undefined` 或空字符串; +- 金额不是有限数字; +- 原始汇率或目标汇率不存在、为零或不是有限数字。 + +不可用的 Token 法币金额按零参与 Portfolio 展示和占比计算。 + +## 12. 占比计算 + +Firmware 不解析展示金额。App 使用格式化前的数值计算: + +- `tokens[].portfolioPercentage` +- `otherTokens.portfolioPercentage` + +规则: + +1. 所有非负有效金额参与计算; +2. 总额小于等于零时,所有占比为零; +3. 占比保留两位小数; +4. 最大金额项吸收舍入误差; +5. 非零 Portfolio 的全部 Token 与 Other 占比总和为 100。 + +这使 Firmware 可以安全显示 `< $0.01`、`0.0₅41` 等非数值展示字符串,同时继续准确绘制资产分布。 + +## 13. Token 元数据 + +### 13.1 原生资产与合约地址 + +- 全网络聚合资产:`contractAddress = ""`; +- 大多数网络原生资产:`contractAddress = ""`; +- Aptos、Sui 原生资产可保留规范化后的地址; +- 普通合约资产保留规范化后的合约地址; +- 大小写敏感网络保持原始地址大小写,其他网络使用小写。 + +### 13.2 标准名称与图标 + +App 使用同一份可信白名单解析 Token 的 `iconName` 和标准英文名称: + +| `iconName` | 标准 `name` | +| --- | --- | +| `BTC` | `Bitcoin` | +| `ETH` | `Ethereum` | +| `BNB` | `BNB` | +| `SOL` | `Solana` | +| `TRON` | `TRON` | +| `USDT` | `Tether USD` | +| `USDC` | `USD Coin` | + +名称处理规则: + +1. 命中 Native、Contract 或 All Networks 图标白名单时,App 使用上表中的标准 `name`; +2. 未命中白名单时,App 保留上游 `token.name`,并保持 `iconName = null`; +3. App 不会仅根据普通合约 Token 的 `symbol` 分配标准名称或图标; +4. `TRX` 和 `TRON` 聚合 Symbol 都规范化为 `name = "TRON"` 和 `iconName = "TRON"`。 + +本地 Mock Portfolio 保留解析出的 `iconName`。正式提交服务端时,App 将 `iconName` 清空,但保留标准化后的 `name`: + +```ts +{ + ...token, + iconName: null, +} +``` + +最终签名包中的 `iconName` 和 `color` 必须由服务端可信规则产生。Firmware 只消费服务端最终结果。 + +### 13.3 服务端白名单 Key + +服务端使用以下格式构建精确匹配 Key: + +```ts +const key = `${networkId}:${contractAddress}:${name}`; +``` + +App 在生成 Portfolio 时已经完成合约地址规范化:EVM 地址统一为小写,Solana 和 TRON 地址保持大小写。服务端不需要根据 `isNative`、`isAllNetworks` 或 `symbol` 重新推导名称。 + +#### Native Token + +| Network | `networkId` | `contractAddress` | `symbol` | `name` | `iconName` | 服务端 Key | +| --- | --- | --- | --- | --- | --- | --- | +| Bitcoin | `btc--0` | `""` | `BTC` | `Bitcoin` | `BTC` | `btc--0::Bitcoin` | +| Ethereum | `evm--1` | `""` | `ETH` | `Ethereum` | `ETH` | `evm--1::Ethereum` | +| BNB Smart Chain | `evm--56` | `""` | `BNB` | `BNB` | `BNB` | `evm--56::BNB` | +| Solana | `sol--101` | `""` | `SOL` | `Solana` | `SOL` | `sol--101::Solana` | +| TRON | `tron--0x2b6653dc` | `""` | `TRX` | `TRON` | `TRON` | `tron--0x2b6653dc::TRON` | + +#### Contract Token + +| Network | `networkId` | `contractAddress` | `symbol` | `name` | `iconName` | 服务端 Key | +| --- | --- | --- | --- | --- | --- | --- | +| Ethereum | `evm--1` | `0xdac17f958d2ee523a2206206994597c13d831ec7` | `USDT` | `Tether USD` | `USDT` | `evm--1:0xdac17f958d2ee523a2206206994597c13d831ec7:Tether USD` | +| Ethereum | `evm--1` | `0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48` | `USDC` | `USD Coin` | `USDC` | `evm--1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48:USD Coin` | +| BNB Smart Chain | `evm--56` | `0x55d398326f99059ff775485246999027b3197955` | `USDT` | `Tether USD` | `USDT` | `evm--56:0x55d398326f99059ff775485246999027b3197955:Tether USD` | +| BNB Smart Chain | `evm--56` | `0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d` | `USDC` | `USD Coin` | `USDC` | `evm--56:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d:USD Coin` | +| Polygon | `evm--137` | `0x3c499c542cef5e3811e1192ce70d8cc03d5c3359` | `USDC` | `USD Coin` | `USDC` | `evm--137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359:USD Coin` | +| Polygon | `evm--137` | `0xc2132d05d31c914a87c6611c10748aeb04b58e8f` | `USDT` | `Tether USD` | `USDT` | `evm--137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f:Tether USD` | +| Solana | `sol--101` | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | `USDC` | `USD Coin` | `USDC` | `sol--101:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v:USD Coin` | +| Solana | `sol--101` | `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` | `USDT` | `Tether USD` | `USDT` | `sol--101:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB:Tether USD` | +| TRON | `tron--0x2b6653dc` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | `USDT` | `Tether USD` | `USDT` | `tron--0x2b6653dc:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t:Tether USD` | + +#### All Networks 聚合 Token + +| `symbol` | `name` | `iconName` | `networkId` | `contractAddress` | 服务端 Key | +| --- | --- | --- | --- | --- | --- | +| `BTC` | `Bitcoin` | `BTC` | `""` | `""` | `::Bitcoin` | +| `ETH` | `Ethereum` | `ETH` | `""` | `""` | `::Ethereum` | +| `BNB` | `BNB` | `BNB` | `""` | `""` | `::BNB` | +| `SOL` | `Solana` | `SOL` | `""` | `""` | `::Solana` | +| `TRX` / `TRON` | `TRON` | `TRON` | `""` | `""` | `::TRON` | +| `USDT` | `Tether USD` | `USDT` | `""` | `""` | `::Tether USD` | +| `USDC` | `USD Coin` | `USDC` | `""` | `""` | `::USD Coin` | + +### 13.4 聚合资产 + +全网络聚合资产使用: + +```json +{ + "isAllNetworks": true, + "isNative": false, + "contractAddress": "", + "networkId": "" +} +``` + +当前 Firmware 允许 `isAllNetworks = true` 时 `networkId` 为空。 + +## 14. App 提交服务端 + +请求地址: + +```text +POST /wallet/v1/hardware/portfolio/pack +``` + +请求体是 Portfolio JSON 对象,不是 PFOL/OKPKG 二进制包。 + +App 约定服务端负责: + +- 严格校验 JSON 字段; +- 保持金额和余额展示字符串原样; +- 校验每个展示金额的 UTF-8 字节长度; +- 使用 `networkId:contractAddress:name` 精确匹配可信白名单; +- 根据命中的白名单配置补齐 `iconName` 和 `color`; +- 生成 Firmware 接受的资源包; +- 使用生产密钥体系签名; +- 返回 Base64 编码的完整包。 + +响应结构: + +```json +{ + "data": { + "packageBase64": "..." + } +} +``` + +如果缺少 `packageBase64`、Base64 无法解码或服务端请求失败,App 不会开始硬件上传。 + +## 15. 内容哈希与去重 + +App 使用稳定 JSON 序列化和 SHA-256 计算内容哈希。 + +哈希排除 `ts`: + +```ts +const { ts, ...content } = portfolio; +``` + +因此仅时间变化、其他内容完全相同的 Portfolio 不会重复上传。 + +去重状态在以下时机才提交: + +- 服务端提交完成;或 +- 硬件设备完成 `PortfolioUpdate`。 + +设备忙碌、断开或上传失败不会永久写入成功哈希,相同内容可以在条件恢复后重试。 + +## 16. Hardware SDK 上传 + +App 将服务端返回的 Base64 解码为独立 `ArrayBuffer`,然后调用: + +```ts +uploadPortfolio(connectId, { + operationId, + packageBytes, + timeoutMs, +}); +``` + +SDK 执行两阶段流程: + +1. 使用 `FilesystemFileWrite` 将包顺序写入: + + ```text + vol1:/portfolio/portfolio.okpkg.pending + ``` + +2. 最后一个分块确认后发送: + + ```text + PortfolioUpdate {} + ``` + +只有 `PortfolioUpdate` 返回 `Success`,SDK 才返回: + +```json +{ + "portfolioUpdated": true +} +``` + +文件写入完成只表示候选包已经暂存,不代表 Portfolio 已应用。 + +## 17. 状态与失败处理 + +| 状态 | 含义 | +| --- | --- | +| `disabled` | Portfolio 调试功能未开启,或目标不是已连接的硬件钱包 | +| `empty` | 当前没有需要同步的正余额资产 | +| `duplicate` | 内容哈希与已完成或正在处理的内容相同 | +| `cooldown` | 目标设备仍在 20 秒冷却期 | +| `hardware-busy` | Hardware Channel 正在执行其他操作 | +| `uploaded` | 设备已成功执行 `PortfolioUpdate` | +| `error` | 构建、服务端或硬件步骤失败 | + +硬件忙碌场景会保留最新事件,并在冷却后重新尝试。 + +## 18. 安全与隐私 + +Portfolio JSON 包含: + +- 账户名称、账户编号或缩短地址回退值; +- 账户编号或缩短地址; +- 主要资产的余额和法币价值; +- Token Symbol、名称、网络和合约地址; +- Portfolio 生成时间。 + +这些数据属于用户资产摘要,应按敏感财务数据处理。 + +Portfolio 包经过签名但不加密。不要在日志中输出完整 Portfolio、账户持仓或完整地址。 + +生产环境必须由服务端持有生产签名密钥。App 不持有生产私钥。 + +## 19. 验证清单 + +### 19.1 App + +- [ ] `0.0000041` 输出 `0.0₅41`; +- [ ] 多位前导零数量逐位转换为 Unicode 下标; +- [ ] 小额法币使用 ASCII `<`; +- [ ] 不发送全角 `<`; +- [ ] `totalFiat` 优先使用本地化完整金额和两位小数; +- [ ] `totalFiat` 仅在 16dp/350dp 放不下或超过 47 bytes 时使用 4 位有效数字科学计数法; +- [ ] Firmware 范围外的法币符号降级为 ISO Code; +- [ ] 四类金额字段都在最终拼接后校验 47 UTF-8 字节; +- [ ] 非 `totalFiat` 字段超过限制时停止构建,不截断字符串; +- [ ] Token 顺序与 UI 顺序一致; +- [ ] 白名单 Token 使用标准 `name`; +- [ ] 未命中白名单的 Token 保留原始 `name` 且 `iconName = null`; +- [ ] 占比总和正确; +- [ ] 内容哈希排除 `ts`。 + +### 19.2 服务端 + +- [ ] 保持 App 金额展示字符串原样; +- [ ] 拒绝超过 47 UTF-8 字节的金额字段; +- [ ] 使用 `networkId:contractAddress:name` 精确匹配白名单; +- [ ] 补齐合法的 `iconName` 和 `color`; +- [ ] 返回可被目标 Firmware 验证的签名包。 + +### 19.3 Firmware + +- [ ] Parser 将金额视为受长度限制的 UTF-8 字符串; +- [ ] 字体资源包含 `U+2080–U+2089`; +- [ ] 首页与详情页正确显示 Unicode 下标; +- [ ] 环图仅使用 `portfolioPercentage`; +- [ ] Token 保持 App 传入顺序; +- [ ] `PortfolioUpdate` 成功后再刷新 UI。 + +## 20. 关键代码位置 + +| 范围 | 文件 | +| --- | --- | +| Portfolio 类型、格式化、占比和字节校验 | `packages/shared/src/utils/portfolioPayload.ts` | +| Token 标准名称与图标白名单 | `packages/shared/src/utils/portfolioTokenIcon.ts` | +| Portfolio 单元测试 | `packages/shared/src/utils/portfolioPayload.test.ts` | +| 稳定序列化和服务端提交数据构建 | `packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts` | +| 同步状态、去重、冷却和服务端请求 | `packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts` | +| Hardware Service 适配 | `packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts` | +| SDK 上传实现 | `node_modules/@onekeyfe/hd-core/src/api/UploadPortfolio.ts` | +| Firmware 展示字符串协议 | `firmware-pro2/utils/onekey_protocol_cli/portfolio.protocol.md` | +| Firmware JSON Parser | `firmware-pro2/tasks/task_foreground/pages/standalone/portfolio_data.c` | +| Firmware Portfolio UI | `firmware-pro2/ui/components/portfolio/portfolio.c` | diff --git a/package.json b/package.json index c6a15bb6e7f9..e3fcfdcd7691 100644 --- a/package.json +++ b/package.json @@ -159,21 +159,21 @@ "@onekeyfe/cross-inpage-provider-injected": "2.2.73", "@onekeyfe/cross-inpage-provider-types": "2.2.73", "@onekeyfe/extension-bridge-hosted": "2.2.73", - "@onekeyfe/hd-ble-sdk": "1.1.34-alpha.0", - "@onekeyfe/hd-common-connect-sdk": "1.1.34-alpha.0", - "@onekeyfe/hd-core": "1.1.34-alpha.0", - "@onekeyfe/hd-shared": "1.1.34-alpha.0", - "@onekeyfe/hd-transport": "1.1.34-alpha.0", - "@onekeyfe/hd-transport-electron": "1.1.34-alpha.0", - "@onekeyfe/hd-web-sdk": "1.1.34-alpha.0", - "@onekeyfe/hwk-adapter-core": "1.1.34-alpha.2", - "@onekeyfe/hwk-ledger-adapter": "1.1.34-alpha.2", - "@onekeyfe/hwk-ledger-connector-ble": "1.1.34-alpha.2", - "@onekeyfe/hwk-ledger-connector-webhid": "1.1.34-alpha.2", - "@onekeyfe/hwk-trezor-adapter": "1.1.34-alpha.2", - "@onekeyfe/hwk-trezor-connector-electron-ble": "1.1.34-alpha.2", - "@onekeyfe/hwk-trezor-connector-rn-ble": "1.1.34-alpha.2", - "@onekeyfe/hwk-trezor-connector-webusb": "1.1.34-alpha.2", + "@onekeyfe/hd-ble-sdk": "1.2.0-alpha.56", + "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.56", + "@onekeyfe/hd-core": "1.2.0-alpha.56", + "@onekeyfe/hd-shared": "1.2.0-alpha.56", + "@onekeyfe/hd-transport": "1.2.0-alpha.56", + "@onekeyfe/hd-transport-electron": "1.2.0-alpha.56", + "@onekeyfe/hd-web-sdk": "1.2.0-alpha.56", + "@onekeyfe/hwk-adapter-core": "1.2.0-alpha.56", + "@onekeyfe/hwk-ledger-adapter": "1.2.0-alpha.56", + "@onekeyfe/hwk-ledger-connector-ble": "1.2.0-alpha.56", + "@onekeyfe/hwk-ledger-connector-webhid": "1.2.0-alpha.56", + "@onekeyfe/hwk-trezor-adapter": "1.2.0-alpha.56", + "@onekeyfe/hwk-trezor-connector-electron-ble": "1.2.0-alpha.56", + "@onekeyfe/hwk-trezor-connector-rn-ble": "1.2.0-alpha.56", + "@onekeyfe/hwk-trezor-connector-webusb": "1.2.0-alpha.56", "@onekeyfe/onekey-cross-webview": "2.2.73", "@polkadot/extension-inject": "0.54.1", "@polkadot/types": "14.3.1", @@ -316,6 +316,8 @@ "@types/validator": "^13", "@types/w3c-web-hid": "^1.0.6", "@types/w3c-web-usb": "^1.0.10", + "@types/web": "0.0.269", + "@types/web-bluetooth": "0.0.21", "@types/zxcvbn": "^4", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", @@ -414,6 +416,11 @@ "@reown/appkit-ethers5-react-native": "https://github.com/OneKeyHQ/app-modules#9d96daccc13625e5b3c8b236f9357956b049b884", "@reown/appkit-scaffold-react-native": "https://github.com/OneKeyHQ/app-modules#ef39e1c6682f8b50dc019a851f6e2211392d353a", "@reown/appkit-scaffold-utils-react-native": "https://github.com/OneKeyHQ/app-modules#aa31ef69e5058bb822c40f0a706ee9bdd191b005", + "@onekeyfe/hd-core": "1.2.0-alpha.56", + "@onekeyfe/hd-shared": "1.2.0-alpha.56", + "@onekeyfe/hd-transport": "1.2.0-alpha.56", + "@onekeyfe/hd-transport-http": "1.2.0-alpha.56", + "@onekeyfe/hd-transport-web-device": "1.2.0-alpha.56", "promise": "^8.3.0", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", diff --git a/packages/kit-bg/package.json b/packages/kit-bg/package.json index d59ec85cbc84..3e32dae4441e 100644 --- a/packages/kit-bg/package.json +++ b/packages/kit-bg/package.json @@ -8,6 +8,7 @@ "fflate": "0.8.2", "idb": "^7.1.1", "ipaddr.js": "^2.3.0", + "jpeg-js": "^0.4.4", "miscreant": "^0.3.2" }, "scripts": { diff --git a/packages/kit-bg/src/apis/BackgroundApi.ts b/packages/kit-bg/src/apis/BackgroundApi.ts index 24d306e02d23..9f7ba567b5f2 100644 --- a/packages/kit-bg/src/apis/BackgroundApi.ts +++ b/packages/kit-bg/src/apis/BackgroundApi.ts @@ -442,6 +442,16 @@ class BackgroundApi extends BackgroundApiBase implements IBackgroundApi { return value; } + get serviceHardwarePortfolioSync() { + const Service = + require('../services/ServiceHardware/serviceHardwarePortfolioSync') as typeof import('../services/ServiceHardware/serviceHardwarePortfolioSync'); + const value = new Service.default({ + backgroundApi: this, + }); + Object.defineProperty(this, 'serviceHardwarePortfolioSync', { value }); + return value; + } + get serviceKeylessCloudSync() { const Service = require('../services/ServiceKeylessCloudSync') as typeof import('../services/ServiceKeylessCloudSync'); diff --git a/packages/kit-bg/src/apis/BackgroundApiProxy.ts b/packages/kit-bg/src/apis/BackgroundApiProxy.ts index b0e4dc0f90ee..422a97e5f3db 100644 --- a/packages/kit-bg/src/apis/BackgroundApiProxy.ts +++ b/packages/kit-bg/src/apis/BackgroundApiProxy.ts @@ -39,6 +39,7 @@ import type ServiceFirmwareUpdate from '../services/ServiceFirmwareUpdate'; import type ServiceFreshAddress from '../services/ServiceFreshAddress'; import type ServiceGas from '../services/ServiceGas'; import type ServiceHardware from '../services/ServiceHardware'; +import type ServiceHardwarePortfolioSync from '../services/ServiceHardware/serviceHardwarePortfolioSync'; import type ServiceHardwareUI from '../services/ServiceHardwareUI'; import type ServiceHistory from '../services/ServiceHistory'; import type ServiceHyperliquid from '../services/ServiceHyperLiquid/ServiceHyperliquid'; @@ -295,6 +296,12 @@ class BackgroundApiProxy return this.getProxyService('servicePrimeCloudSync'); } + get serviceHardwarePortfolioSync(): ServiceHardwarePortfolioSync { + return this.getProxyService( + 'serviceHardwarePortfolioSync', + ); + } + get serviceKeylessCloudSync(): ServiceKeylessCloudSync { return this.getProxyService( 'serviceKeylessCloudSync', diff --git a/packages/kit-bg/src/apis/IBackgroundApi.ts b/packages/kit-bg/src/apis/IBackgroundApi.ts index f44c4019aa2c..f14dd24edb46 100644 --- a/packages/kit-bg/src/apis/IBackgroundApi.ts +++ b/packages/kit-bg/src/apis/IBackgroundApi.ts @@ -41,6 +41,7 @@ import type ServiceFirmwareUpdate from '../services/ServiceFirmwareUpdate'; import type ServiceFreshAddress from '../services/ServiceFreshAddress'; import type ServiceGas from '../services/ServiceGas'; import type ServiceHardware from '../services/ServiceHardware'; +import type ServiceHardwarePortfolioSync from '../services/ServiceHardware/serviceHardwarePortfolioSync'; import type ServiceHardwareUI from '../services/ServiceHardwareUI'; import type ServiceHistory from '../services/ServiceHistory'; import type ServiceHyperliquid from '../services/ServiceHyperLiquid/ServiceHyperliquid'; @@ -204,6 +205,7 @@ export interface IBackgroundApi extends IBackgroundApiBridge { serviceIdentityExit: ILazyServiceProxy; servicePrime: ServicePrime; servicePrimeCloudSync: ServicePrimeCloudSync; + serviceHardwarePortfolioSync: ServiceHardwarePortfolioSync; serviceKeylessCloudSync: ServiceKeylessCloudSync; serviceQrWallet: ServiceQrWallet; serviceAccountProfile: ServiceAccountProfile; diff --git a/packages/kit-bg/src/dbs/local/LocalDbBase.deviceState.test.ts b/packages/kit-bg/src/dbs/local/LocalDbBase.deviceState.test.ts new file mode 100644 index 000000000000..14117ee2e5f1 --- /dev/null +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.deviceState.test.ts @@ -0,0 +1,814 @@ +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; + +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +import { INDEXED_DB_VERSION, REALM_DB_VERSION } from './consts'; +import { LocalDbBase, sanitizeDeviceStateForPersistence } from './LocalDbBase'; +import { ELocalDBStoreNames } from './localDBStoreNames'; + +import type { + EIndexedDBBucketNames, + IDBDevice, + IDBWallet, + ILocalDBTxUpdateRecordsParams, +} from './types'; + +const createState = ({ + revision, + updatedAt, + label, + bleName = 'Pro2 6136', + language, + firmware = '1.0.0', + deviceType = EDeviceType.Pro2, + model = 'pro2', + serialNo = '', + deviceId = null, + passphraseProtection, +}: { + revision: number; + updatedAt: number; + label: string | null; + bleName?: string; + language: string | null; + firmware?: string; + deviceType?: EDeviceType; + model?: string; + serialNo?: string; + deviceId?: string | null; + passphraseProtection?: boolean; +}): IOneKeyDeviceState => + ({ + schemaVersion: 1, + revision, + updatedAt, + protocol: 'V2', + identity: { + deviceType, + firmwareType: EFirmwareType.Universal, + model, + vendor: 'onekey.so', + deviceId, + serialNo, + label, + bleName, + }, + status: { mode: 'normal', passphraseProtection }, + settings: { language }, + versions: { firmware }, + capabilities: [], + }) as unknown as IOneKeyDeviceState; + +class DeviceStateTestLocalDb extends LocalDbBase { + override readyDb = Promise.resolve(this as never); + + devices: IDBDevice[]; + + get device() { + return this.devices[0]; + } + + constructor(state: IOneKeyDeviceState) { + super(); + this.devices = [ + { + id: 'device-db-1', + name: deviceUtils.getDeviceDisplayName({ state }), + features: '{}', + deviceState: JSON.stringify(state), + connectId: 'ABC-DEF', + uuid: '', + deviceId: '', + deviceType: EDeviceType.Pro2, + settingsRaw: JSON.stringify({ vendor: EHardwareVendor.onekey }), + createdAt: 1, + updatedAt: 1, + vendor: EHardwareVendor.onekey, + }, + ]; + } + + override async reset() {} + + override async getAllDevices() { + return { + devices: this.devices.map((device) => this.refillDeviceInfo({ device })), + }; + } + + override async withTransaction( + _bucketName: EIndexedDBBucketNames, + task: (tx: never) => Promise, + ): Promise { + return task({} as never); + } + + override async txUpdateRecords({ + name, + ids = [], + updater, + }: ILocalDBTxUpdateRecordsParams): Promise { + if (name === ELocalDBStoreNames.Device) { + for (let index = 0; index < this.devices.length; index += 1) { + if (ids.includes(this.devices[index].id)) { + this.devices[index] = await ( + updater as (item: IDBDevice) => IDBDevice | Promise + )(this.devices[index]); + } + } + } + } +} + +describe('LocalDb DeviceState persistence', () => { + it('bumps the local database version for the new Realm field', () => { + expect(INDEXED_DB_VERSION).toBe(20); + expect(REALM_DB_VERSION).toBe(20); + }); + + it('isolates malformed device state and settings records during hydration', async () => { + const state = createState({ + revision: 1, + updatedAt: 100, + label: 'Healthy device', + language: 'en-US', + }); + const db = new DeviceStateTestLocalDb(state); + db.devices.push({ + ...db.device, + id: 'device-db-broken', + deviceState: '{broken', + features: JSON.stringify({ label: 'Legacy fallback' }), + settingsRaw: '{broken', + }); + + const { devices } = await db.getAllDevices(); + + expect(devices).toHaveLength(2); + expect(devices[0].deviceStateInfo?.identity.label).toBe('Healthy device'); + expect(devices[1].deviceStateInfo).toBeUndefined(); + expect(devices[1].featuresInfo?.label).toBe('Legacy fallback'); + expect(devices[1].settings).toEqual({}); + }); + + it('uses the canonical DeviceState display name for OneKey wallets', async () => { + const state = createState({ + revision: 1, + updatedAt: 100, + label: 'Stale compatibility label', + language: 'en-US', + }); + state.identity.label = 'My Pro 2'; + const db = new DeviceStateTestLocalDb(state); + const wallet: IDBWallet = { + id: 'hw-wallet-1', + name: 'Pro2 6136', + type: 'hw', + backuped: true, + accounts: [], + nextIds: {}, + associatedDevice: db.device.id, + walletNo: 1, + }; + + const result = await db.refillWalletInfo({ + wallet, + allDevices: [db.refillDeviceInfo({ device: db.device })], + }); + + expect(result.name).toBe('My Pro 2'); + }); + + it('repairs a wallet name previously polluted by the BLE name', async () => { + const state = createState({ + revision: 1, + updatedAt: 100, + label: null, + bleName: 'Pro2 6136', + language: 'en-US', + }); + const db = new DeviceStateTestLocalDb(state); + const wallet: IDBWallet = { + id: 'hw-wallet-1', + name: 'Pro2 6136', + type: 'hw', + backuped: true, + accounts: [], + nextIds: {}, + associatedDevice: db.device.id, + walletNo: 1, + }; + + const result = await db.refillWalletInfo({ + wallet, + allDevices: [db.refillDeviceInfo({ device: db.device })], + }); + + expect(result.name).toBe('OneKey Pro 2'); + }); + + it('uses the legacy Features label before DeviceState persistence', async () => { + const state = createState({ + revision: 1, + updatedAt: 100, + label: null, + language: 'en-US', + }); + const db = new DeviceStateTestLocalDb(state); + db.device.deviceState = undefined; + db.device.features = JSON.stringify({ label: 'Legacy OneKey Name' }); + const wallet: IDBWallet = { + id: 'hw-wallet-legacy', + name: 'Old Wallet Name', + type: 'hw', + backuped: true, + accounts: [], + nextIds: {}, + associatedDevice: db.device.id, + walletNo: 1, + }; + + const result = await db.refillWalletInfo({ + wallet, + allDevices: [db.refillDeviceInfo({ device: db.device })], + }); + + expect(result.name).toBe('Legacy OneKey Name'); + }); + + it('strips SDK-internal raw and session fields before persistence', () => { + const state = createState({ + revision: 1, + updatedAt: 1, + label: 'Safe state', + language: 'en-US', + }); + (state as unknown as { raw?: unknown }).raw = { protocolV2DeviceInfo: {} }; + (state as unknown as { session?: unknown }).session = { + sessionId: 'private-session', + }; + + const persisted = sanitizeDeviceStateForPersistence(state); + + expect(persisted).not.toHaveProperty('raw'); + expect(persisted).not.toHaveProperty('session'); + expect(state).toHaveProperty('session'); + }); + + it('sanitizes device state when structuredClone is unavailable on Hermes', () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'structuredClone', + ); + Object.defineProperty(globalThis, 'structuredClone', { + configurable: true, + value: undefined, + }); + + try { + expect( + sanitizeDeviceStateForPersistence( + createState({ + revision: 2, + updatedAt: 2, + label: null, + language: null, + }), + ), + ).toMatchObject({ revision: 2, updatedAt: 2 }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'structuredClone', descriptor); + } else { + Reflect.deleteProperty(globalThis, 'structuredClone'); + } + } + }); + + it('merges sparse reconnect state without erasing the persisted label or settings', async () => { + const current = createState({ + revision: 8, + updatedAt: 100, + label: 'My Pro 2', + language: 'ja-JP', + }); + const incoming = createState({ + revision: 1, + updatedAt: 200, + label: null, + language: null, + firmware: '1.1.0', + }); + const db = new DeviceStateTestLocalDb(current); + + await db.updateDeviceState({ + connectId: 'abc-def', + state: incoming, + revision: incoming.revision, + source: 'transport-reconnect', + changedKeys: ['identity.bleName', 'versions.firmware'], + }); + + const persisted = JSON.parse(db.device.deviceState || '{}'); + expect(persisted.identity.label).toBe('My Pro 2'); + expect(persisted.identity).not.toHaveProperty('displayName'); + expect(persisted.settings.language).toBe('ja-JP'); + expect(persisted.versions.firmware).toBe('1.1.0'); + expect(db.device.connectProtocol).toBe('V2'); + expect(db.device.name).toBe('My Pro 2'); + }); + + it('ignores an event older than the persisted state', async () => { + const current = createState({ + revision: 3, + updatedAt: 300, + label: 'Newest', + language: 'en-US', + }); + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: 'Older', + language: 'zh-CN', + }); + const db = new DeviceStateTestLocalDb(current); + + await db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'transport-reconnect', + changedKeys: ['identity.label', 'settings.language'], + }); + + const persisted = JSON.parse(db.device.deviceState || '{}'); + expect(persisted.identity.label).toBe('Newest'); + expect(persisted.settings.language).toBe('en-US'); + }); + + it('orders SDK events by instance epoch and monotonic sequence', async () => { + const persistedFutureState = createState({ + revision: 99, + updatedAt: 9_999_999, + label: 'Future timestamp', + language: 'en-US', + }); + const firstCurrentInstanceState = createState({ + revision: 0, + updatedAt: 10, + label: 'Current instance', + language: 'ja-JP', + }); + const clockRollbackState = createState({ + revision: 1, + updatedAt: 5, + label: 'Clock rolled back', + language: 'zh-CN', + }); + const rebuiltSdkState = createState({ + revision: 0, + updatedAt: 1, + label: 'Rebuilt SDK', + language: 'de-DE', + }); + const db = new DeviceStateTestLocalDb(persistedFutureState); + + await db.updateDeviceState({ + changedKeys: ['*'], + connectId: 'ABC-DEF', + revision: firstCurrentInstanceState.revision, + sdkEventSequence: 1, + sdkInstanceEpoch: 1, + source: 'initialize', + state: firstCurrentInstanceState, + }); + await db.updateDeviceState({ + changedKeys: ['identity.label', 'settings.language'], + connectId: 'ABC-DEF', + revision: clockRollbackState.revision, + sdkEventSequence: 2, + sdkInstanceEpoch: 1, + source: 'device-status', + state: clockRollbackState, + }); + await db.updateDeviceState({ + changedKeys: ['identity.label'], + connectId: 'ABC-DEF', + revision: firstCurrentInstanceState.revision, + sdkEventSequence: 1, + sdkInstanceEpoch: 1, + source: 'delayed-event', + state: firstCurrentInstanceState, + }); + await db.updateDeviceState({ + changedKeys: ['*'], + connectId: 'ABC-DEF', + revision: rebuiltSdkState.revision, + sdkEventSequence: 1, + sdkInstanceEpoch: 2, + source: 'initialize', + state: rebuiltSdkState, + }); + + const persisted = JSON.parse(db.device.deviceState || '{}'); + expect(persisted.identity.label).toBe('Rebuilt SDK'); + expect(persisted.settings.language).toBe('de-DE'); + expect(persisted.updatedAt).toBe(1); + }); + + it('uses the stable product name when a V1 device has no label or BLE name', async () => { + const current = createState({ + revision: 1, + updatedAt: 100, + label: null, + bleName: '', + language: null, + deviceType: EDeviceType.Classic1s, + model: '1', + }); + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: null, + bleName: '', + language: null, + deviceType: EDeviceType.Classic1s, + model: '1', + }); + const db = new DeviceStateTestLocalDb(current); + + await db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'initialize', + changedKeys: ['identity.label'], + }); + + const persisted = JSON.parse(db.device.deviceState || '{}'); + expect(persisted.identity).not.toHaveProperty('displayName'); + expect(db.device.name).toBe('OneKey Classic 1S'); + }); + + it('prefers stable serial identity over a reused connect id', async () => { + const firstState = createState({ + revision: 1, + updatedAt: 100, + label: 'First device', + language: 'en-US', + serialNo: 'SERIAL-A', + }); + const secondState = createState({ + revision: 1, + updatedAt: 100, + label: 'Second device', + language: 'en-US', + serialNo: 'SERIAL-B', + }); + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: 'Renamed second device', + language: 'en-US', + serialNo: 'SERIAL-B', + }); + const db = new DeviceStateTestLocalDb(firstState); + db.devices[0].connectId = 'REUSED-CONNECT-ID'; + db.devices[0].uuid = 'SERIAL-A'; + db.devices.push({ + ...db.devices[0], + id: 'device-db-2', + name: deviceUtils.getDeviceDisplayName({ state: secondState }), + uuid: 'SERIAL-B', + deviceState: JSON.stringify(secondState), + }); + + await db.updateDeviceState({ + connectId: 'REUSED-CONNECT-ID', + state: incoming, + revision: incoming.revision, + source: 'apply-settings', + changedKeys: ['identity.label'], + }); + + expect(JSON.parse(db.devices[0].deviceState || '{}').identity.label).toBe( + 'First device', + ); + expect(JSON.parse(db.devices[1].deviceState || '{}').identity.label).toBe( + 'Renamed second device', + ); + }); + + it('synchronizes canonical identity without duplicating DeviceState into persisted features', async () => { + const current = createState({ + revision: 1, + updatedAt: 100, + label: 'My Pro 2', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: null, + }); + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: 'My Pro 2', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'LIVE_DEVICE_ID', + }); + const db = new DeviceStateTestLocalDb(current); + db.devices[0].features = JSON.stringify({ + deviceId: 'STALE_DEVICE_ID', + $app_firmware_type: 'universal', + }); + + await db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'device-status', + changedKeys: ['identity.deviceId'], + }); + + expect(db.device.deviceId).toBe('LIVE_DEVICE_ID'); + expect(db.device.uuid).toBe('SERIAL-A'); + expect(db.device.deviceType).toBe(EDeviceType.Pro2); + expect(JSON.parse(db.device.features)).toEqual({ + $app_firmware_type: 'universal', + }); + + const hydrated = db.refillDeviceInfo({ device: db.device }); + expect(hydrated.featuresInfo?.deviceId).toBe('LIVE_DEVICE_ID'); + expect(hydrated.featuresInfo?.device_id).toBe('LIVE_DEVICE_ID'); + expect(hydrated.featuresInfo?.$app_firmware_type).toBe('universal'); + }); + + it('migrates a legacy record matched by connectId despite a stale uuid', async () => { + const legacyState = createState({ + revision: 1, + updatedAt: 100, + label: 'Legacy Pro 2', + language: 'en-US', + serialNo: '', + deviceId: 'DEVICE-A', + }); + const liveState = createState({ + revision: 2, + updatedAt: 200, + label: 'Migrated Pro 2', + language: 'ja-JP', + serialNo: 'SERIAL-A', + deviceId: 'DEVICE-A', + }); + const db = new DeviceStateTestLocalDb(legacyState); + db.device.deviceState = undefined; + db.device.uuid = 'LEGACY-BLE-UUID'; + db.device.deviceId = 'DEVICE-A'; + db.device.features = JSON.stringify({ + label: 'Legacy Pro 2', + $app_firmware_type: 'universal', + }); + + await expect( + db.updateDeviceState({ + connectId: 'ABC-DEF', + state: liveState, + revision: liveState.revision, + source: 'initialize', + changedKeys: ['*'], + }), + ).resolves.toMatchObject({ + kind: 'updated', + deviceDbId: 'device-db-1', + }); + + expect(db.device.uuid).toBe('SERIAL-A'); + expect(db.device.deviceStateInfo?.identity.deviceId).toBe('DEVICE-A'); + expect(JSON.parse(db.device.features)).toEqual({ + $app_firmware_type: 'universal', + }); + }); + + it('refreshes cached DeviceState and legacy passphrase projections before returning', async () => { + const current = createState({ + revision: 1, + updatedAt: 100, + label: 'My Pro 2', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'DEVICE-A', + passphraseProtection: false, + }); + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: 'My Pro 2', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'DEVICE-A', + passphraseProtection: true, + }); + const db = new DeviceStateTestLocalDb(current); + db.device.uuid = 'SERIAL-A'; + db.device.deviceId = 'DEVICE-A'; + db.device.features = JSON.stringify({ + passphraseProtection: false, + $app_firmware_type: 'universal', + }); + + await db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'apply-settings', + changedKeys: ['status.passphraseProtection'], + }); + + expect(db.device.deviceStateInfo?.status.passphraseProtection).toBe(true); + expect(db.device.featuresInfo?.passphraseProtection).toBe(true); + expect(JSON.parse(db.device.features)).toEqual({ + $app_firmware_type: 'universal', + }); + }); + + it('hydrates legacy Features consumers from DeviceState while preserving only app metadata', () => { + const state = createState({ + revision: 3, + updatedAt: 300, + label: 'Canonical Pro 2', + language: 'ja-JP', + firmware: '2.0.0', + serialNo: 'SERIAL-A', + deviceId: 'DEVICE-A', + }); + const db = new DeviceStateTestLocalDb(state); + db.device.features = JSON.stringify({ + staleField: 'must-not-win', + $app_firmware_type: 'bitcoinOnly', + }); + + const hydrated = db.refillDeviceInfo({ device: db.device }); + + expect(hydrated.featuresInfo).toMatchObject({ + deviceId: 'DEVICE-A', + serialNo: 'SERIAL-A', + label: 'Canonical Pro 2', + language: 'ja-JP', + firmwareVersion: '2.0.0', + $app_firmware_type: 'bitcoinOnly', + }); + expect(hydrated.featuresInfo).not.toHaveProperty('staleField'); + }); + + it('matches the exact wallet-lifecycle identity when one serial has multiple records', async () => { + const oldState = createState({ + revision: 5, + updatedAt: 100, + label: 'Old wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'OLD_DEVICE_ID', + }); + const currentState = createState({ + revision: 2, + updatedAt: 150, + label: 'Current wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'CURRENT_DEVICE_ID', + }); + const incoming = createState({ + revision: 3, + updatedAt: 200, + label: 'Renamed current wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'CURRENT_DEVICE_ID', + }); + const db = new DeviceStateTestLocalDb(oldState); + db.devices[0].uuid = 'SERIAL-A'; + db.devices[0].deviceId = 'OLD_DEVICE_ID'; + db.devices.push({ + ...db.devices[0], + id: 'device-db-2', + name: deviceUtils.getDeviceDisplayName({ state: currentState }), + deviceId: 'CURRENT_DEVICE_ID', + deviceState: JSON.stringify(currentState), + updatedAt: 2, + }); + + await expect( + db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'apply-settings', + changedKeys: ['identity.label'], + }), + ).resolves.toMatchObject({ + kind: 'updated', + deviceDbId: 'device-db-2', + }); + + expect(JSON.parse(db.devices[0].deviceState || '{}').identity.label).toBe( + 'Old wallet', + ); + expect(JSON.parse(db.devices[1].deviceState || '{}').identity.label).toBe( + 'Renamed current wallet', + ); + }); + + it('compares a new reset identity with the latest record for the same serial', async () => { + const oldState = createState({ + revision: 5, + updatedAt: 100, + label: 'Old wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'OLD_DEVICE_ID', + }); + const currentState = createState({ + revision: 2, + updatedAt: 150, + label: 'Current wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'CURRENT_DEVICE_ID', + }); + const resetState = createState({ + revision: 1, + updatedAt: 200, + label: 'Reset again', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'NEXT_DEVICE_ID', + }); + const db = new DeviceStateTestLocalDb(oldState); + db.devices[0].uuid = 'SERIAL-A'; + db.devices[0].deviceId = 'OLD_DEVICE_ID'; + db.devices.push({ + ...db.devices[0], + id: 'device-db-2', + name: deviceUtils.getDeviceDisplayName({ state: currentState }), + deviceId: 'CURRENT_DEVICE_ID', + deviceState: JSON.stringify(currentState), + updatedAt: 2, + }); + + await expect( + db.updateDeviceState({ + connectId: 'ABC-DEF', + state: resetState, + revision: resetState.revision, + source: 'device-status', + changedKeys: ['identity.deviceId'], + }), + ).resolves.toMatchObject({ + kind: 'identity-mismatch', + deviceDbId: 'device-db-2', + currentDeviceId: 'CURRENT_DEVICE_ID', + incomingDeviceId: 'NEXT_DEVICE_ID', + }); + }); + + it('isolates a reset identity instead of overwriting the wallet bound to the serial', async () => { + const current = createState({ + revision: 8, + updatedAt: 100, + label: 'Original wallet', + language: 'en-US', + serialNo: 'SERIAL-A', + deviceId: 'OLD_DEVICE_ID', + }); + const incoming = createState({ + revision: 9, + updatedAt: 200, + label: 'Reset wallet', + language: 'ja-JP', + serialNo: 'SERIAL-A', + deviceId: 'NEW_DEVICE_ID', + }); + const db = new DeviceStateTestLocalDb(current); + db.devices[0].uuid = 'SERIAL-A'; + db.devices[0].deviceId = 'OLD_DEVICE_ID'; + + await expect( + db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'device-status', + changedKeys: ['identity.deviceId', 'status.unlocked'], + }), + ).resolves.toMatchObject({ + kind: 'identity-mismatch', + deviceDbId: 'device-db-1', + currentDeviceId: 'OLD_DEVICE_ID', + incomingDeviceId: 'NEW_DEVICE_ID', + }); + + expect(JSON.parse(db.device.deviceState || '{}')).toEqual(current); + }); +}); diff --git a/packages/kit-bg/src/dbs/local/LocalDbBase.ts b/packages/kit-bg/src/dbs/local/LocalDbBase.ts index 28888549cdd3..bbfdd83d2010 100644 --- a/packages/kit-bg/src/dbs/local/LocalDbBase.ts +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.ts @@ -4,6 +4,7 @@ import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import { Semaphore } from 'async-mutex'; import { + cloneDeep, debounce, isEmpty, isNil, @@ -86,6 +87,11 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { + hasDeviceStateIdentityMismatch, + mergeDeviceStateEvent, + projectLegacyDeviceFeaturesFromState, +} from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { CoreSDKLoader } from '@onekeyhq/shared/src/hardware/instance'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -122,6 +128,7 @@ import type { IDeviceHomeScreen, IDeviceVersionCacheInfo, IOneKeyDeviceFeatures, + IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import type { IKeylessCloudSyncCredential } from '@onekeyhq/shared/types/keylessCloudSync'; import type { @@ -201,6 +208,76 @@ import type { import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IDeviceType } from '@onekeyfe/hd-core'; +export function sanitizeDeviceStateForPersistence( + state: IOneKeyDeviceState, +): IOneKeyDeviceState { + const persistedState = cloneDeep(state); + delete (persistedState as unknown as { raw?: unknown }).raw; + delete (persistedState as unknown as { session?: unknown }).session; + delete ( + persistedState.identity as unknown as { + displayName?: unknown; + } + ).displayName; + return persistedState; +} + +function getAppFeatureParams( + features?: Record, +): Record { + return Object.fromEntries( + Object.entries(features ?? {}).filter(([key]) => key.startsWith('$app_')), + ); +} + +function parsePersistedFeatures(features?: string): IOneKeyDeviceFeatures { + try { + return JSON.parse(features || '{}') as IOneKeyDeviceFeatures; + } catch { + return {} as IOneKeyDeviceFeatures; + } +} + +function parsePersistedDeviceState( + deviceState?: string, +): IOneKeyDeviceState | undefined { + try { + const parsed = JSON.parse(deviceState || 'null') as unknown; + if (!isPlainObject(parsed)) { + return undefined; + } + const stateRecord = parsed as Record; + if ( + !isPlainObject(stateRecord.identity) || + !isPlainObject(stateRecord.status) || + !isPlainObject(stateRecord.settings) || + !isPlainObject(stateRecord.versions) + ) { + return undefined; + } + return parsed as IOneKeyDeviceState; + } catch { + return undefined; + } +} + +export type IUpdateDeviceStateResult = + | { + kind: 'updated'; + deviceDbId: string; + state: IOneKeyDeviceState; + } + | { + kind: 'identity-mismatch'; + deviceDbId: string; + currentDeviceId: string; + incomingDeviceId: string; + } + | { + kind: 'ignored'; + reason: 'device-not-found' | 'stale'; + }; + const LOCAL_PASSWORD_KDF_LAZY_UPGRADE_CREDENTIAL_BATCH_SIZE = 3; const LOCAL_SECRET_ENVELOPE_CREDENTIAL_MIGRATION_BATCH_SIZE = 3; const LOCAL_SECRET_ENVELOPE_CREDENTIAL_MIGRATION_TARGET_VERSION = 1; @@ -555,7 +632,8 @@ function parseDeviceSettingsRaw(settingsRaw?: string): IDBDeviceSettings { return {}; } try { - return JSON.parse(settingsRaw) as IDBDeviceSettings; + const parsed = JSON.parse(settingsRaw) as unknown; + return isPlainObject(parsed) ? (parsed as IDBDeviceSettings) : {}; } catch { return {}; } @@ -730,6 +808,11 @@ type IResolveExistingDeviceParams = { }; export abstract class LocalDbBase extends LocalDbBaseContainer { + private deviceStateEventOrderByDeviceId = new Map< + string, + { sdkEventSequence: number; sdkInstanceEpoch: number } + >(); + tempWallets: { [walletId: string]: boolean; } = {}; @@ -3386,16 +3469,29 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { wallet.name = vendorLabel; } } else { - // OneKey devices: sync name from features.label - const label = device?.featuresInfo?.label; - if (device && label && label !== wallet.name) { + // Only a real device label may rename the wallet. BLE names are + // transport identities and must not overwrite the user-facing name. + const state = device?.deviceStateInfo; + const isBleNamePollution = Boolean( + state && + !state.identity.label && + state.identity.bleName && + wallet.name === state.identity.bleName, + ); + const displayName = state + ? state.identity.label || + (isBleNamePollution && state.identity.deviceType + ? deviceUtils.getDefaultDeviceLabel(state.identity.deviceType) + : undefined) + : device?.featuresInfo?.label; + if (device && displayName && displayName !== wallet.name) { appEventBus.emit(EAppEventBusNames.SyncDeviceLabelToWalletName, { walletId: wallet.id, dbDeviceId: device.id, - label, + label: displayName, walletName: wallet.name, }); - wallet.name = label; + wallet.name = displayName; } } } @@ -4975,6 +5071,14 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { const featuresInfo = await deviceUtils.attachAppParamsToFeatures({ features: updateFeatures, }); + const persistedFeatures = device.deviceStateInfo + ? { + ...getAppFeatureParams( + parsePersistedFeatures(device.features) as Record, + ), + ...getAppFeatureParams(featuresInfo as Record), + } + : featuresInfo; let isUpdated = false; await this.withTransaction(EIndexedDBBucketNames.account, async (tx) => { await this.txUpdateRecords({ @@ -4982,7 +5086,7 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { name: ELocalDBStoreNames.Device, ids: [device.id], updater: async (item) => { - const newFeatures = stringUtils.stableStringify(featuresInfo); + const newFeatures = stringUtils.stableStringify(persistedFeatures); if (item.features !== newFeatures) { item.features = newFeatures; isUpdated = true; @@ -4998,6 +5102,212 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { } } + async updateDeviceState({ + changedKeys, + connectId, + sdkEventSequence, + sdkInstanceEpoch, + state, + }: { + changedKeys: string[]; + connectId?: string | null; + revision: number; + sdkEventSequence?: number; + sdkInstanceEpoch?: number; + source: string; + state: IOneKeyDeviceState; + }): Promise { + const { devices } = await this.getAllDevices(); + const oneKeyDevices = devices.filter( + (item) => + (item.vendor ?? EHardwareVendor.onekey) === EHardwareVendor.onekey, + ); + const serialNo = state.identity.serialNo; + const deviceId = state.identity.deviceId; + const getPersistedDeviceId = (item: IDBDevice) => + item.deviceStateInfo?.identity.deviceId || item.deviceId; + const serialCandidates = serialNo + ? oneKeyDevices.filter((item) => item.uuid === serialNo) + : []; + let device = + serialNo && deviceId + ? serialCandidates.find( + (item) => getPersistedDeviceId(item) === deviceId, + ) + : undefined; + if (!device && deviceId) { + device = oneKeyDevices.find( + (item) => + getPersistedDeviceId(item) === deviceId && + (!serialNo || !item.uuid || item.uuid === serialNo), + ); + } + if (!device && serialCandidates.length > 0) { + device = [...serialCandidates].toSorted( + (left, right) => right.updatedAt - left.updatedAt, + )[0]; + } + if (!device) { + const normalizedConnectId = connectId?.toLowerCase(); + device = normalizedConnectId + ? oneKeyDevices.find( + (item) => + [item.connectId, item.usbConnectId, item.bleConnectId].some( + (value) => value?.toLowerCase() === normalizedConnectId, + ) && + (!deviceId || !item.deviceId || item.deviceId === deviceId), + ) + : undefined; + } + if (!device) { + return { kind: 'ignored' as const, reason: 'device-not-found' as const }; + } + let updateResult: IUpdateDeviceStateResult = { + kind: 'ignored', + reason: 'stale', + }; + let updatedState: IOneKeyDeviceState | undefined; + await this.withTransaction(EIndexedDBBucketNames.account, async (tx) => { + await this.txUpdateRecords({ + tx, + name: ELocalDBStoreNames.Device, + ids: [device.id], + updater: (item) => { + const currentState = parsePersistedDeviceState(item.deviceState); + const currentDeviceId = + currentState?.identity.deviceId || item.deviceId; + if ( + hasDeviceStateIdentityMismatch({ + currentDeviceId, + incomingDeviceId: state.identity.deviceId, + }) + ) { + updateResult = { + kind: 'identity-mismatch', + deviceDbId: item.id, + currentDeviceId, + incomingDeviceId: state.identity.deviceId as string, + }; + return item; + } + const currentEventOrder = this.deviceStateEventOrderByDeviceId.get( + item.id, + ); + const hasSdkEventOrder = + sdkInstanceEpoch !== undefined && sdkEventSequence !== undefined; + const isStaleSdkEvent = Boolean( + hasSdkEventOrder && + currentEventOrder && + (sdkInstanceEpoch < currentEventOrder.sdkInstanceEpoch || + (sdkInstanceEpoch === currentEventOrder.sdkInstanceEpoch && + sdkEventSequence <= currentEventOrder.sdkEventSequence)), + ); + const isStaleLegacyEvent = Boolean( + !hasSdkEventOrder && + currentState && + (state.updatedAt < currentState.updatedAt || + (state.updatedAt === currentState.updatedAt && + state.revision <= currentState.revision)), + ); + if (isStaleSdkEvent || isStaleLegacyEvent) { + return item; + } + const persistedState = mergeDeviceStateEvent({ + currentState, + incomingState: state, + changedKeys, + }); + item.deviceState = stringUtils.stableStringify(persistedState); + item.features = stringUtils.stableStringify( + getAppFeatureParams( + parsePersistedFeatures(item.features) as Record, + ), + ); + if ( + persistedState.protocol === 'V1' || + persistedState.protocol === 'V2' + ) { + item.connectProtocol = persistedState.protocol; + } + item.name = deviceUtils.getDeviceDisplayName({ + state: persistedState, + }); + item.updatedAt = Math.max(item.updatedAt, persistedState.updatedAt); + if (persistedState.identity.deviceId) { + item.deviceId = persistedState.identity.deviceId; + } + if (persistedState.identity.serialNo) { + item.uuid = persistedState.identity.serialNo; + } + if (persistedState.identity.deviceType !== EDeviceType.Unknown) { + item.deviceType = persistedState.identity.deviceType; + } + if (hasSdkEventOrder) { + this.deviceStateEventOrderByDeviceId.set(item.id, { + sdkEventSequence, + sdkInstanceEpoch, + }); + } + updateResult = { + kind: 'updated', + deviceDbId: item.id, + state: persistedState, + }; + updatedState = persistedState; + return item; + }, + }); + }); + if (updatedState) { + const persistedState = updatedState; + device.deviceState = stringUtils.stableStringify(persistedState); + device.features = stringUtils.stableStringify( + getAppFeatureParams( + parsePersistedFeatures(device.features) as Record, + ), + ); + if ( + persistedState.protocol === 'V1' || + persistedState.protocol === 'V2' + ) { + device.connectProtocol = persistedState.protocol; + } + device.name = deviceUtils.getDeviceDisplayName({ state: persistedState }); + device.updatedAt = Math.max(device.updatedAt, persistedState.updatedAt); + if (persistedState.identity.deviceId) { + device.deviceId = persistedState.identity.deviceId; + } + if (persistedState.identity.serialNo) { + device.uuid = persistedState.identity.serialNo; + } + if (persistedState.identity.deviceType !== EDeviceType.Unknown) { + device.deviceType = persistedState.identity.deviceType; + } + this.refillDeviceInfo({ device }); + } + return updateResult; + } + + async updateDeviceConnectProtocol({ + dbDeviceId, + connectProtocol, + }: { + dbDeviceId: string; + connectProtocol: 'V1' | 'V2'; + }): Promise { + await this.withTransaction(EIndexedDBBucketNames.account, async (tx) => { + await this.txUpdateRecords({ + tx, + name: ELocalDBStoreNames.Device, + ids: [dbDeviceId], + updater: (item) => { + item.connectProtocol = connectProtocol; + return item; + }, + }); + }); + } + async updateThirdPartyDeviceFeatures({ vendor, features, @@ -5005,8 +5315,9 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { vendor: EHardwareVendor; features: IOneKeyDeviceFeatures; }) { - const featuresDeviceId = - typeof features.device_id === 'string' ? features.device_id : undefined; + const featuresDeviceId = thirdPartyDeviceUtils.getDeviceId( + features as Record, + ); if (!featuresDeviceId) { return; } @@ -5047,54 +5358,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { }); } - async updateDeviceFeaturesLabel({ - dbDeviceId, - label, - }: { - dbDeviceId: string; - label: string; - }) { - const device = await this.getDevice(dbDeviceId); - await this.withTransaction(EIndexedDBBucketNames.account, async (tx) => { - await this.txUpdateRecords({ - tx, - name: ELocalDBStoreNames.Device, - ids: [dbDeviceId], - updater: async (item) => { - item.features = JSON.stringify({ - ...device.featuresInfo, - label, - }); - return item; - }, - }); - }); - } - - async updateDeviceFeaturesPassphraseProtection({ - dbDeviceId, - passphraseProtection, - }: { - dbDeviceId: string; - passphraseProtection: boolean; - }) { - const device = await this.getDevice(dbDeviceId); - await this.withTransaction(EIndexedDBBucketNames.account, async (tx) => { - await this.txUpdateRecords({ - tx, - name: ELocalDBStoreNames.Device, - ids: [dbDeviceId], - updater: async (item) => { - item.features = JSON.stringify({ - ...device.featuresInfo, - passphrase_protection: passphraseProtection, - }); - return item; - }, - }); - }); - } - async updateDeviceVersionInfo({ dbDeviceId, versionCacheInfo, @@ -5117,11 +5380,23 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { name: ELocalDBStoreNames.Device, ids: [dbDeviceId], updater: async (item) => { - item.features = JSON.stringify({ - ...device.featuresInfo, - ...versionCacheInfo, - ...bitcoinOnlyFlag, - }); + const currentFeatures = parsePersistedFeatures(item.features); + item.features = JSON.stringify( + device.deviceStateInfo + ? { + ...getAppFeatureParams( + currentFeatures as Record, + ), + ...getAppFeatureParams( + bitcoinOnlyFlag as Record | undefined, + ), + } + : { + ...device.featuresInfo, + ...versionCacheInfo, + ...bitcoinOnlyFlag, + }, + ); return item; }, }); @@ -5710,8 +5985,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { } async buildHwWalletId(params: IDBCreateHwWalletParams) { - const { getDeviceType, getDeviceUUID } = await CoreSDKLoader(); - const { name, device, @@ -5720,7 +5993,8 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { isFirmwareVerified, vendor, } = params; - const deviceUUID = device.uuid || getDeviceUUID(features); + const deviceUUID = + device.uuid || deviceUtils.getDeviceSerialNoFromFeatures(features) || ''; const rawDeviceId = deviceUtils.getRawDeviceId({ device, features, @@ -5900,8 +6174,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { hiddenDefaultWalletName = hiddenWalletNameInfo.hiddenWalletName; } - const featuresStr = JSON.stringify(featuresInfo); - const firstAccountIndex = 0; let addedHdAccountIndex = -1; @@ -5928,17 +6200,25 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { // BLE connections - set bleConnectId but don't override connectId // @ts-expect-error bleConnectId = (device.bleConnectId || connectId) ?? undefined; - // If connectId is empty, get it from getDeviceUUID for compatibility + // If connectId is empty, get it from device serial number for compatibility if (!compatibleConnectId) { - const { getDeviceUUID } = await CoreSDKLoader(); - const uuid = + const hardwareSdk = await CoreSDKLoader(); + const getDeviceSerialNo = + ( + hardwareSdk as typeof hardwareSdk & { + getDeviceSerialNo?: typeof hardwareSdk.getDeviceUUID; + } + ).getDeviceSerialNo ?? hardwareSdk.getDeviceUUID; + const fallbackConnectId = buildTrezorDesktopBleUsbConnectId({ vendor: resolvedVendor, transportType, rawDeviceId, - }) || getDeviceUUID(features); - compatibleConnectId = uuid; - usbConnectId = uuid; + }) || + deviceUtils.getDeviceSerialNoFromFeatures(features) || + getDeviceSerialNo(features); + compatibleConnectId = fallbackConnectId; + usbConnectId = fallbackConnectId; } break; default: @@ -5958,14 +6238,34 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { vendor: resolvedVendor, }; + const initialDeviceState = params.deviceState + ? sanitizeDeviceStateForPersistence(params.deviceState) + : undefined; + const featuresStr = JSON.stringify( + initialDeviceState && !profile.isThirdParty + ? getAppFeatureParams(featuresInfo as Record) + : featuresInfo, + ); + const deviceToAdd: IDBDevice = { id: dbDeviceId, - name: deviceName, + name: initialDeviceState + ? deviceUtils.getDeviceDisplayName({ state: initialDeviceState }) + : deviceName, + connectProtocol: + params.connectProtocol ?? + (initialDeviceState?.protocol === 'V1' || + initialDeviceState?.protocol === 'V2' + ? initialDeviceState.protocol + : undefined), connectId: compatibleConnectId || '', uuid: deviceUUID, deviceId: rawDeviceId, deviceType, features: featuresStr, + deviceState: initialDeviceState + ? stringUtils.stableStringify(initialDeviceState) + : undefined, settingsRaw: JSON.stringify(initialSettings), createdAt: now, updatedAt: now, @@ -6061,9 +6361,12 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { ids: [dbDeviceId], updater: async (item) => { item.features = featuresStr; + item.deviceState = deviceToAdd.deviceState ?? item.deviceState; + item.connectProtocol = + deviceToAdd.connectProtocol ?? item.connectProtocol; item.updatedAt = now; - // Use compatibleConnectId which includes getDeviceUUID fallback for BLE + // Use compatibleConnectId which includes serial-number fallback for BLE item.connectId = compatibleConnectId || item.connectId || ''; item.uuid = deviceUUID; item.deviceId = rawDeviceId; @@ -8256,7 +8559,7 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { ) => Promise<'match' | 'mismatch' | 'unknown'>; vendor?: EHardwareVendor; }): Promise { - // Third-party devices may not have rawDeviceId (features.device_id). + // Third-party devices may not have rawDeviceId. // Use vendorProfile.canMatchDeviceByConnectId to determine if connectId // is reliable enough to identify an existing device. if (!rawDeviceId) { @@ -8417,7 +8720,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { features?: IOneKeyDeviceFeatures; vendor?: EHardwareVendor; }): Promise { - const { getDeviceUUID } = await CoreSDKLoader(); const normalizedVendor = vendor ?? EHardwareVendor.onekey; const { devices } = await this.getAllDevices(); const device = devices.find((item) => { @@ -8449,14 +8751,20 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { if (features) { let uuidInDb = item.uuid; if (!uuidInDb) { - uuidInDb = item.featuresInfo ? getDeviceUUID(item.featuresInfo) : ''; + uuidInDb = + item.deviceStateInfo?.identity.serialNo || + (item.featuresInfo + ? deviceUtils.getDeviceSerialNoFromFeatures(item.featuresInfo) || + '' + : ''); } - const uuidInQuery = getDeviceUUID(features); + const uuidInQuery = + deviceUtils.getDeviceSerialNoFromFeatures(features) || ''; if (uuidInDb && uuidInQuery) { mergePredicate(uuidInQuery === uuidInDb); } else if (!connectId && !featuresDeviceId) { // features is the only discriminator and it can't discriminate here - // (getDeviceUUID reads OneKey-specific serial fields, so a + // (the serial helper reads OneKey-specific fields, so a // third-party device's features always yield an empty UUID) — // constraining by vendor alone would return an arbitrary device of // that vendor. No current caller combines features with connectId @@ -8487,9 +8795,19 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { } refillDeviceInfo({ device }: { device: IDBDevice }) { - device.featuresInfo = JSON.parse(device.features || '{}'); - device.settings = JSON.parse(device.settingsRaw || '{}'); + const persistedFeatures = parsePersistedFeatures(device.features); + device.deviceStateInfo = parsePersistedDeviceState(device.deviceState); + device.settings = parseDeviceSettingsRaw(device.settingsRaw); device.vendor = device.settings?.vendor ?? EHardwareVendor.onekey; + device.featuresInfo = + device.vendor === EHardwareVendor.onekey && device.deviceStateInfo + ? { + ...projectLegacyDeviceFeaturesFromState(device.deviceStateInfo), + ...getAppFeatureParams( + persistedFeatures as Record, + ), + } + : persistedFeatures; return device; } diff --git a/packages/kit-bg/src/dbs/local/consts.ts b/packages/kit-bg/src/dbs/local/consts.ts index 229384215045..cb00528fc0b6 100644 --- a/packages/kit-bg/src/dbs/local/consts.ts +++ b/packages/kit-bg/src/dbs/local/consts.ts @@ -9,7 +9,7 @@ export const IS_DB_BUCKET_SUPPORT = Boolean( ); const LOCAL_DB_NAME = 'OneKeyV5'; -const LOCAL_DB_VERSION = 19; +const LOCAL_DB_VERSION = 20; // ---------------------------------------------- diff --git a/packages/kit-bg/src/dbs/local/realm/schemas/RealmSchemaDevice.ts b/packages/kit-bg/src/dbs/local/realm/schemas/RealmSchemaDevice.ts index b28043abffa0..77120880acc3 100644 --- a/packages/kit-bg/src/dbs/local/realm/schemas/RealmSchemaDevice.ts +++ b/packages/kit-bg/src/dbs/local/realm/schemas/RealmSchemaDevice.ts @@ -3,6 +3,7 @@ import { RealmObjectBase } from '../base/RealmObjectBase'; import type { IDBDevice } from '../../types'; import type { IDeviceType } from '@onekeyfe/hd-core'; +import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared'; import type Realm from 'realm'; class RealmSchemaDevice extends RealmObjectBase { @@ -21,6 +22,10 @@ class RealmSchemaDevice extends RealmObjectBase { */ public features!: string; + public deviceState?: string; + + public connectProtocol?: HardwareConnectProtocol; + /** * ble connectId address (mac) */ @@ -80,6 +85,8 @@ class RealmSchemaDevice extends RealmObjectBase { deviceType: 'string', settingsRaw: 'string', features: 'string', + deviceState: 'string?', + connectProtocol: 'string?', createdAt: 'int', updatedAt: 'int', verifiedAtVersion: 'string?', @@ -98,6 +105,8 @@ class RealmSchemaDevice extends RealmObjectBase { deviceId: this.deviceId, deviceType: this.deviceType, features: this.features, + deviceState: this.deviceState, + connectProtocol: this.connectProtocol, settingsRaw: this.settingsRaw, createdAt: this.createdAt, updatedAt: this.updatedAt, diff --git a/packages/kit-bg/src/dbs/local/types.ts b/packages/kit-bg/src/dbs/local/types.ts index 8830e2bf815b..c21461d85a3a 100644 --- a/packages/kit-bg/src/dbs/local/types.ts +++ b/packages/kit-bg/src/dbs/local/types.ts @@ -28,6 +28,7 @@ import type { IDeviceHomeScreen, IHardwareGetPubOrAddressExtraInfo, IOneKeyDeviceFeatures, + IOneKeyDeviceState, IQrWalletDevice, } from '@onekeyhq/shared/types/device'; import type { IExternalConnectionInfo } from '@onekeyhq/shared/types/externalWallet.types'; @@ -53,7 +54,10 @@ import type { RealmSchemaHardwareHomeScreen } from './realm/schemas/RealmSchemaH import type { RealmSchemaIndexedAccount } from './realm/schemas/RealmSchemaIndexedAccount'; import type { RealmSchemaWallet } from './realm/schemas/RealmSchemaWallet'; import type { IDeviceType, SearchDevice } from '@onekeyfe/hd-core'; -import type { EFirmwareType } from '@onekeyfe/hd-shared'; +import type { + EFirmwareType, + HardwareConnectProtocol, +} from '@onekeyfe/hd-shared'; import type { DBSchema } from 'idb'; // ---------------------------------------------- base @@ -205,6 +209,9 @@ export type IDBCreateHwWalletParamsBase = { name?: string; device: Omit; features: IOneKeyDeviceFeatures; + connectProtocol?: HardwareConnectProtocol; + /** Unified OneKey SDK state snapshot populated only by background services. */ + deviceState?: IOneKeyDeviceState; isFirmwareVerified?: boolean; skipDeviceCancel?: boolean; hideCheckingDeviceLoading?: boolean; @@ -404,17 +411,34 @@ export type IDBDeviceSettings = { vendorFirmwareVersion?: string; }; export type IDBDevice = IDBBaseObjectWithName & { - features: string; // TODO rename to featuresRaw + /** + * Legacy persisted Features field. + * OneKey DeviceState devices may store only `$app_*` local metadata here; + * V1 compatibility records, QR wallets, and third-party devices may persist full Features. + */ + features: string; + /** + * Runtime compatibility projection, not the source of truth for OneKey devices. + * @deprecated OneKey flows should read deviceStateInfo; third-party devices still use this field. + */ featuresInfo?: IOneKeyDeviceFeatures & { // only qr wallet $app_firmware_type?: EFirmwareType; - }; // readonly field // TODO rename to features + }; + deviceState?: string; + deviceStateInfo?: IOneKeyDeviceState; + /** + * Transport handshake protocol selected before device communication. + * This is independent from DeviceState.protocolVersion. + */ + connectProtocol?: HardwareConnectProtocol; // TODO make index for better performance (getDeviceByQuery) connectId: string; // alias BLE mac or USB sn, never changed even if device reset name: string; // TODO make index for better performance (getDeviceByQuery) uuid: string; - deviceId: string; // features.device_id changed after device reset, use deviceUtils.getRawDeviceId() + /** Wallet-lifecycle ID; stable across reboots and changes after wipe/reinitialization. */ + deviceId: string; deviceType: IDeviceType; settingsRaw: string; settings?: IDBDeviceSettings; diff --git a/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts b/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts index 16bf2da47325..dc1dcdc41b91 100644 --- a/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts +++ b/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts @@ -556,6 +556,15 @@ export class SimpleDb { return value; } + get hardwarePortfolioSync() { + const SimpleDbEntityHardwarePortfolioSync = ( + require('../entity/SimpleDbEntityHardwarePortfolioSync') as unknown as typeof import('../entity/SimpleDbEntityHardwarePortfolioSync') + ).SimpleDbEntityHardwarePortfolioSync; + const value = new SimpleDbEntityHardwarePortfolioSync(); + Object.defineProperty(this, 'hardwarePortfolioSync', { value }); + return value; + } + get appStatus() { const value = createLazyServiceProxy({ serviceName: 'simpleDb@appStatus', diff --git a/packages/kit-bg/src/dbs/simple/base/SimpleDbProxy.ts b/packages/kit-bg/src/dbs/simple/base/SimpleDbProxy.ts index cf0536760cab..f809c2235931 100644 --- a/packages/kit-bg/src/dbs/simple/base/SimpleDbProxy.ts +++ b/packages/kit-bg/src/dbs/simple/base/SimpleDbProxy.ts @@ -36,6 +36,7 @@ import type { SimpleDbEntityEarnOrders } from '../entity/SimpleDbEntityEarnOrder import type { SimpleDbEntityFeeInfo } from '../entity/SimpleDbEntityFeeInfo'; import type { SimpleDbEntityFloatingIconDomainBlockList } from '../entity/SimpleDbEntityFloatingIconDomainBlockList'; import type { SimpleDbEntityFloatingIconSettings } from '../entity/SimpleDbEntityFloatingIconSettings'; +import type { SimpleDbEntityHardwarePortfolioSync } from '../entity/SimpleDbEntityHardwarePortfolioSync'; import type { SimpleDbEntityIpTable } from '../entity/SimpleDbEntityIpTable'; import type { SimpleDbEntityLegacyWalletNames } from '../entity/SimpleDbEntityLegacyWalletNames'; import type { SimpleDbEntityLightning } from '../entity/SimpleDbEntityLightning'; @@ -237,6 +238,10 @@ export class SimpleDbProxy 'babylonSync', ) as SimpleDbEntityBabylonSync; + hardwarePortfolioSync = this._createProxyService( + 'hardwarePortfolioSync', + ) as SimpleDbEntityHardwarePortfolioSync; + appStatus = this._createProxyService('appStatus') as SimpleDbEntityAppStatus; allNetworks = this._createProxyService( diff --git a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts index 19df1d691725..64d35919e398 100644 --- a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityAppStatus.ts @@ -11,6 +11,11 @@ export type IWalletAssetStatusAnalyticsState = { lastSnapshotReportedAt?: number; }; +export type IHardwareConnectProtocolCacheEntry = { + protocol: 'V1' | 'V2'; + updatedAt: number; +}; + export interface ISimpleDBAppStatus { // hdWalletHashGenerated?: boolean; // hdWalletXfpGenerated?: boolean; @@ -29,8 +34,15 @@ export interface ISimpleDBAppStatus { fixHardwareLtcXPubMigrated?: boolean; btcFreshAddressSettingMigrated?: boolean; removeDeviceHomeScreenMigrated?: boolean; + /** Version of the one-time connect protocol backfill for existing devices. */ + hardwareConnectProtocolMigrationVersion?: number; lastWalletProfileAnalyticsAt?: number; walletAssetStatusAnalytics?: IWalletAssetStatusAnalyticsState; + /** Confirmed protocols keyed by normalized transport endpoint. */ + hardwareConnectProtocolByConnectId?: Record< + string, + IHardwareConnectProtocolCacheEntry + >; // OneKey IDs (onekeyUserId) that have already seen the KYT intro dialog. // Scoped per Prime user so each account is prompted once. kytIntroShownUserIds?: string[]; diff --git a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHardwarePortfolioSync.ts b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHardwarePortfolioSync.ts new file mode 100644 index 000000000000..ae35bdb0a2ca --- /dev/null +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHardwarePortfolioSync.ts @@ -0,0 +1,51 @@ +import { SimpleDbEntityBase } from '../base/SimpleDbEntityBase'; + +export type IHardwarePortfolioSyncTargetState = { + // Content hash of the last snapshot actually submitted/uploaded for this + // target. Used for dedup so an unchanged portfolio is not re-synced. + lastContentHash?: string; + // Timestamp of the last successful hardware transfer for this target. Used + // for the transfer cooldown. + lastTransferAt?: number; + // Standard wallet whose snapshot was last applied. Missing legacy values + // force one overwrite so unknown hidden-wallet remnants cannot survive. + lastWalletId?: string; +}; + +export type IHardwarePortfolioSyncData = { + // Keyed by the authoritative persisted device id. Per-target state keeps + // simultaneously connected devices in independent dedup/cooldown domains. + targets: Record; +}; + +export class SimpleDbEntityHardwarePortfolioSync extends SimpleDbEntityBase { + entityName = 'hardwarePortfolioSync'; + + override enableCache = false; + + async getTargetState( + targetKey: string, + ): Promise { + if (!targetKey) { + return undefined; + } + const data = await this.getRawData(); + return data?.targets?.[targetKey]; + } + + async updateTargetState( + targetKey: string, + patch: IHardwarePortfolioSyncTargetState, + ): Promise { + if (!targetKey) { + return; + } + // setRawData runs the updater under a mutex, so concurrent per-target + // writes merge instead of clobbering each other. + await this.setRawData((rawData) => { + const targets = { ...rawData?.targets }; + targets[targetKey] = { ...targets[targetKey], ...patch }; + return { targets }; + }); + } +} diff --git a/packages/kit-bg/src/migrations/v4ToV5Migration/v4local/v4localDBTypesSchema.ts b/packages/kit-bg/src/migrations/v4ToV5Migration/v4local/v4localDBTypesSchema.ts index f85b086490b8..d81703081ec7 100644 --- a/packages/kit-bg/src/migrations/v4ToV5Migration/v4local/v4localDBTypesSchema.ts +++ b/packages/kit-bg/src/migrations/v4ToV5Migration/v4local/v4localDBTypesSchema.ts @@ -84,7 +84,7 @@ export type IV4DBDevice = IV4DBBaseObjectWithName & { name: string; // TODO make index for better performance (getDeviceByQuery) uuid: string; - deviceId: string; // features.device_id changed after device reset + deviceId: string; // rawDeviceId changed after device reset deviceType: IDeviceType; payloadJson: string; // settingsRaw // settings?: IDBDeviceSettings; diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.deviceReset.test.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.deviceReset.test.ts new file mode 100644 index 000000000000..050ea2c9f872 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.deviceReset.test.ts @@ -0,0 +1,213 @@ +import { HardwareErrorCode } from '@onekeyfe/hd-shared'; + +import { DeviceNotSame } from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; +import { convertDeviceError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { ETranslations, LOCALES } from '@onekeyhq/shared/src/locale'; +import { EHardwareCallContext } from '@onekeyhq/shared/types/device'; + +import ServiceAccount from './ServiceAccount'; + +const mockBatchGetAddresses = jest.fn(); + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + AccountUpdate: 'AccountUpdate', + WalletUpdate: 'WalletUpdate', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + updateDeviceConnectProtocol: jest.fn(), + }, +})); + +jest.mock('../../vaults/factory', () => ({ + vaultFactory: { + getWalletOnlyVault: jest.fn(async () => ({ + keyring: { + batchGetAddresses: mockBatchGetAddresses, + }, + })), + }, +})); + +async function expectDeviceResetChinesePrompt(error: unknown) { + expect(error).toBeInstanceOf(DeviceNotSame); + expect((error as DeviceNotSame).key).toBe( + ETranslations.hardware_device_information_is_inconsistent_it_may_be_caused_by_device_reset, + ); + const zhCNMessages = await LOCALES['zh-CN'](); + expect( + zhCNMessages[ + ETranslations + .hardware_device_information_is_inconsistent_it_may_be_caused_by_device_reset + ], + ).toBe( + '设备连接状态已更新。请选择「添加钱包」>「连接硬件钱包」来重新设置。使用原助记词将恢复当前钱包,使用新助记词将创建新钱包。', + ); +} + +describe('ServiceAccount device reset isolation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('在接收地址入口将已确认 deviceId 不一致的 deprecated 钱包映射为中文设备重置提示', async () => { + const service = new ServiceAccount({ + backgroundApi: { + servicePassword: { + promptPasswordVerifyByWallet: jest.fn( + async ({ walletId }: { walletId: string }) => ({ + password: '', + isHardware: true, + isQrWallet: false, + deviceParams: await service.getWalletDeviceParams({ + walletId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + }), + ), + }, + serviceHardware: { + getCompatibleConnectId: jest.fn().mockResolvedValue('PRO2_USB'), + }, + }, + }); + service.getWallet = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + deprecated: true, + associatedDevice: 'db-device-1', + }); + const getWalletDevice = jest.fn().mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_USB', + deviceId: 'OLD_DEVICE_ID', + }); + service.getWalletDevice = getWalletDevice; + + const error = await service + .verifyHWAccountAddresses({ + walletId: 'hw-wallet-1', + networkId: 'evm--1', + indexes: [0], + indexedAccountId: undefined, + deriveType: 'default', + }) + .catch((e: unknown) => e); + + await expectDeviceResetChinesePrompt(error); + expect(getWalletDevice).not.toHaveBeenCalled(); + }); + + it('接收地址实时校验发现 deviceId 不一致时透传中文设备重置提示', async () => { + mockBatchGetAddresses.mockRejectedValueOnce( + convertDeviceError({ + code: HardwareErrorCode.DeviceCheckDeviceIdError, + error: 'Device Id in the features is not same.', + connectId: 'PRO2_USB', + deviceId: 'NEW_DEVICE_ID', + }), + ); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (operation: () => Promise) => operation(), + ), + }, + serviceNetwork: { + getVaultSettings: jest.fn().mockResolvedValue({ + accountType: 'simple', + }), + }, + }, + }); + service.getPrepareHDOrHWAccountsParams = jest.fn().mockResolvedValue({ + prepareParams: { + indexes: [0], + }, + deviceParams: { + dbDevice: { + id: 'db-device-1', + }, + }, + networkId: 'evm--1', + walletId: 'hw-wallet-1', + }); + + const error = await service + .verifyHWAccountAddresses({ + walletId: 'hw-wallet-1', + networkId: 'evm--1', + indexes: [0], + indexedAccountId: undefined, + deriveType: 'default', + }) + .catch((e: unknown) => e); + + await expectDeviceResetChinesePrompt(error); + }); + + it('在创建隐藏钱包前拒绝已被设备重置标记为 deprecated 的钱包', async () => { + const service = new ServiceAccount({ + backgroundApi: {}, + }); + service.getWallet = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + deprecated: true, + associatedDevice: 'db-device-1', + }); + const getWalletDevice = jest.fn(); + service.getWalletDevice = getWalletDevice; + + const error = await service + .createHWHiddenWallet({ walletId: 'hw-wallet-1' }) + .catch((e: unknown) => e); + + await expectDeviceResetChinesePrompt(error); + expect(getWalletDevice).not.toHaveBeenCalled(); + }); + + it('允许使用 mocked 标准钱包作为隐藏钱包创建占位记录', async () => { + const service = new ServiceAccount({ + backgroundApi: {}, + }); + service.getWallet = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + deprecated: false, + isMocked: true, + associatedDevice: 'db-device-1', + }); + const getWalletDevice = jest + .fn() + .mockRejectedValue(new Error('reached device lookup')); + service.getWalletDevice = getWalletDevice; + + const error = await service + .createHWHiddenWallet({ walletId: 'hw-wallet-1' }) + .catch((e: unknown) => e); + + expect(error).toEqual(new Error('reached device lookup')); + expect(getWalletDevice).toHaveBeenCalledWith({ walletId: 'hw-wallet-1' }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hwWalletCreateAddress.test.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hwWalletCreateAddress.test.ts new file mode 100644 index 000000000000..3f1753196dfc --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hwWalletCreateAddress.test.ts @@ -0,0 +1,221 @@ +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import ServiceAccount from './ServiceAccount'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + AccountUpdate: 'AccountUpdate', + WalletUpdate: 'WalletUpdate', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: {}, +})); + +type IHwWalletCreateAddressService = { + getFeaturesForHwWalletCreate(params: { + dbDevice: { + vendor: EHardwareVendor; + connectProtocol: 'V1' | 'V2'; + deviceStateInfo: unknown; + }; + compatibleConnectId: string; + }): Promise<{ + protocol?: string; + deviceId?: string; + }>; + getFirstEvmAddressForHwWalletCreate(params: { + compatibleConnectId: string; + deviceId: string; + passphraseState?: string; + vendor?: EHardwareVendor; + isMockedStandardHwWallet?: boolean; + }): Promise; +}; + +describe('ServiceAccount hardware wallet creation address', () => { + it('创建 Pro1 隐藏钱包时复用已持久化状态,避免打断刚建立的 passphrase 会话', async () => { + const getDeviceState = jest.fn(); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getDeviceState, + }, + }, + }) as unknown as IHwWalletCreateAddressService; + const deviceStateInfo = { + schemaVersion: 1, + revision: 1, + updatedAt: 1, + protocol: 'V1', + identity: { + deviceId: 'PRO1_DEVICE_ID', + serialNo: 'PRO1_SERIAL', + }, + status: { + mode: 'normal', + unlocked: true, + passphraseProtection: true, + }, + settings: {}, + versions: { + firmware: '4.15.0', + }, + }; + + await expect( + service.getFeaturesForHwWalletCreate({ + dbDevice: { + vendor: EHardwareVendor.onekey, + connectProtocol: 'V1', + deviceStateInfo, + }, + compatibleConnectId: 'PRO1_USB', + }), + ).resolves.toMatchObject({ + protocol: 'V1', + deviceId: 'PRO1_DEVICE_ID', + }); + + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('创建 Pro2 隐藏钱包时仍读取实时设备状态', async () => { + const liveState = { + schemaVersion: 1, + revision: 2, + updatedAt: 2, + protocol: 'V2', + identity: { + deviceId: 'PRO2_DEVICE_ID', + serialNo: 'PRO2_SERIAL', + }, + status: { + mode: 'normal', + unlocked: true, + passphraseProtection: true, + }, + settings: {}, + versions: { + firmware: '1.0.0', + }, + }; + const getDeviceState = jest.fn().mockResolvedValue(liveState); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getDeviceState, + }, + }, + }) as unknown as IHwWalletCreateAddressService; + + await expect( + service.getFeaturesForHwWalletCreate({ + dbDevice: { + vendor: EHardwareVendor.onekey, + connectProtocol: 'V2', + deviceStateInfo: { + ...liveState, + revision: 1, + identity: { + ...liveState.identity, + deviceId: 'STALE_DEVICE_ID', + }, + }, + }, + compatibleConnectId: 'PRO2_USB', + }), + ).resolves.toMatchObject({ + protocol: 'V2', + deviceId: 'PRO2_DEVICE_ID', + }); + + expect(getDeviceState).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + }); + }); + + it('derives a OneKey hidden wallet address from its passphrase state', async () => { + const getEvmAddressByWalletState = jest.fn().mockResolvedValue('0xhidden'); + const getEvmAddressByStandardWallet = jest + .fn() + .mockResolvedValue('0xstandard'); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getEvmAddressByWalletState, + getEvmAddressByStandardWallet, + }, + }, + }) as unknown as IHwWalletCreateAddressService; + + await expect( + service.getFirstEvmAddressForHwWalletCreate({ + compatibleConnectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + passphraseState: 'PRO2_HIDDEN_STATE', + vendor: EHardwareVendor.onekey, + }), + ).resolves.toBe('0xhidden'); + + expect(getEvmAddressByWalletState).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + path: "m/44'/60'/0'/0/0", + vendor: EHardwareVendor.onekey, + passphraseState: 'PRO2_HIDDEN_STATE', + useEmptyPassphrase: undefined, + }); + expect(getEvmAddressByStandardWallet).not.toHaveBeenCalled(); + }); + + it('keeps standard wallet creation on the empty passphrase', async () => { + const getEvmAddressByWalletState = jest + .fn() + .mockResolvedValue('0xstandard'); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getEvmAddressByWalletState, + }, + }, + }) as unknown as IHwWalletCreateAddressService; + + await expect( + service.getFirstEvmAddressForHwWalletCreate({ + compatibleConnectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + }), + ).resolves.toBe('0xstandard'); + + expect(getEvmAddressByWalletState).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + path: "m/44'/60'/0'/0/0", + vendor: EHardwareVendor.onekey, + passphraseState: undefined, + useEmptyPassphrase: true, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.operationLease.test.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.operationLease.test.ts new file mode 100644 index 000000000000..6a667c403d60 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.operationLease.test.ts @@ -0,0 +1,321 @@ +import ServiceBatchCreateAccount from '../ServiceBatchCreateAccount/ServiceBatchCreateAccount'; +import { + HardwareProcessingManager, + type IOneKeyHardwareOperationLease, +} from '../ServiceHardwareUI/HardwareProcessingManager'; + +import ServiceAccount from './ServiceAccount'; + +const mockPrepareAccounts = jest.fn(async () => []); +const mockBatchGetAddresses = jest.fn(async () => []); + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + AccountUpdate: 'AccountUpdate', + WalletUpdate: 'WalletUpdate', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: {}, +})); + +jest.mock('../../states/jotai/atoms/prime', () => ({ + primeTransferAtom: { + set: jest.fn(async () => undefined), + }, +})); + +jest.mock('../../vaults/factory', () => ({ + vaultFactory: { + getWalletOnlyVault: jest.fn(async () => ({ + keyring: { + prepareAccounts: mockPrepareAccounts, + batchGetAddresses: mockBatchGetAddresses, + }, + getNetworkInfo: jest.fn(async () => ({})), + })), + }, +})); + +describe('ServiceAccount hardware operation lease', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('复用真实外层 lease,避免 prepareAccounts 重入时等待自己持有的硬件锁', async () => { + const manager = new HardwareProcessingManager(); + const withHardwareProcessing = jest.fn( + async ( + operation: () => Promise, + options: { oneKeyOperationLease?: IOneKeyHardwareOperationLease }, + ) => + manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + lease: options.oneKeyOperationLease, + operation: () => operation(), + }), + ); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardwareUI: { + withHardwareProcessing, + }, + }, + }); + service.getPrepareHDOrHWAccountsParams = jest.fn(async () => ({ + prepareParams: {}, + deviceParams: { + dbDevice: { + id: 'device-1', + }, + }, + networkId: 'evm--1', + walletId: 'hw-1', + })) as unknown as typeof service.getPrepareHDOrHWAccountsParams; + + const nestedFlow = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + operation: (oneKeyOperationLease) => + service.prepareHdOrHwAccounts({ + walletId: 'hw-1', + networkId: 'evm--1', + deriveType: 'default', + indexedAccountId: undefined, + oneKeyOperationLease, + } as Parameters[0]), + }); + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('nested hardware operation deadlocked')), + 500, + ); + }); + + await expect(Promise.race([nestedFlow, timeout])).resolves.toMatchObject({ + accounts: [], + }); + clearTimeout(timeoutId); + + expect(withHardwareProcessing).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + oneKeyOperationLease: expect.objectContaining({ + deviceKey: 'device-1', + }), + }), + ); + expect(mockPrepareAccounts).toHaveBeenCalledTimes(1); + }); + + it('复用真实外层 lease,避免验证地址进入 previewBatchBuildAccounts 时死锁', async () => { + const manager = new HardwareProcessingManager(); + const withHardwareProcessing = jest.fn( + async ( + operation: (lease: IOneKeyHardwareOperationLease) => Promise, + options: { oneKeyOperationLease?: IOneKeyHardwareOperationLease }, + ) => + manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + lease: options.oneKeyOperationLease, + operation, + }), + ); + const deviceParams = { + dbDevice: { + id: 'device-1', + }, + }; + const backgroundApi = { + serviceAccount: { + getWalletDeviceParams: jest.fn(async () => deviceParams), + }, + serviceHardwareUI: { + closeHardwareUiStateDialog: jest.fn(async () => undefined), + withHardwareProcessing, + }, + serviceNetwork: { + getVaultSettings: jest.fn(async () => ({ accountType: 'simple' })), + }, + } as Record; + const serviceBatchCreateAccount = new ServiceBatchCreateAccount({ + backgroundApi, + }); + Object.assign(serviceBatchCreateAccount, { + buildBatchCreateAccountsNetworksParams: jest.fn(async () => []), + getHwAllNetworkPrepareAccountsResponse: jest.fn(async () => ({ + destroy: jest.fn(), + })), + batchBuildAccounts: jest.fn(async () => ({ + accountsForCreate: [{ address: '0x1234' }], + })), + }); + Object.assign(backgroundApi, { serviceBatchCreateAccount }); + + const service = new ServiceAccount({ backgroundApi }); + service.getPrepareHDOrHWAccountsParams = jest.fn(async () => ({ + prepareParams: { indexes: [0] }, + deviceParams, + networkId: 'evm--1', + walletId: 'hw-1', + })) as unknown as typeof service.getPrepareHDOrHWAccountsParams; + + const verifyFlow = service.verifyHWAccountAddresses({ + walletId: 'hw-1', + networkId: 'evm--1', + indexes: [0], + indexedAccountId: undefined, + deriveType: 'default', + }); + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('verify address hardware operation deadlocked')), + 500, + ); + }); + + await expect(Promise.race([verifyFlow, timeout])).resolves.toEqual([ + '0x1234', + ]); + clearTimeout(timeoutId); + + expect(withHardwareProcessing).toHaveBeenCalledTimes(2); + expect(withHardwareProcessing).toHaveBeenLastCalledWith( + expect.any(Function), + expect.objectContaining({ + oneKeyOperationLease: expect.objectContaining({ + deviceKey: 'device-1', + }), + }), + ); + }); + + it('从批量建账号入口透传 lease 到 prepareHdOrHwAccounts', async () => { + const manager = new HardwareProcessingManager(); + const withHardwareProcessing = jest.fn( + async ( + operation: (lease: IOneKeyHardwareOperationLease) => Promise, + options: { oneKeyOperationLease?: IOneKeyHardwareOperationLease }, + ) => + manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + lease: options.oneKeyOperationLease, + operation, + }), + ); + const deviceParams = { + dbDevice: { + id: 'device-1', + }, + }; + const backgroundApi = { + serviceHardwareUI: { + withHardwareProcessing, + }, + serviceNetwork: { + getVaultSettings: jest.fn(async () => ({ + mergeDeriveAssetsEnabled: false, + })), + }, + servicePrimeTransfer: { + isInTransferImportOrBackupRestoreFlow: jest.fn(async () => false), + }, + } as Record; + const serviceAccount = new ServiceAccount({ backgroundApi }); + serviceAccount.getPrepareHDOrHWAccountsParams = jest.fn(async () => ({ + prepareParams: {}, + deviceParams, + networkId: 'evm--1', + walletId: 'hw-1', + })) as unknown as typeof serviceAccount.getPrepareHDOrHWAccountsParams; + Object.assign(serviceAccount, { + getWalletDeviceParams: jest.fn(async () => deviceParams), + }); + Object.assign(backgroundApi, { serviceAccount }); + + const serviceBatchCreateAccount = new ServiceBatchCreateAccount({ + backgroundApi, + }); + const buildBatchCreateAccountsNetworksParams = jest.fn(async () => [ + { + walletId: 'hw-1', + networkId: 'evm--1', + deriveType: 'default', + indexes: [0], + }, + ]); + const getHwAllNetworkPrepareAccountsResponse = jest.fn( + async () => undefined, + ); + Object.assign(serviceBatchCreateAccount, { + buildBatchCreateAccountsNetworksParams, + getHwAllNetworkPrepareAccountsResponse, + }); + const batchBuildAccounts = jest.spyOn( + serviceBatchCreateAccount, + 'batchBuildAccounts', + ); + const prepareHdOrHwAccounts = jest.spyOn( + serviceAccount, + 'prepareHdOrHwAccounts', + ); + + const batchFlow = serviceBatchCreateAccount.startBatchCreateAccountsFlow({ + mode: 'normal', + params: { + walletId: 'hw-1', + networkId: 'evm--1', + deriveType: 'default', + indexes: [0], + saveToDb: false, + }, + }); + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('batch account hardware operation deadlocked')), + 500, + ); + }); + + await expect(Promise.race([batchFlow, timeout])).resolves.toMatchObject({ + accountsForCreate: [], + }); + clearTimeout(timeoutId); + + expect(withHardwareProcessing).toHaveBeenCalledTimes(2); + expect(withHardwareProcessing).toHaveBeenLastCalledWith( + expect.any(Function), + expect.objectContaining({ + oneKeyOperationLease: expect.objectContaining({ + deviceKey: 'device-1', + }), + }), + ); + expect(buildBatchCreateAccountsNetworksParams).toHaveBeenCalledTimes(1); + expect(getHwAllNetworkPrepareAccountsResponse).toHaveBeenCalledTimes(1); + expect(batchBuildAccounts).toHaveBeenCalledTimes(1); + expect(prepareHdOrHwAccounts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts index 1396c6db2d7d..75989496949c 100644 --- a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts @@ -84,13 +84,17 @@ import { OneKeyInternalError, OneKeyLocalError, } from '@onekeyhq/shared/src/errors'; -import { DeviceNotOpenedPassphrase } from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; +import { + DeviceNotOpenedPassphrase, + DeviceNotSame, +} from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; import { EOneKeyErrorClassNames } from '@onekeyhq/shared/src/errors/types/errorTypes'; import errorUtils from '@onekeyhq/shared/src/errors/utils/errorUtils'; import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { appLocale } from '@onekeyhq/shared/src/locale/appLocale'; @@ -211,6 +215,11 @@ import { isDefaultBotWalletName, resolveBotWalletSyncItemDataTime, } from './botWalletCreateUtils'; +import { buildBtcOnlyFirmwareCacheKey } from './btcOnlyFirmwareCacheUtils'; +import { + refreshDeviceStateAfterStandardWalletUnlock, + resolveDeviceStateForHwWalletCreate, +} from './deviceStateForHwWalletCreate'; import { getHwHiddenWalletPassphraseState } from './hardwarePassphraseState'; import { type IKeylessWalletRemovalCapability, @@ -237,6 +246,7 @@ import type { IPrepareWatchingAccountsParams, IValidateGeneralInputParams, } from '../../vaults/types'; +import type { IOneKeyHardwareOperationLease } from '../ServiceHardwareUI/HardwareProcessingManager'; import type { IWithHardwareProcessingControlParams } from '../ServiceHardwareUI/ServiceHardwareUI'; export type IAddHDOrHWAccountsParams = { @@ -252,6 +262,7 @@ export type IAddHDOrHWAccountsParams = { hdCredentialCacheScopeId?: string; // auto multi-network fill scene flag (business derived from it, not passed in) isAutoCreateMultiNetwork?: boolean; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; // purpose?: number; // skipRepeat?: boolean; @@ -1269,6 +1280,7 @@ class ServiceAccount extends ServiceBase { skipDeviceCancelAtFirst, hideCheckingDeviceLoading, skipWaitingAnimationAtFirst, + oneKeyOperationLease, } = params; const { prepareParams, deviceParams, networkId, walletId } = @@ -1304,6 +1316,7 @@ class ServiceAccount extends ServiceBase { hideCheckingDeviceLoading, debugMethodName: 'keyring.prepareAccounts', skipWaitingAnimationAtFirst, + oneKeyOperationLease, }, ); @@ -3447,6 +3460,14 @@ class ServiceAccount extends ServiceBase { } const wallet = await this.getWallet({ walletId }); + if (wallet.deprecated) { + throw new DeviceNotSame(); + } + if (accountUtils.isWalletDeprecatedOrMocked(wallet)) { + throw new OneKeyLocalError( + 'Hardware wallet is unavailable after device reset', + ); + } const dbDevice = await this.getWalletDevice({ walletId }); // Ensure connectId is compatible for the current transport type @@ -3470,6 +3491,23 @@ class ServiceAccount extends ServiceBase { } } + const isOneKeyDevice = + (dbDevice.vendor ?? EHardwareVendor.onekey) === EHardwareVendor.onekey; + const connectProtocol = + dbDevice.connectProtocol ?? + dbDevice.deviceStateInfo?.protocol ?? + (isOneKeyDevice ? undefined : dbDevice.featuresInfo?.protocol); + if ( + !dbDevice.connectProtocol && + (connectProtocol === 'V1' || connectProtocol === 'V2') + ) { + await localDb.updateDeviceConnectProtocol({ + dbDeviceId: dbDevice.id, + connectProtocol, + }); + dbDevice.connectProtocol = connectProtocol; + } + return { confirmOnDevice: EConfirmOnDeviceType.LastItem, dbDevice, @@ -3479,6 +3517,9 @@ class ServiceAccount extends ServiceBase { useEmptyPassphrase: !wallet.passphraseState, // Pre-warm signal; only sign methods honor it (getAddress etc. just MISS) usePreInitialize: true, + ...(connectProtocol === 'V1' || connectProtocol === 'V2' + ? { connectProtocol } + : {}), }, }; } @@ -3504,11 +3545,29 @@ class ServiceAccount extends ServiceBase { features = connected.payload.features as IOneKeyDeviceFeatures; } } else { - features = await this.backgroundApi.serviceHardware.getFeatures({ - connectId: compatibleConnectId, - }); + const persistedState = dbDevice.deviceStateInfo; + const protocol = dbDevice.connectProtocol ?? persistedState?.protocol; + // Pro 1 already opened the hidden-wallet session in the previous step. + // Reading live state without its passphrase context would restore the + // standard Protocol V1 session and prompt again while deriving the XFP. + const state = + protocol === 'V1' && persistedState + ? persistedState + : await this.backgroundApi.serviceHardware.getDeviceState({ + connectId: compatibleConnectId, + }); + features = projectLegacyDeviceFeaturesFromState(state); + } + if (features) { + return features; + } + if ( + (dbDevice.vendor ?? EHardwareVendor.onekey) === EHardwareVendor.onekey && + dbDevice.deviceStateInfo + ) { + return projectLegacyDeviceFeaturesFromState(dbDevice.deviceStateInfo); } - return features || dbDevice.featuresInfo || ({} as IOneKeyDeviceFeatures); + return dbDevice.featuresInfo || ({} as IOneKeyDeviceFeatures); } private async getFirstEvmAddressForHwWalletCreate({ @@ -3527,15 +3586,6 @@ class ServiceAccount extends ServiceBase { if (isMockedStandardHwWallet) { return ''; } - const vendorProfile = vendor ? getVendorProfile(vendor) : undefined; - if (!vendorProfile?.isThirdParty) { - return this.backgroundApi.serviceHardware.getEvmAddressByStandardWallet({ - connectId: compatibleConnectId, - deviceId, - path: FIRST_EVM_ADDRESS_PATH, - vendor, - }); - } return this.backgroundApi.serviceHardware.getEvmAddressByWalletState({ connectId: compatibleConnectId, deviceId, @@ -3558,8 +3608,18 @@ class ServiceAccount extends ServiceBase { hideCheckingDeviceLoading?: boolean; isAttachPinMode?: boolean; }) { + const wallet = await this.getWallet({ walletId }); + if (wallet.deprecated) { + throw new DeviceNotSame(); + } const dbDevice = await this.getWalletDevice({ walletId }); const { connectId } = dbDevice; + const storedConnectProtocol = + dbDevice.connectProtocol ?? dbDevice.deviceStateInfo?.protocol; + const connectProtocol = + storedConnectProtocol === 'V1' || storedConnectProtocol === 'V2' + ? storedConnectProtocol + : undefined; const compatibleConnectId = await this.backgroundApi.serviceHardware.getCompatibleConnectId({ connectId, @@ -3604,6 +3664,8 @@ class ServiceAccount extends ServiceBase { const dbWallet = await this.createHWWalletBase({ device: deviceUtils.dbDeviceToSearchDevice(dbDevice), features: resolvedFeatures, + connectProtocol, + deviceState: dbDevice.deviceStateInfo, passphraseState, fillingXfpByCallingSdk: true, }); @@ -3634,7 +3696,7 @@ class ServiceAccount extends ServiceBase { return { ...dbWallet, - isAttachPinMode: resolvedFeatures.unlocked_attach_pin, + isAttachPinMode: resolvedFeatures.unlockedAttachPin, }; }, { @@ -3756,22 +3818,44 @@ class ServiceAccount extends ServiceBase { hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); - const deviceId = deviceUtils.getRawDeviceId({ + let deviceId = deviceUtils.getRawDeviceId({ device: params.device, features, isThirdParty: vendorProfile?.isThirdParty, }); - let xfp: string | undefined; - if (fillingXfpByCallingSdk && !isMockedStandardHwWallet) { - xfp = await this.backgroundApi.serviceHardware.buildHwWalletXfp({ - connectId: compatibleConnectId, - deviceId, - passphraseState, - throwError: true, - withUserInteraction: true, - vendor, + const getDeviceStateForHwWalletCreate = ( + connectId: string, + stateParams: { scope: 'runtime' }, + ) => + this.backgroundApi.serviceHardware.getDeviceState({ + connectId, + params: { + ...stateParams, + ...(params.connectProtocol + ? { connectProtocol: params.connectProtocol } + : {}), + }, }); + let deviceState = await resolveDeviceStateForHwWalletCreate({ + existingState: params.deviceState, + preserveWalletSession: + !vendorProfile?.isThirdParty && + params.connectProtocol === 'V1' && + Boolean(passphraseState), + isThirdParty: Boolean(vendorProfile?.isThirdParty), + isMocked: Boolean(isMockedStandardHwWallet), + connectId: compatibleConnectId, + getDeviceState: getDeviceStateForHwWalletCreate, + onError: (error) => + defaultLogger.hardware.sdkLog.log( + 'createHWWalletBase: unable to seed canonical device state', + error instanceof Error ? error.message : 'Unknown error', + ), + }); + const liveDeviceId = deviceState?.identity.deviceId; + if (!vendorProfile?.isThirdParty && liveDeviceId) { + deviceId = liveDeviceId; } // Refresh DB info when compatibility lookup resolves to another connectId. // Skip empty connectId: getDeviceByQuery would otherwise match by vendor @@ -3789,8 +3873,39 @@ class ServiceAccount extends ServiceBase { params.device = refreshedDevice; } } + if (!vendorProfile?.isThirdParty && liveDeviceId) { + params.device = { ...params.device, deviceId: liveDeviceId }; + params.features = { ...params.features, deviceId: liveDeviceId }; + } + + let xfp: string | undefined; + if (fillingXfpByCallingSdk && !isMockedStandardHwWallet) { + xfp = await this.backgroundApi.serviceHardware.buildHwWalletXfp({ + connectId: compatibleConnectId, + deviceId, + passphraseState, + throwError: true, + withUserInteraction: true, + vendor, + }); + } + deviceState = await refreshDeviceStateAfterStandardWalletUnlock({ + existingState: deviceState, + connectProtocol: params.connectProtocol, + isThirdParty: Boolean(vendorProfile?.isThirdParty), + isMocked: Boolean(isMockedStandardHwWallet), + passphraseState, + connectId: compatibleConnectId, + getDeviceState: getDeviceStateForHwWalletCreate, + onError: (error) => + defaultLogger.hardware.sdkLog.log( + 'createHWWalletBase: unable to refresh state after standard wallet unlock', + error instanceof Error ? error.message : 'Unknown error', + ), + }); const result = await localDb.createHwWallet({ ...params, + deviceState, vendor, xfp, passphraseState: passphraseState || '', @@ -5752,7 +5867,7 @@ class ServiceAccount extends ServiceBase { const isThirdPartyVendor = getVendorProfile(deviceVendor).isThirdParty; return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { + async (oneKeyOperationLease) => { const addresses = await vault.keyring.batchGetAddresses(prepareParams); if (!isEmpty(addresses)) { return addresses.map((address) => address.address); @@ -5768,6 +5883,7 @@ class ServiceAccount extends ServiceBase { indexes: prepareParams.indexes, showOnOneKey: true, isVerifyAddressAction: prepareParams.isVerifyAddressAction, + oneKeyOperationLease, }, ); const results: string[] = []; @@ -6665,7 +6781,24 @@ class ServiceAccount extends ServiceBase { }; generateHwWalletsMissingXfpDebounced = debounce( - this.generateHwWalletsMissingXfpFn, + (params: Parameters[0]) => { + const operation = () => this.generateHwWalletsMissingXfpFn(params); + const vendor = + params.wallet?.associatedDeviceInfo?.vendor ?? EHardwareVendor.onekey; + if (getVendorProfile(vendor).isThirdParty) { + void operation(); + return; + } + void this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + operation, + { + deviceKey: + params.wallet?.associatedDevice || + params.deviceId || + params.connectId, + }, + ); + }, 3000, { leading: false, @@ -6773,7 +6906,7 @@ class ServiceAccount extends ServiceBase { deviceId: string | undefined; withUserInteraction: boolean; }) { - await this.generateHwWalletsMissingXfpDebounced({ + this.generateHwWalletsMissingXfpDebounced({ wallet, connectId, deviceId, @@ -8058,16 +8191,7 @@ class ServiceAccount extends ServiceBase { { promise: true, primitive: true, - normalizer: ([options]) => { - const fwVendor = options.featuresInfo?.fw_vendor || ''; - const capabilities = - options.featuresInfo?.capabilities?.join(',') ?? ''; - const unitBtcOnly = String( - (options.featuresInfo as { unit_btconly?: boolean } | undefined) - ?.unit_btconly ?? '', - ); - return `${options.walletId}-${fwVendor}-${capabilities}-${unitBtcOnly}`; - }, + normalizer: ([options]) => buildBtcOnlyFirmwareCacheKey(options), maxAge: timerUtils.getTimeDurationMs({ seconds: 60 }), max: 5, }, diff --git a/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.test.ts b/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.test.ts new file mode 100644 index 000000000000..0781d213e7fb --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.test.ts @@ -0,0 +1,43 @@ +import { EFirmwareType } from '@onekeyfe/hd-shared'; + +import { buildBtcOnlyFirmwareCacheKey } from './btcOnlyFirmwareCacheUtils'; + +describe('buildBtcOnlyFirmwareCacheKey', () => { + const walletId = 'hw-wallet-1'; + + it('invalidates the cache when raw firmware vendor changes', () => { + const universal = buildBtcOnlyFirmwareCacheKey({ + walletId, + featuresInfo: { + vendor: 'onekey.so', + fw_vendor: 'OneKey', + }, + }); + const bitcoinOnly = buildBtcOnlyFirmwareCacheKey({ + walletId, + featuresInfo: { + vendor: 'onekey.so', + fw_vendor: 'OneKey Bitcoin-only', + }, + }); + + expect(bitcoinOnly).not.toBe(universal); + }); + + it('invalidates the cache when the App firmware override changes', () => { + const universal = buildBtcOnlyFirmwareCacheKey({ + walletId, + featuresInfo: { + $app_firmware_type: EFirmwareType.Universal, + }, + }); + const bitcoinOnly = buildBtcOnlyFirmwareCacheKey({ + walletId, + featuresInfo: { + $app_firmware_type: EFirmwareType.BitcoinOnly, + }, + }); + + expect(bitcoinOnly).not.toBe(universal); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.ts b/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.ts new file mode 100644 index 000000000000..87415438fc33 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/btcOnlyFirmwareCacheUtils.ts @@ -0,0 +1,28 @@ +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import type { EFirmwareType } from '@onekeyfe/hd-shared'; + +type IFirmwareTypeCacheFeatures = Partial< + Pick +> & { + $app_firmware_type?: EFirmwareType; + fw_vendor?: string | null; + unit_btconly?: boolean; +}; + +export function buildBtcOnlyFirmwareCacheKey({ + walletId, + featuresInfo, +}: { + walletId: string; + featuresInfo?: IFirmwareTypeCacheFeatures; +}) { + return [ + walletId, + featuresInfo?.vendor ?? '', + featuresInfo?.fw_vendor ?? '', + featuresInfo?.capabilities?.join(',') ?? '', + String(featuresInfo?.unit_btconly ?? ''), + featuresInfo?.$app_firmware_type ?? '', + ].join('\u0000'); +} diff --git a/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.test.ts b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.test.ts new file mode 100644 index 000000000000..e4f1e1b6ba41 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.test.ts @@ -0,0 +1,202 @@ +import { + refreshDeviceStateAfterStandardWalletUnlock, + resolveDeviceStateForHwWalletCreate, +} from './deviceStateForHwWalletCreate'; + +describe('resolveDeviceStateForHwWalletCreate', () => { + it('loads the canonical OneKey state before creating the DB record', async () => { + const state = { + revision: 2, + identity: { deviceId: 'DEVICE_ID', displayName: 'My Pro 2' }, + status: { mode: 'normal' }, + } as never; + const getDeviceState = jest.fn().mockResolvedValue(state); + + await expect( + resolveDeviceStateForHwWalletCreate({ + isThirdParty: false, + isMocked: false, + connectId: 'PRO2_USB', + getDeviceState, + }), + ).resolves.toBe(state); + expect(getDeviceState).toHaveBeenCalledWith('PRO2_USB', { + scope: 'runtime', + }); + }); + + it('does not let an existing snapshot bypass the live identity read', async () => { + const existingState = { + identity: { deviceId: 'OLD_DEVICE_ID' }, + status: { mode: 'normal' }, + } as never; + const liveState = { + identity: { deviceId: 'NEW_DEVICE_ID' }, + status: { mode: 'normal' }, + } as never; + const getDeviceState = jest.fn().mockResolvedValue(liveState); + + await expect( + resolveDeviceStateForHwWalletCreate({ + existingState, + isThirdParty: false, + isMocked: false, + connectId: 'PRO2_USB', + getDeviceState, + }), + ).resolves.toBe(liveState); + }); + + it('创建 Pro1 隐藏钱包时保留现有状态,不发送会打断钱包会话的实时读取', async () => { + const existingState = { + identity: { deviceId: 'PRO1_DEVICE_ID' }, + status: { mode: 'normal' }, + } as never; + const getDeviceState = jest.fn(); + + await expect( + resolveDeviceStateForHwWalletCreate({ + existingState, + preserveWalletSession: true, + isThirdParty: false, + isMocked: false, + connectId: 'PRO1_USB', + getDeviceState, + }), + ).resolves.toBe(existingState); + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('rejects a normal OneKey state without a live device id', async () => { + const onError = jest.fn(); + + await expect( + resolveDeviceStateForHwWalletCreate({ + isThirdParty: false, + isMocked: false, + connectId: 'PRO2_USB', + getDeviceState: jest.fn().mockResolvedValue({ + identity: { deviceId: null }, + status: { mode: 'normal' }, + }), + onError, + }), + ).rejects.toThrow('Unable to resolve live hardware device identity'); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('does not add SDK state requirements to third-party wallet creation', async () => { + const getDeviceState = jest.fn(); + + await expect( + resolveDeviceStateForHwWalletCreate({ + isThirdParty: true, + isMocked: false, + connectId: 'LEDGER_USB', + getDeviceState, + }), + ).resolves.toBeUndefined(); + expect(getDeviceState).not.toHaveBeenCalled(); + }); +}); + +describe('refreshDeviceStateAfterStandardWalletUnlock', () => { + it('刷新 Pro2 标准钱包解锁后的 Passphrase 状态', async () => { + const lockedState = { + protocol: 'V2', + identity: { deviceId: 'PRO2_DEVICE_ID' }, + status: { + mode: 'normal', + unlocked: false, + passphraseProtection: null, + }, + } as never; + const unlockedState = { + protocol: 'V2', + identity: { deviceId: 'PRO2_DEVICE_ID' }, + status: { + mode: 'normal', + unlocked: true, + passphraseProtection: true, + }, + } as never; + const getDeviceState = jest.fn().mockResolvedValue(unlockedState); + + await expect( + refreshDeviceStateAfterStandardWalletUnlock({ + existingState: lockedState, + connectProtocol: 'V2', + isThirdParty: false, + isMocked: false, + passphraseState: undefined, + connectId: 'PRO2_USB', + getDeviceState, + }), + ).resolves.toBe(unlockedState); + expect(getDeviceState).toHaveBeenCalledWith('PRO2_USB', { + scope: 'runtime', + }); + }); + + it('不刷新 Pro1 或隐藏钱包会话', async () => { + const existingState = { + protocol: 'V1', + identity: { deviceId: 'PRO1_DEVICE_ID' }, + status: { mode: 'normal', passphraseProtection: true }, + } as never; + const hiddenWalletState = { + protocol: 'V2', + identity: { deviceId: 'PRO2_DEVICE_ID' }, + status: { mode: 'normal', passphraseProtection: true }, + } as never; + const getDeviceState = jest.fn(); + + await expect( + refreshDeviceStateAfterStandardWalletUnlock({ + existingState, + connectProtocol: 'V1', + isThirdParty: false, + isMocked: false, + passphraseState: undefined, + connectId: 'PRO1_USB', + getDeviceState, + }), + ).resolves.toBe(existingState); + await expect( + refreshDeviceStateAfterStandardWalletUnlock({ + existingState: hiddenWalletState, + connectProtocol: 'V2', + isThirdParty: false, + isMocked: false, + passphraseState: 'hidden-session', + connectId: 'PRO2_USB', + getDeviceState, + }), + ).resolves.toBe(hiddenWalletState); + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('刷新失败时沿用建钱包前的状态,不阻断钱包创建', async () => { + const existingState = { + protocol: 'V2', + identity: { deviceId: 'PRO2_DEVICE_ID' }, + status: { mode: 'normal', passphraseProtection: null }, + } as never; + const error = new Error('read failed'); + const onError = jest.fn(); + + await expect( + refreshDeviceStateAfterStandardWalletUnlock({ + existingState, + connectProtocol: 'V2', + isThirdParty: false, + isMocked: false, + passphraseState: undefined, + connectId: 'PRO2_USB', + getDeviceState: jest.fn().mockRejectedValue(error), + onError, + }), + ).resolves.toBe(existingState); + expect(onError).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.ts b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.ts new file mode 100644 index 000000000000..6f757fe1eac5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.ts @@ -0,0 +1,82 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +type IGetDeviceStateForHwWalletCreate = ( + connectId: string, + params: { scope: 'runtime' }, +) => Promise; + +export async function resolveDeviceStateForHwWalletCreate({ + existingState, + preserveWalletSession, + isThirdParty, + isMocked, + connectId, + getDeviceState, + onError, +}: { + existingState?: IOneKeyDeviceState; + preserveWalletSession?: boolean; + isThirdParty: boolean; + isMocked: boolean; + connectId?: string; + getDeviceState: IGetDeviceStateForHwWalletCreate; + onError?: (error: unknown) => void; +}) { + if (preserveWalletSession && existingState) { + return existingState; + } + if (isThirdParty || isMocked || !connectId) { + return existingState; + } + try { + const state = await getDeviceState(connectId, { scope: 'runtime' }); + if (state.status.mode === 'normal' && !state.identity.deviceId) { + throw new OneKeyLocalError( + 'Unable to resolve live hardware device identity', + ); + } + return state; + } catch (error) { + onError?.(error); + throw error; + } +} + +export async function refreshDeviceStateAfterStandardWalletUnlock({ + existingState, + connectProtocol, + isThirdParty, + isMocked, + passphraseState, + connectId, + getDeviceState, + onError, +}: { + existingState?: IOneKeyDeviceState; + connectProtocol?: 'V1' | 'V2'; + isThirdParty: boolean; + isMocked: boolean; + passphraseState?: string; + connectId?: string; + getDeviceState: IGetDeviceStateForHwWalletCreate; + onError?: (error: unknown) => void; +}): Promise { + const protocol = connectProtocol ?? existingState?.protocol; + if ( + protocol !== 'V2' || + isThirdParty || + isMocked || + Boolean(passphraseState) || + !connectId + ) { + return existingState; + } + + try { + return await getDeviceState(connectId, { scope: 'runtime' }); + } catch (error) { + onError?.(error); + return existingState; + } +} diff --git a/packages/kit-bg/src/services/ServiceAccount/hardwarePassphraseState.test.ts b/packages/kit-bg/src/services/ServiceAccount/hardwarePassphraseState.test.ts index 6a356fbb976d..8ff376c932b6 100644 --- a/packages/kit-bg/src/services/ServiceAccount/hardwarePassphraseState.test.ts +++ b/packages/kit-bg/src/services/ServiceAccount/hardwarePassphraseState.test.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { getHwHiddenWalletPassphraseState } from './hardwarePassphraseState'; @@ -53,4 +55,28 @@ describe('getHwHiddenWalletPassphraseState', () => { serviceThirdPartyHardware.getTrezorPassphraseState, ).not.toHaveBeenCalled(); }); + + it('allows Pro2 hidden wallet creation through the core hardware service', async () => { + const serviceHardware = { + getPassphraseState: jest.fn(async () => 'PRO2_PASSPHRASE_STATE'), + }; + const serviceThirdPartyHardware = { + getTrezorPassphraseState: jest.fn(), + }; + + await expect( + getHwHiddenWalletPassphraseState({ + vendor: EHardwareVendor.onekey, + connectId: 'PRO2-USB', + dbDevice: { deviceType: EDeviceType.Pro2 } as never, + serviceHardware, + serviceThirdPartyHardware, + }), + ).resolves.toBe('PRO2_PASSPHRASE_STATE'); + + expect(serviceHardware.getPassphraseState).toHaveBeenCalledWith({ + connectId: 'PRO2-USB', + forceInputPassphrase: true, + }); + }); }); diff --git a/packages/kit-bg/src/services/ServiceBatchCreateAccount/ServiceBatchCreateAccount.ts b/packages/kit-bg/src/services/ServiceBatchCreateAccount/ServiceBatchCreateAccount.ts index c9fca760143c..f2b7d535bc41 100644 --- a/packages/kit-bg/src/services/ServiceBatchCreateAccount/ServiceBatchCreateAccount.ts +++ b/packages/kit-bg/src/services/ServiceBatchCreateAccount/ServiceBatchCreateAccount.ts @@ -83,6 +83,7 @@ import type { IHwAllNetworkPrepareAccountsResponse, } from '../../vaults/types'; import type { IThirdPartyHardwareAdapter } from '../ServiceHardware/adapters/types'; +import type { IOneKeyHardwareOperationLease } from '../ServiceHardwareUI/HardwareProcessingManager'; import type { IWithHardwareProcessingControlParams } from '../ServiceHardwareUI/ServiceHardwareUI'; import type { AllNetworkAddressParams } from '@onekeyfe/hd-core'; import type { @@ -222,6 +223,7 @@ export type IBatchBuildAccountsParams = IBatchBuildAccountsBaseParams & { }; applyRestoreSyncPolicy?: boolean; hdCredentialCacheScopeId?: string; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; }; export type IBatchBuildAccountsNormalFlowParams = @@ -421,7 +423,7 @@ class ServiceBatchCreateAccount extends ServiceBase { | IHwAllNetworkPrepareAccountsResponse | undefined; const flow = this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { + async (oneKeyOperationLease) => { let customNetworks: IBatchCreateCustomNetworkParams[] = [ { networkId: payload.params.networkId, @@ -471,6 +473,7 @@ class ServiceBatchCreateAccount extends ServiceBase { saveToCache: payload.saveToCache, loopMode: true, isAutoCreateMultiNetwork: payload.params.isAutoCreateMultiNetwork, + oneKeyOperationLease, }); this.progressInfo = this.buildProgressInfo({ indexes, @@ -505,6 +508,7 @@ class ServiceBatchCreateAccount extends ServiceBase { hwAllNetworkPrepareAccountsResponse, hwRootFingerprintInfo, hdCredentialCacheScopeId, + oneKeyOperationLease, }); result.accountsForCreate = result.accountsForCreate.concat( resp.accountsForCreate, @@ -568,6 +572,7 @@ class ServiceBatchCreateAccount extends ServiceBase { showOnOneKey, saveToCache, isVerifyAddressAction, + oneKeyOperationLease, }: { walletId: string; networkId: string; @@ -576,6 +581,7 @@ class ServiceBatchCreateAccount extends ServiceBase { showOnOneKey?: boolean; saveToCache?: boolean; isVerifyAddressAction?: boolean; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; }) { const deviceParams = await this.backgroundApi.serviceAccount.getWalletDeviceParams({ @@ -592,7 +598,7 @@ class ServiceBatchCreateAccount extends ServiceBase { const result = await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { + async (activeOneKeyOperationLease) => { const networksParams = await this.buildBatchCreateAccountsNetworksParams({ walletId, @@ -614,6 +620,7 @@ class ServiceBatchCreateAccount extends ServiceBase { showOnOneKey, saveToCache, isVerifyAddressAction, + oneKeyOperationLease: activeOneKeyOperationLease, // skipDeviceCancel: true, }); @@ -628,10 +635,12 @@ class ServiceBatchCreateAccount extends ServiceBase { skipDeviceCancel: true, isVerifyAddressAction, hdCredentialCacheScopeId, + oneKeyOperationLease: activeOneKeyOperationLease, }); }, { deviceParams, + oneKeyOperationLease, skipDeviceCancel: true, onFinally: () => { hwAllNetworkPrepareAccountsResponse?.destroy(); @@ -986,6 +995,7 @@ class ServiceBatchCreateAccount extends ServiceBase { loopMode?: boolean; isAutoCreateMultiNetwork?: boolean; isVerifyAddressAction?: boolean; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; }): Promise { const hwAllNetworkPrepareAccountsResponse = new HardwareAllNetworkGetAddressResponse(); @@ -1108,6 +1118,7 @@ class ServiceBatchCreateAccount extends ServiceBase { let allNetworkGetAddressResponse: IHwAllNetworkPrepareAccountsItem[] = []; + let usingSdkLoopMode = false; try { const thirdPartyAllNetworkGetAddress = bindThirdPartyAllNetworkGetAddress(thirdPartyHw); @@ -1139,6 +1150,9 @@ class ServiceBatchCreateAccount extends ServiceBase { !thirdPartyAllNetworkAdapter || !thirdPartyAllNetworkGetAddress ) { + usingSdkLoopMode = Boolean( + params.loopMode && !platformEnv.isExtension, + ); const sdk = await this.backgroundApi.serviceHardware.getSDKInstance({ connectId: deviceParams.dbDevice?.connectId, @@ -1155,59 +1169,67 @@ class ServiceBatchCreateAccount extends ServiceBase { allNetworkGetAddressResponse = (await convertDeviceResponse( async () => { - const sdkPromiseResult = - params.loopMode && !platformEnv.isExtension - ? sdk.allNetworkGetAddressByLoop( - compatibleConnectId, - deviceParams.dbDevice?.deviceId || '', - { - ...deviceParams.deviceCommonParams, - bundle: bundleParams, - onLoopItemResponse: (data) => { - if (hideCheckingDeviceLoading) { - void this.backgroundApi.serviceHardwareUI.closeHardwareUiStateDialog( - { - connectId: compatibleConnectId, - }, - ); - } - if (data) { - hwAllNetworkPrepareAccountsResponse.onSdkItemCallResponse( - data as IHwAllNetworkPrepareAccountsItem, - ); - } - }, - onAllItemsResponse: (data, error) => { - if (data === undefined && error) { - const hwError = convertDeviceError( - { - code: error.payload?.code, - error: error.payload?.error, - }, - {}, - ); - hwAllNetworkPrepareAccountsResponse.rejectAllResponse( - hwError || - new OneKeyLocalError( - 'Device communication interrupted, please try again later (386147)', - ), - ); - } - appEventBus.emit( - EAppEventBusNames.SDKGetAllNetworkAddressesEnd, - undefined, + const sdkPromiseResult = usingSdkLoopMode + ? sdk.allNetworkGetAddressByLoop( + compatibleConnectId, + deviceParams.dbDevice?.deviceId || '', + { + ...deviceParams.deviceCommonParams, + bundle: bundleParams, + onLoopItemResponse: (data) => { + if (hideCheckingDeviceLoading) { + void this.backgroundApi.serviceHardwareUI.closeHardwareUiStateDialog( + { + connectId: compatibleConnectId, + }, + ); + } + if (data) { + hwAllNetworkPrepareAccountsResponse.onSdkItemCallResponse( + data as IHwAllNetworkPrepareAccountsItem, ); - }, + } }, - ) - : sdk.allNetworkGetAddress( - compatibleConnectId, - deviceParams.dbDevice?.deviceId || '', - { - ...deviceParams.deviceCommonParams, - bundle: bundleParams, + onAllItemsResponse: (data, error) => { + if (data === undefined && error) { + const hwError = convertDeviceError( + { + code: error.payload?.code, + error: error.payload?.error, + }, + {}, + ); + hwAllNetworkPrepareAccountsResponse.rejectAllResponse( + hwError || + new OneKeyLocalError( + 'Device communication interrupted, please try again later (386147)', + ), + ); + } else { + hwAllNetworkPrepareAccountsResponse.onSdkResponse( + { + items: + (data as IHwAllNetworkPrepareAccountsItem[]) || + [], + completed: true, + }, + ); + } + appEventBus.emit( + EAppEventBusNames.SDKGetAllNetworkAddressesEnd, + undefined, + ); }, - ); + }, + ) + : sdk.allNetworkGetAddress( + compatibleConnectId, + deviceParams.dbDevice?.deviceId || '', + { + ...deviceParams.deviceCommonParams, + bundle: bundleParams, + }, + ); const sdkAllNetworkGetAddressResponse = await sdkPromiseResult; @@ -1221,7 +1243,7 @@ class ServiceBatchCreateAccount extends ServiceBase { allNetworkGetAddressResponse, ); } catch (error) { - if (params.loopMode) { + if (usingSdkLoopMode) { appEventBus.emit( EAppEventBusNames.SDKGetAllNetworkAddressesEnd, undefined, @@ -1229,7 +1251,7 @@ class ServiceBatchCreateAccount extends ServiceBase { } throw error; } finally { - if (!params.loopMode) { + if (!usingSdkLoopMode) { appEventBus.emit( EAppEventBusNames.SDKGetAllNetworkAddressesEnd, undefined, @@ -1239,14 +1261,16 @@ class ServiceBatchCreateAccount extends ServiceBase { setTimeout(() => { const resolveSdkGetAllAddressResponse = () => { - for (const item of allNetworkGetAddressResponse) { - hwAllNetworkPrepareAccountsResponse.onSdkItemCallResponse( - item, - ); - } + hwAllNetworkPrepareAccountsResponse.onSdkResponse({ + items: allNetworkGetAddressResponse, + completed: false, + }); }; resolveSdkGetAllAddressResponse(); + if (!usingSdkLoopMode) { + hwAllNetworkPrepareAccountsResponse.completeSdkResponse(); + } if (process.env.NODE_ENV !== 'production') { // resolve by console call manually: @@ -1265,6 +1289,7 @@ class ServiceBatchCreateAccount extends ServiceBase { skipCloseHardwareUiStateDialog: skipCloseHardwareUiStateDialog ?? false, hideCheckingDeviceLoading, + oneKeyOperationLease: params.oneKeyOperationLease, }, ); } @@ -1304,7 +1329,7 @@ class ServiceBatchCreateAccount extends ServiceBase { }); return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { + async (oneKeyOperationLease) => { const networksParams: IBatchBuildAccountsNetworkParams[] = await this.buildBatchCreateAccountsNetworksParams({ walletId: params.walletId, @@ -1360,6 +1385,7 @@ class ServiceBatchCreateAccount extends ServiceBase { indexes, networksParams, isAutoCreateMultiNetwork: params.isAutoCreateMultiNetwork, + oneKeyOperationLease, }); await this.recordPrimeTransferImportBatchCreateTrace({ event: 'done', @@ -1400,6 +1426,7 @@ class ServiceBatchCreateAccount extends ServiceBase { hwAllNetworkPrepareAccountsResponse, indexedAccountNames: params.indexedAccountNames, hdCredentialCacheScopeId, + oneKeyOperationLease, // isAutoCreateMultiNetwork flows from ...params. }); addedAccounts.push({ @@ -1680,6 +1707,7 @@ class ServiceBatchCreateAccount extends ServiceBase { applyRestoreSyncPolicy, hdCredentialCacheScopeId, isAutoCreateMultiNetwork, + oneKeyOperationLease, }: IBatchBuildAccountsParams): Promise<{ accountsForCreate: IBatchCreateAccount[]; }> { @@ -1890,6 +1918,7 @@ class ServiceBatchCreateAccount extends ServiceBase { hwAllNetworkPrepareAccountsResponse, hdCredentialCacheScopeId, isAutoCreateMultiNetwork, + oneKeyOperationLease, }); await this.recordPrimeTransferImportBatchCreateTrace({ event: 'done', diff --git a/packages/kit-bg/src/services/ServiceBootstrap.ts b/packages/kit-bg/src/services/ServiceBootstrap.ts index c7350f5e5ebc..eca8ea52ed64 100644 --- a/packages/kit-bg/src/services/ServiceBootstrap.ts +++ b/packages/kit-bg/src/services/ServiceBootstrap.ts @@ -64,6 +64,18 @@ class ServiceBootstrap extends ServiceBase { 0, ); } + try { + await this.timed( + 'serviceHardware.migrateExistingDeviceConnectProtocols', + () => + this.backgroundApi.serviceHardware.migrateExistingDeviceConnectProtocols(), + ); + } catch (_error) { + defaultLogger.app.bootstrap.initCriticalStep( + 'hardwareConnectProtocolMigration (FAILED)', + 0, + ); + } try { await this.timed('initSystemLocale', () => this.backgroundApi.serviceSetting.initSystemLocale(), @@ -147,6 +159,9 @@ class ServiceBootstrap extends ServiceBase { timedDeferred('serviceToken.clearLastActiveTabNameData', () => this.backgroundApi.serviceToken.clearLastActiveTabNameData(), ), + timedDeferred('serviceHardwarePortfolioSync.init', async () => + this.backgroundApi.serviceHardwarePortfolioSync.init(), + ), ]); } catch (_error) { // individual errors already handled by timedDeferred diff --git a/packages/kit-bg/src/services/ServiceDevSetting.ts b/packages/kit-bg/src/services/ServiceDevSetting.ts index 71002a68200f..52e7e1a86805 100644 --- a/packages/kit-bg/src/services/ServiceDevSetting.ts +++ b/packages/kit-bg/src/services/ServiceDevSetting.ts @@ -220,6 +220,10 @@ class ServiceDevSetting extends ServiceBase { enabled: false, settings: {}, })); + await firmwareUpdateDevSettingsPersistAtom.set((prev) => ({ + ...prev, + usePreReleaseConfig: false, + })); await this.saveDevModeToSyncStorage(); await this.syncCryptoSettings(); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts index 98f2ac557806..2b2ffd2a0627 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts @@ -1,5 +1,6 @@ import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EHardwareVendor, @@ -10,9 +11,12 @@ import localDb from '../../dbs/local/localDb'; import { firmwareUpdateRetryAtom, firmwareUpdateWorkflowRunningAtom, + hardwareUiStateCompletedAtom, } from '../../states/jotai/atoms'; -import ServiceFirmwareUpdate from './ServiceFirmwareUpdate'; +import ServiceFirmwareUpdate, { + buildPro2TargetsToUpdate, +} from './ServiceFirmwareUpdate'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IDBDevice } from '../../dbs/local/types'; @@ -40,6 +44,10 @@ jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ }, })); +jest.mock('@onekeyhq/shared/src/hardware/instance', () => ({ + CoreSDKLoader: jest.fn(), +})); + jest.mock('../../dbs/local/localDb', () => ({ __esModule: true, default: { @@ -72,6 +80,9 @@ jest.mock('../../states/jotai/atoms', () => ({ hardwareUiStateAtom: { set: jest.fn(), }, + hardwareUiStateCompletedAtom: { + set: jest.fn(), + }, })); jest.mock('../ServiceHardware/serviceHardwareUtils', () => ({ @@ -82,7 +93,6 @@ jest.mock('../ServiceHardware/serviceHardwareUtils', () => ({ })); const mockedLocalDb = jest.mocked(localDb); - describe('ServiceFirmwareUpdate.detectActiveAccountFirmwareUpdates', () => { beforeEach(() => { jest.clearAllMocks(); @@ -122,6 +132,127 @@ describe('ServiceFirmwareUpdate.detectActiveAccountFirmwareUpdates', () => { ); }); +describe('buildPro2TargetsToUpdate', () => { + it('uses SDK targets when no developer override is configured', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['app_v1', 'resource'], + }), + ).toEqual(['app_v1', 'resource']); + }); + + it('does not infer a resource update from an app update', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['app_v1'], + }), + ).toEqual(['app_v1']); + }); + + it('deduplicates SDK update targets', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['se01', 'se01', 'resource'], + }), + ).toEqual(['se01', 'resource']); + }); + + it('keeps boot resources independent from stable resources', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['resource', 'boot_resources'], + }), + ).toEqual(['resource', 'boot_resources']); + }); + + it('merges and deduplicates developer force targets after SDK targets', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['app_v1', 'resource'], + forceTargets: ['resource', 'se01'], + }), + ).toEqual(['app_v1', 'resource', 'se01']); + }); +}); + +describe('ServiceFirmwareUpdate Pro2 developer settings', () => { + it('clears one-time Pro2 targets with the other one-time overrides', async () => { + const updateFirmwareUpdateDevSettings = jest.fn(); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceDevSetting: { updateFirmwareUpdateDevSettings }, + } as unknown as IBackgroundApi, + }); + + await service.clearOnceUpdateDevSettings(); + + expect(updateFirmwareUpdateDevSettings).toHaveBeenCalledWith({ + forceUpdateOnceFirmware: false, + forceUpdateOnceBle: false, + forceUpdateOnceBootloader: false, + pro2ForceUpdateOnceTargets: [], + }); + }); +}); + +describe('ServiceFirmwareUpdate Pro2 resource update options', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps an SDK-selected resource target on the incremental inventory path', async () => { + const firmwareUpdateV4 = jest.fn().mockResolvedValue({ + success: true, + payload: {}, + }); + const hardwareSDK = { + firmwareUpdateV4, + on: jest.fn(), + off: jest.fn(), + }; + jest.spyOn(timerUtils, 'wait').mockResolvedValue(undefined); + + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance: jest.fn().mockResolvedValue(hardwareSDK), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn(async (key: string) => { + if (key === 'forceUpdateResEvenSameVersion') { + return false; + } + return undefined; + }), + }, + } as unknown as IBackgroundApi, + }); + + await service.updatingFirmwareV3({ + connectId: 'PRO2_CONNECT_ID', + bleVersion: undefined, + firmwareVersion: undefined, + bootloaderVersion: undefined, + firmwareType: undefined, + isPro2Device: true, + pro2TargetsToUpdate: ['resource'], + }); + + expect(firmwareUpdateV4).toHaveBeenCalledTimes(1); + expect(firmwareUpdateV4.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + forcedUpdateRes: false, + targetsToUpdate: ['resource'], + }), + ); + }); +}); + describe('ServiceFirmwareUpdate workflow tracking', () => { beforeEach(() => { jest.clearAllMocks(); @@ -361,6 +492,7 @@ describe('ServiceFirmwareUpdate workflow tracking', () => { expect(await service.getUpdateWorkflowTrackingInfo()).toEqual( expect.objectContaining({ retryCount: 1 }), ); + expect(hardwareUiStateCompletedAtom.set).toHaveBeenCalledWith(undefined); }); it('does not wait for attempt analytics before exposing retry state', async () => { diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts index 1019e3ed15ea..157f2b6b0ef5 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts @@ -55,6 +55,7 @@ import type { IFirmwareUpdateV3VersionParams, IHardwareBridgeReleasePayload, IOneKeyDeviceFeatures, + IPro2FirmwareUpdateTarget, IResourceUpdateInfo, } from '@onekeyhq/shared/types/device'; import { @@ -71,6 +72,7 @@ import { firmwareUpdateStepInfoAtom, firmwareUpdateWorkflowRunningAtom, hardwareUiStateAtom, + hardwareUiStateCompletedAtom, } from '../../states/jotai/atoms'; import ServiceBase from '../ServiceBase'; import serviceHardwareUtils from '../ServiceHardware/serviceHardwareUtils'; @@ -92,12 +94,12 @@ import type { AllFirmwareRelease, CoreApi, Success as CoreSuccess, + DeviceSuccess, DeviceUploadResourceParams, IDeviceType, IVersionArray, } from '@onekeyfe/hd-core'; import type { EFirmwareType } from '@onekeyfe/hd-shared'; -import type { Features, Success } from '@onekeyfe/hd-transport'; export type IAutoUpdateFirmwareParams = { connectId: string | undefined; @@ -116,11 +118,31 @@ export type IStartUpdateWorkflowV2Result = { backgroundTaskStarted: true; }; +const PRO2_APP_FIRMWARE_UPDATE_TARGETS = new Set([ + 'app_v1', + 'app_v2', +]); + +export function buildPro2TargetsToUpdate({ + sdkTargets, + forceTargets = [], +}: { + sdkTargets: readonly IPro2FirmwareUpdateTarget[] | undefined; + forceTargets?: readonly IPro2FirmwareUpdateTarget[]; +}) { + return Array.from( + new Set([ + ...(sdkTargets ?? []), + ...forceTargets, + ]), + ); +} + export type IUpdateFirmwareTaskFn = ({ id, }: { id: number; -}) => Promise; // return Success | undefined go to next task, throw error to retry +}) => Promise; // return DeviceSuccess | undefined go to next task, throw error to retry type IUpdateFirmwareTask = { fn: IUpdateFirmwareTaskFn; @@ -157,6 +179,7 @@ class ServiceFirmwareUpdate extends ServiceBase { forceUpdateOnceFirmware: false, forceUpdateOnceBle: false, forceUpdateOnceBootloader: false, + pro2ForceUpdateOnceTargets: [], }); } @@ -171,7 +194,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } @backgroundMethod() - async rebootToBoardloader(connectId: string): Promise { + async rebootToBoardloader(connectId: string): Promise { const hardwareSDK = await this.getSDKInstance({ connectId, }); @@ -388,17 +411,27 @@ class ServiceFirmwareUpdate extends ServiceBase { firmwareType, skipCancel, baseReleaseInfoCache, + checkFirmwareHash, }: { connectId: string | undefined; firmwareType: EFirmwareType | undefined; skipCancel?: boolean; baseReleaseInfoCache?: AllFirmwareRelease; + checkFirmwareHash?: boolean; }): Promise { - const { getDeviceUUID } = await CoreSDKLoader(); + const hardwareSdk = await CoreSDKLoader(); + const getDeviceSerialNo = + ( + hardwareSdk as typeof hardwareSdk & { + getDeviceSerialNo?: typeof hardwareSdk.getDeviceUUID; + } + ).getDeviceSerialNo ?? hardwareSdk.getDeviceUUID; - const releaseInfoCache = this._checkCacheMeetExpectations({ - baseReleaseInfo: baseReleaseInfoCache, - }); + const releaseInfoCache = checkFirmwareHash + ? undefined + : this._checkCacheMeetExpectations({ + baseReleaseInfo: baseReleaseInfoCache, + }); const originalConnectId = connectId; // Skip cancel when using cached data since device state was already verified @@ -451,9 +484,12 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.checkDeviceIsBootloaderMode({ connectId: originalConnectId, allowEmptyConnectId: true, - featuresCache: releaseInfoCache?.features, + featuresCache: releaseInfoCache?.features as unknown as + | IOneKeyDeviceFeatures + | undefined, }); - let features: Features = initialFeatures as Features; + let features: IOneKeyDeviceFeatures = + initialFeatures as IOneKeyDeviceFeatures; // use originalConnectId getFeatures() make sure sdk throw DeviceNotFound if connected device not matched with originalConnectId if (isBootloaderMode || !features) { @@ -466,16 +502,23 @@ class ServiceFirmwareUpdate extends ServiceBase { }); } + const deviceType = await deviceUtils.getDeviceTypeFromFeatures({ + features, + }); + const releaseInfo = releaseInfoCache ?? (await this.baseCheckAllFirmwareRelease({ connectId: originalConnectId, firmwareType, skipChangeTransportType: true, + checkFirmwareHash, })); const currentFirmwareType = await deviceUtils.getFirmwareType({ - features: releaseInfo.features, + features: releaseInfo.features as unknown as + | IOneKeyDeviceFeatures + | undefined, }); const firmware = await this.checkFirmwareRelease({ @@ -557,11 +600,8 @@ class ServiceFirmwareUpdate extends ServiceBase { } } - // TODO boot mode device uuid is empty - const deviceUUID = getDeviceUUID(features); - const deviceType = await deviceUtils.getDeviceTypeFromFeatures({ - features, - }); + // TODO boot mode device serial number is empty + const deviceSerialNo = getDeviceSerialNo(features); const deviceName = await deviceUtils.buildDeviceName({ features }); const deviceBleName = deviceUtils.buildDeviceBleName({ features }); @@ -625,15 +665,34 @@ class ServiceFirmwareUpdate extends ServiceBase { // ignore } - return { + const pro2ForceTargets = + deviceType === EDeviceType.Pro2 + ? [ + ...((await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateTargets', + )) ?? []), + ...((await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateOnceTargets', + )) ?? []), + ] + : undefined; + const pro2TargetsToUpdate = + deviceType === EDeviceType.Pro2 + ? buildPro2TargetsToUpdate({ + sdkTargets: releaseInfo.targetsToUpdate, + forceTargets: pro2ForceTargets, + }) + : undefined; + + const result = { updatingConnectId: fixedUpdatingConnectId, originalConnectId, features, deviceType, deviceName, deviceBleName, - deviceUUID, - hasUpgrade, + deviceUUID: deviceSerialNo, + hasUpgrade: hasUpgrade || Boolean(pro2TargetsToUpdate?.length), isBootloaderMode: features ? (await deviceUtils.getDeviceModeFromFeatures({ features })) === EOneKeyDeviceMode.bootloader @@ -645,7 +704,22 @@ class ServiceFirmwareUpdate extends ServiceBase { bridge, }, totalPhase: totalPhase.filter(Boolean), + pro2TargetsToUpdate, }; + + // Firmware-check interactions such as PIN entry are complete at this point. + // Close only the UI without cancelling the device request used by the update. + if (originalConnectId) { + await this.backgroundApi.serviceHardwareUI.closeHardwareUiStateDialog({ + connectId: originalConnectId, + skipDeviceCancel: true, + deviceResetToHome: false, + skipDelayClose: true, + reason: 'checkAllFirmwareRelease completed', + }); + } + + return result; } @backgroundMethod() @@ -679,12 +753,14 @@ class ServiceFirmwareUpdate extends ServiceBase { skipChangeTransportType, retryCount, silentMode, + checkFirmwareHash, }: { connectId: string | undefined; firmwareType: EFirmwareType | undefined; skipChangeTransportType?: boolean; retryCount?: number; silentMode?: boolean; + checkFirmwareHash?: boolean; }) { const hardwareSDK = await this.getSDKInstance({ connectId, @@ -704,6 +780,7 @@ class ServiceFirmwareUpdate extends ServiceBase { // method fail if device on boot mode hardwareSDK.checkAllFirmwareRelease(currentConnectId, { checkBridgeRelease, + checkFirmwareHash, firmwareType, retryCount, }), @@ -920,8 +997,16 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( 'forceUpdateOnceBootloader', ); + const releaseFeatures = + 'features' in releasePayload ? releasePayload.features : undefined; + const isPro2Device = + releaseFeatures && + (await deviceUtils.getDeviceTypeFromFeatures({ + features: releaseFeatures, + })) === EDeviceType.Pro2; if ( firmwareType === 'firmware' && + !isPro2Device && (mockUpdateFirmware || mockUpdateOnceFirmware) ) { hasUpgrade = true; @@ -931,6 +1016,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } if ( firmwareType === 'bootloader' && + !isPro2Device && (mockUpdateBootloader || mockUpdateOnceBootloader) ) { hasUpgrade = true; @@ -1147,7 +1233,7 @@ class ServiceFirmwareUpdate extends ServiceBase { async updatingBootloader( params: IUpdateFirmwareWorkflowParams, updateInfo: IBootloaderUpdateInfo, - ): Promise { + ): Promise { const hardwareSDK = await this.getSDKInstance({ connectId: params.releaseResult.updatingConnectId, }); @@ -1266,7 +1352,7 @@ class ServiceFirmwareUpdate extends ServiceBase { { connectId, version, firmwareType, deviceType }: IAutoUpdateFirmwareParams, updateInfo: IBleFirmwareUpdateInfo | IFirmwareUpdateInfo, workflowParams: IUpdateFirmwareWorkflowParams, - ): Promise { + ): Promise { // const { dispatch } = this.backgroundApi; // dispatch(setUpdateFirmwareStep('')); @@ -1811,6 +1897,7 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId: '', payload: {} as any, }); + await hardwareUiStateCompletedAtom.set(undefined); await firmwareUpdateResultVerifyAtom.set(undefined); } @@ -1951,7 +2038,10 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.cancelUpdateWorkflowIfExit(); const deviceType = params?.releaseResult?.deviceType; - if (deviceType !== EDeviceType.Pro) { + if ( + deviceType !== EDeviceType.Pro && + deviceType !== EDeviceType.Pro2 + ) { throw new OneKeyLocalError( 'Do not support update firmware for this device', ); @@ -2218,6 +2308,7 @@ class ServiceFirmwareUpdate extends ServiceBase { // Re-block lock screen before resuming hardware communication await firmwareUpdateWorkflowRunningAtom.set(true); + await this.clearHardwareUiStateBeforeStartUpdateWorkflow(); await firmwareUpdateRetryAtom.set(undefined); await this.waitDeviceRestart({ @@ -2305,6 +2396,7 @@ class ServiceFirmwareUpdate extends ServiceBase { ): Promise { const { releaseResult } = params; const { updateInfos } = releaseResult; + const isPro2Device = releaseResult.deviceType === EDeviceType.Pro2; const updateParams: IFirmwareUpdateV3VersionParams = { connectId: releaseResult.updatingConnectId, @@ -2318,6 +2410,8 @@ class ServiceFirmwareUpdate extends ServiceBase { ? updateInfos.bootloader?.toVersion : undefined, firmwareType: updateInfos.firmware?.toFirmwareType, + isPro2Device, + pro2TargetsToUpdate: releaseResult.pro2TargetsToUpdate, }; return this.createRunTaskWithRetry({ fn: async () => this.updatingFirmwareV3(updateParams), @@ -2326,7 +2420,7 @@ class ServiceFirmwareUpdate extends ServiceBase { async updatingFirmwareV3( params: IFirmwareUpdateV3VersionParams, - ): Promise { + ): Promise { const hardwareSDK = await this.getSDKInstance({ connectId: params.connectId, }); @@ -2351,24 +2445,41 @@ class ServiceFirmwareUpdate extends ServiceBase { const toBleVersion = convertVersion(params.bleVersion); const toBootloaderVersion = convertVersion(params.bootloaderVersion); const versionMismatches: string[] = []; + const forceUpdateResEvenIfSameVersion = + await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'forceUpdateResEvenSameVersion', + ); + const forcedUpdateRes = forceUpdateResEvenIfSameVersion === true; + const shouldVerifyFirmwareVersion = + !params.isPro2Device || + !params.pro2TargetsToUpdate?.length || + params.pro2TargetsToUpdate.some((target) => + PRO2_APP_FIRMWARE_UPDATE_TARGETS.has(target), + ); try { const currentTransportType = await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId, + currentTransportType, + }); const updateResult = await convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV3( - deviceUtils.getUpdatingConnectId({ - connectId, - currentTransportType, - }), - { - platform: platformEnv.symbol ?? 'web', - bleVersion: toBleVersion, - firmwareVersion: toFirmwareVersion, - bootloaderVersion: toBootloaderVersion, - firmwareType: params.firmwareType, - }, - ), + params.isPro2Device + ? hardwareSDK.firmwareUpdateV4(updatingConnectId, { + platform: platformEnv.symbol ?? 'web', + forcedUpdateRes, + firmwareType: params.firmwareType, + targetsToUpdate: params.pro2TargetsToUpdate, + }) + : hardwareSDK.firmwareUpdateV3(updatingConnectId, { + platform: platformEnv.symbol ?? 'web', + bleVersion: toBleVersion, + firmwareVersion: toFirmwareVersion, + bootloaderVersion: toBootloaderVersion, + forcedUpdateRes, + firmwareType: params.firmwareType, + }), ); // verify final version @@ -2393,10 +2504,12 @@ class ServiceFirmwareUpdate extends ServiceBase { } }; - verifyVersion( - toFirmwareVersion?.join('.'), - updateResult?.firmwareVersion, - ); + if (shouldVerifyFirmwareVersion) { + verifyVersion( + toFirmwareVersion?.join('.'), + updateResult?.firmwareVersion, + ); + } verifyVersion(toBleVersion?.join('.'), updateResult?.bleVersion); verifyVersion( toBootloaderVersion?.join('.'), @@ -2532,7 +2645,12 @@ class ServiceFirmwareUpdate extends ServiceBase { const { features: deviceFeatures } = params.releaseResult; - let batteryLevel: number | undefined = deviceFeatures?.battery_level; + const legacyDeviceFeatures = deviceFeatures as + | (IOneKeyDeviceFeatures & { + battery_level?: number; + }) + | undefined; + let batteryLevel: number | undefined = legacyDeviceFeatures?.battery_level; const mockLowBattery = await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( diff --git a/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.pro2.test.ts b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.pro2.test.ts new file mode 100644 index 000000000000..73b1a615647f --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.pro2.test.ts @@ -0,0 +1,536 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import localDb from '../../dbs/local/localDb'; + +import { DeviceSettingsManager } from './DeviceSettingsManager'; + +import type { IBackgroundApi } from '../../apis/IBackgroundApi'; +import type { IDBDevice } from '../../dbs/local/types'; +import type { CoreApi } from '@onekeyfe/hd-core'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ + __esModule: true, + default: { + isMonochromeScreen: jest.fn(() => false), + }, +})); + +jest.mock('@onekeyhq/shared/src/utils/deviceUtils', () => ({ + __esModule: true, + default: {}, +})); + +jest.mock('jpeg-js', () => ({ + decode: jest.fn(() => ({ + width: 604, + height: 1024, + data: new Uint8Array(604 * 1024 * 4), + })), +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getWalletDevice: jest.fn(), + getDeviceByQuery: jest.fn(), + getDevice: jest.fn(), + updateDevice: jest.fn(), + }, +})); + +function buildDevice(deviceType: EDeviceType): IDBDevice { + return { + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + deviceId: 'PRO2_DEVICE_ID', + deviceType, + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; +} + +function buildTrezorDevice(): IDBDevice { + return { + ...buildDevice(EDeviceType.Unknown), + connectId: 'TREZOR_CONNECT_ID', + deviceId: 'TREZOR_DEVICE_ID', + vendor: EHardwareVendor.trezor, + featuresInfo: { + device_id: 'TREZOR_DEVICE_ID', + passphrase_protection: false, + auto_lock_delay_ms: 60_000, + haptic_feedback: false, + }, + } as IDBDevice; +} + +function buildManager(device: IDBDevice, sdk: CoreApi) { + jest.spyOn(localDb, 'getDeviceByQuery').mockResolvedValue(device); + jest.spyOn(localDb, 'getWalletDevice').mockResolvedValue(device); + const manager = new DeviceSettingsManager({ + backgroundApi: {} as IBackgroundApi, + }); + jest + .spyOn(manager, '_withDeviceProcessing') + .mockImplementation(async ({ action }) => { + const response = await action(sdk, device.connectId, device); + if (!response.success) throw new OneKeyLocalError('SDK call failed'); + return response.payload; + }); + return manager; +} + +describe('DeviceSettingsManager device adapters', () => { + test.each([ + ['setLanguage', { language: 'ja-Jpan-JP' }, { language: 'ja-Jpan-JP' }], + [ + 'setAutoLockDelayMs', + { autoLockDelayMs: 60_000 }, + { autoLockDelayMs: 60_000 }, + ], + [ + 'setAutoShutDownDelayMs', + { autoShutdownDelayMs: 300_000 }, + { autoShutdownDelayMs: 300_000 }, + ], + ['setHapticFeedback', { hapticFeedback: true }, { hapticFeedback: true }], + ['setBrightness', { brightness: 60 }, { brightness: 60 }], + ] as const)( + 'routes %s through the protocol-neutral deviceSettings API', + async (methodName, params, settings) => { + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + deviceSettings, + } as unknown as CoreApi); + + const method = manager[methodName] as ( + input: typeof params & { connectId: string }, + ) => Promise; + await method.call(manager, { + connectId: 'PRO2_CONNECT_ID', + ...params, + }); + + expect(deviceSettings).toHaveBeenCalledWith('PRO2_CONNECT_ID', settings); + }, + ); + + test('routes label updates through deviceSettings as well', async () => { + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + deviceSettings, + } as unknown as CoreApi); + + await manager.setDeviceLabel({ + walletId: 'wallet-1', + label: 'Renamed Pro 2', + }); + + expect(deviceSettings).toHaveBeenCalledWith('PRO2_CONNECT_ID', { + label: 'Renamed Pro 2', + }); + }); + + test.each(['OneKey-Pro2', 'OneKey_Pro2', 'OneKey Pro2', '一键Pro2'])( + 'rejects unsupported label %s before calling the SDK', + async (label) => { + const deviceSettings = jest.fn(); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + deviceSettings, + } as unknown as CoreApi); + + await expect( + manager.setDeviceLabel({ walletId: 'wallet-1', label }), + ).rejects.toThrow('only support ASCII letters, numbers, and spaces'); + expect(deviceSettings).not.toHaveBeenCalled(); + }, + ); + + test.each([ + ['Custom Label', 'Custom Label'], + [null, ''], + ])( + 'reads the editable label from DeviceState without display-name fallback', + async (label, expected) => { + const device = buildDevice(EDeviceType.Pro2); + jest.spyOn(localDb, 'getWalletDevice').mockResolvedValue(device); + const getDeviceStateWithUnlock = jest.fn(async () => ({ + identity: { + label, + bleName: 'Pro2 6136', + displayName: 'Pro2 6136', + }, + })); + const backgroundApi = { + serviceHardware: { + getCompatibleConnectId: jest.fn(async () => device.connectId), + getDeviceStateWithUnlock, + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: (lease: object) => Promise) => + action({ deviceKey: 'device-db-id', owner: Symbol('test') }), + ), + closeHardwareUiStateDialog: jest.fn(async () => undefined), + }, + } as unknown as IBackgroundApi; + const manager = new DeviceSettingsManager({ backgroundApi }); + + await expect( + manager.getDeviceLabel({ walletId: 'wallet-1' }), + ).resolves.toBe(expected); + expect(getDeviceStateWithUnlock).toHaveBeenCalledWith({ + connectId: device.connectId, + pinType: DeviceSessionPinType.Main, + params: { scope: 'settings' }, + oneKeyOperationLease: expect.objectContaining({ + deviceKey: 'device-db-id', + }), + }); + }, + ); + + test('relies on the SDK state event instead of manually patching the database', async () => { + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const device = buildDevice(EDeviceType.Pro2); + device.featuresInfo = { + deviceId: 'PRO2_DEVICE_ID', + autoLockDelayMs: 60_000, + } as never; + const manager = buildManager(device, { + deviceSettings, + } as unknown as CoreApi); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.updateDevice).mockClear(); + + await manager.setAutoShutDownDelayMs({ + connectId: 'PRO2_CONNECT_ID', + autoShutdownDelayMs: 300_000, + }); + + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDevice).not.toHaveBeenCalled(); + }); + + test.each([ + ['changePin', { remove: false }, 'deviceChangePin'], + ['wipeDevice', {}, 'deviceWipe'], + ] as const)( + 'routes %s through the unified public method', + async (methodName, params, sdkMethodName) => { + const sdkMethod = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + [sdkMethodName]: sdkMethod, + } as unknown as CoreApi); + + const method = manager[methodName] as ( + input: typeof params & { connectId: string }, + ) => Promise; + await method.call(manager, { + connectId: 'PRO2_CONNECT_ID', + ...params, + }); + + if (methodName === 'changePin') { + expect(sdkMethod).toHaveBeenCalledWith('PRO2_CONNECT_ID', { + remove: false, + }); + } else { + expect(sdkMethod).toHaveBeenCalledWith('PRO2_CONNECT_ID'); + } + }, + ); + + test('routes Pro2 passphrase settings through the unified public method', async () => { + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + deviceSettings, + } as unknown as CoreApi); + + await manager.setPassphraseEnabled({ + connectId: 'PRO2_CONNECT_ID', + passphraseEnabled: true, + }); + + expect(deviceSettings).toHaveBeenCalledWith('PRO2_CONNECT_ID', { + usePassphrase: true, + }); + }); + + test('waits for the Pro2 DeviceState event to finish syncing before returning', async () => { + const device = buildDevice(EDeviceType.Pro2); + jest.spyOn(localDb, 'getDeviceByQuery').mockResolvedValue(device); + let releaseStateSync: (() => void) | undefined; + let notifyStateSyncStarted: (() => void) | undefined; + const stateSyncStarted = new Promise((resolve) => { + notifyStateSyncStarted = resolve; + }); + const waitForDeviceStateSync = jest.fn( + () => + new Promise((resolve) => { + releaseStateSync = resolve; + notifyStateSyncStarted?.(); + }), + ); + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = new DeviceSettingsManager({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn(async () => device.connectId), + waitForDeviceStateSync, + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: () => Promise) => action(), + ), + }, + } as unknown as IBackgroundApi, + }); + jest.spyOn(manager, 'getSDKInstance').mockResolvedValue({ + deviceSettings, + } as unknown as CoreApi); + + let completed = false; + const settingTask = manager + .setPassphraseEnabled({ + connectId: device.connectId, + passphraseEnabled: true, + }) + .then(() => { + completed = true; + }); + + await stateSyncStarted; + expect(completed).toBe(false); + expect(waitForDeviceStateSync).toHaveBeenCalledWith({ + connectIds: expect.arrayContaining(['PRO2_CONNECT_ID', 'PRO2_DEVICE_ID']), + }); + releaseStateSync?.(); + await settingTask; + expect(completed).toBe(true); + }); + + test.each([ + [ + 'setPassphraseEnabled', + { passphraseEnabled: true }, + { passphrase_protection: true }, + ], + [ + 'setAutoLockDelayMs', + { autoLockDelayMs: 120_000 }, + { auto_lock_delay_ms: 120_000 }, + ], + [ + 'setAutoShutDownDelayMs', + { autoShutdownDelayMs: 300_000 }, + { auto_shutdown_delay_ms: 300_000 }, + ], + ['setLanguage', { language: 'en-US' }, { language: 'en-US' }], + ] as const)( + 'persists legacy OneKey %s settings without waiting for Pro2 state events', + async (methodName, params, preciseUpdateFields) => { + const device = buildDevice(EDeviceType.Pro); + device.featuresInfo = { + device_id: 'LEGACY_DEVICE_ID', + } as never; + const waitForDeviceStateSync = jest.fn(async () => undefined); + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValue(device); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.updateDevice).mockClear(); + const manager = new DeviceSettingsManager({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn(async () => device.connectId), + waitForDeviceStateSync, + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: () => Promise) => action(), + ), + }, + } as unknown as IBackgroundApi, + }); + jest.spyOn(manager, 'getSDKInstance').mockResolvedValue({ + deviceSettings, + } as unknown as CoreApi); + + const method = manager[methodName] as ( + input: typeof params & { connectId: string }, + ) => Promise; + await method.call(manager, { + connectId: device.connectId, + ...params, + }); + + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDevice).toHaveBeenCalledWith({ + features: device.featuresInfo, + preciseUpdateFields, + }); + expect(waitForDeviceStateSync).not.toHaveBeenCalled(); + }, + ); + + test('opens the Protocol V1 brightness page without a V2 brightness value', async () => { + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success' }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro), { + deviceSettings, + } as unknown as CoreApi); + + await manager.setBrightness({ + connectId: 'PRO2_CONNECT_ID', + }); + + expect(deviceSettings).toHaveBeenCalledWith('PRO2_CONNECT_ID', { + changeBrightness: true, + }); + }); + + test('decodes the compressed Pro2 wallpaper in background before upload', async () => { + const device = buildDevice(EDeviceType.Pro2); + jest.spyOn(localDb, 'getDevice').mockResolvedValue(device); + const deviceUploadWallpaper = jest.fn(async () => ({ + success: true as const, + payload: { message: 'Success', path: 'vol1:/wallpapers/custom.bin' }, + })); + const manager = new DeviceSettingsManager({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn(async () => device.connectId), + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: () => Promise) => action(), + ), + }, + } as unknown as IBackgroundApi, + }); + jest.spyOn(manager, 'getSDKInstance').mockResolvedValue({ + deviceUploadWallpaper, + } as unknown as CoreApi); + + const result = await manager.setDeviceHomeScreen({ + dbDeviceId: device.id, + screenItem: { + id: 'custom wallpaper', + resType: 'custom', + screenHex: 'ffd8ff', + isUserUpload: true, + }, + }); + + expect(deviceUploadWallpaper).toHaveBeenCalledWith(device.connectId, { + width: 604, + height: 1024, + rgba: expect.any(Uint8Array), + fileName: 'custom-wallpaper', + }); + expect(result).toMatchObject({ message: 'Success', applyScreen: true }); + }); + + test.each([ + [ + 'setPassphraseEnabled', + { passphraseEnabled: true }, + { use_passphrase: true }, + { passphrase_protection: true }, + ], + [ + 'setAutoLockDelayMs', + { autoLockDelayMs: 120_000 }, + { auto_lock_delay_ms: 120_000 }, + { auto_lock_delay_ms: 120_000 }, + ], + [ + 'setHapticFeedback', + { hapticFeedback: true }, + { haptic_feedback: true }, + { haptic_feedback: true }, + ], + ] as const)( + 'persists Trezor %s using canonical feature fields', + async (methodName, params, settings, preciseUpdateFields) => { + const device = buildTrezorDevice(); + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: {}, + })); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValue(device); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.updateDevice).mockClear(); + const manager = new DeviceSettingsManager({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn(async () => device.connectId), + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: () => Promise) => action(), + ), + }, + serviceThirdPartyHardware: { + getAdapterForVendor: jest.fn(async () => ({ deviceSettings })), + requestTrezorBleConnectIdForDevice: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + + const method = manager[methodName] as ( + input: typeof params & { connectId: string }, + ) => Promise; + await method.call(manager, { + connectId: device.connectId, + ...params, + }); + + expect(deviceSettings).toHaveBeenCalledWith(device.connectId, settings); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDevice).toHaveBeenCalledWith({ + features: device.featuresInfo, + preciseUpdateFields, + }); + }, + ); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts index 821476cde8be..dcc9c2b0b2ae 100644 --- a/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts +++ b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts @@ -1,4 +1,12 @@ -import { ResourceType, type Success } from '@onekeyfe/hd-transport'; +import { + type CoreApi, + type DeviceSettingsParams, + type DeviceSuccess, + type DeviceUploadResourceParams, + type DeviceUploadResourceResponse, +} from '@onekeyfe/hd-core'; +import { EDeviceType } from '@onekeyfe/hd-shared'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; import { isNil } from 'lodash'; import { backgroundMethod } from '@onekeyhq/shared/src/background/backgroundDecorators'; @@ -9,7 +17,8 @@ import { import { convertDeviceResponse } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import { convertThirdPartyDeviceError } from '@onekeyhq/shared/src/errors/utils/thirdPartyDeviceErrorUtils'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; -import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isAsciiAlphanumericWithSpaces } from '@onekeyhq/shared/src/utils/stringUtils'; +import thirdPartyDeviceUtils from '@onekeyhq/shared/src/utils/thirdPartyDeviceUtils'; import { EHardwareCallContext, EHardwareVendor, @@ -24,6 +33,7 @@ import { getTrezorAdapterFromBackgroundApi, } from '../../vaults/base/trezorTransportUtils'; +import { getWallpaperResourceType } from './getWallpaperResourceType'; import { ServiceHardwareManagerBase } from './ServiceHardwareManagerBase'; import type { TrezorDeviceSettingsParams } from './adapters/types'; @@ -32,12 +42,6 @@ import type { IDBDeviceSettings as IDBDeviceDbSettings, } from '../../dbs/local/types'; import type { IWithHardwareProcessingControlParams } from '../ServiceHardwareUI/ServiceHardwareUI'; -import type { - CoreApi, - DeviceSettingsParams, - DeviceUploadResourceParams, - DeviceUploadResourceResponse, -} from '@onekeyfe/hd-core'; import type { Response as ThirdPartyResponse } from '@onekeyfe/hwk-adapter-core'; export type ISetInputPinOnSoftwareParams = { @@ -67,6 +71,10 @@ export type ISetHapticFeedbackParams = IBaseDeviceProcessingParams & { hapticFeedback: boolean; }; +export type ISetBrightnessParams = IBaseDeviceProcessingParams & { + brightness?: number; +}; + export type ISetPassphraseEnabledParams = IBaseDeviceProcessingParams & { passphraseEnabled: boolean; }; @@ -122,6 +130,7 @@ type IWithDeviceProcessingParams = { hardwareCallContext?: EHardwareCallContext; dbDevice?: IDBDevice; params?: IWithHardwareProcessingControlParams; + preciseUpdateFields?: Partial; }; type ITrezorDeviceSettingsAction = (params: { @@ -162,6 +171,10 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { ); } + private _isPro2Device(device: IDBDevice | undefined): boolean { + return device?.deviceType === EDeviceType.Pro2; + } + private async _withTrezorDeviceProcessing({ walletId, connectId, @@ -182,7 +195,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { > & { action: ITrezorDeviceSettingsAction; preciseUpdateFields?: Partial; - }): Promise { + }): Promise { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -240,7 +253,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { > & { settings: TrezorDeviceSettingsParams; preciseUpdateFields?: Partial; - }): Promise { + }): Promise { return this._withTrezorDeviceProcessing({ walletId, connectId, @@ -269,6 +282,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { debugMethodName, action, params, + preciseUpdateFields, }: IWithDeviceProcessingParams & { action: ( hardwareSDK: CoreApi, @@ -276,21 +290,12 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { device: IDBDevice, ) => Promise>; }): Promise { - let device = dbDevice; - if (!device && walletId) { - device = await localDb.getWalletDevice({ walletId }); - } - if (!device) { - if (connectId || featuresDeviceId) { - device = await localDb.getDeviceByQuery({ - connectId, - featuresDeviceId, - }); - } - } - if (!device) { - throw new OneKeyLocalError('Device not found'); - } + const device = await this._getDeviceForSettings({ + walletId, + connectId, + featuresDeviceId, + dbDevice, + }); return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( async () => { @@ -303,9 +308,29 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const hardwareSDK = await this.getSDKInstance({ connectId: compatibleConnectId, }); - return convertDeviceResponse(() => + const result = await convertDeviceResponse(() => action(hardwareSDK, compatibleConnectId, device), ); + if (this._isPro2Device(device)) { + await this.serviceHardware.waitForDeviceStateSync({ + connectIds: [ + compatibleConnectId, + device.connectId, + device.usbConnectId, + device.bleConnectId, + device.uuid, + device.deviceId, + device.deviceStateInfo?.identity.serialNo, + device.deviceStateInfo?.identity.deviceId, + ], + }); + } else if (preciseUpdateFields && device.featuresInfo) { + await localDb.updateDevice({ + features: device.featuresInfo, + preciseUpdateFields, + }); + } + return result; }, { deviceParams: { @@ -324,7 +349,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { connectId, featuresDeviceId, remove, - }: IChangePinParams): Promise { + }: IChangePinParams): Promise { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -391,10 +416,11 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const dbDevice = await localDb.getWalletDevice({ walletId }); if (this._isTrezorDevice(dbDevice)) { + const thirdPartyState = thirdPartyDeviceUtils.getDeviceState({ + features: dbDevice.featuresInfo as Record, + }); return { - passphraseEnabled: Boolean( - dbDevice.featuresInfo?.passphrase_protection, - ), + passphraseEnabled: Boolean(thirdPartyState.passphraseProtection), inputPinOnSoftware: false, inputPinOnSoftwareSupport: false, }; @@ -402,13 +428,15 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( async () => { - // touch or Pro should unlock device first, otherwise features?.passphrase_protection will return undefined + // touch or Pro should unlock device first, otherwise features?.passphraseProtection will return undefined await this.serviceHardware.unlockDevice({ connectId: dbDevice.connectId, + pinType: DeviceSessionPinType.Main, }); - const features = await this.serviceHardware.getFeaturesByWallet({ + const state = await this.serviceHardware.getDeviceStateByWallet({ walletId, + params: { scope: 'settings' }, }); const supportFeatures = await this.serviceHardware.getDeviceSupportFeatures( @@ -417,7 +445,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const inputPinOnSoftwareSupport = Boolean( supportFeatures?.inputPinOnSoftware?.support, ); - const passphraseEnabled = Boolean(features?.passphrase_protection); + const passphraseEnabled = Boolean(state.status.passphraseProtection); const inputPinOnSoftware = Boolean( dbDevice?.settings?.inputPinOnSoftware, ); @@ -444,26 +472,25 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { return device.featuresInfo?.label || device.name || 'Unknown'; } return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { + async (oneKeyOperationLease) => { const compatibleConnectId = await this.serviceHardware.getCompatibleConnectId({ connectId: device.connectId, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); - const features = - await this.backgroundApi.serviceHardware.getFeaturesWithoutCache({ + const state = + await this.backgroundApi.serviceHardware.getDeviceStateWithUnlock({ connectId: compatibleConnectId, - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + pinType: DeviceSessionPinType.Main, + params: { scope: 'settings' }, + oneKeyOperationLease, }); await this.backgroundApi.serviceHardwareUI.closeHardwareUiStateDialog({ connectId: compatibleConnectId, skipDeviceCancel: true, deviceResetToHome: false, }); - const label = await deviceUtils.buildDeviceLabel({ - features, - }); - return label || 'Unknown'; + return state.identity.label || ''; }, { deviceParams: { @@ -477,6 +504,11 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { @backgroundMethod() async setDeviceLabel({ walletId, label }: ISetDeviceLabelParams) { const device = await localDb.getWalletDevice({ walletId }); + if (this._isPro2Device(device) && !isAsciiAlphanumericWithSpaces(label)) { + throw new OneKeyLocalError( + 'OneKey Pro 2 device labels only support ASCII letters, numbers, and spaces', + ); + } if (this._isTrezorDevice(device)) { return this._applyTrezorSettings({ walletId, @@ -486,18 +518,14 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { preciseUpdateFields: { label }, }); } - return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - () => - this.applySettingsToDevice(device.connectId, { - label, - }), - { - deviceParams: { - dbDevice: device, - }, - debugMethodName: 'deviceSettings.applySettingsToDevice', - }, - ); + return this._withDeviceProcessing({ + walletId, + dbDevice: device, + debugMethodName: 'deviceSettings.setDeviceLabel', + preciseUpdateFields: { label }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { label }), + }); } @backgroundMethod() @@ -531,6 +559,42 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { async () => { // pro touch custom upload wallpaper if (needUploadResource) { + if (this._isPro2Device(device)) { + if (!finallyScreenHex) { + throw new OneKeyLocalError( + 'Upload Pro2 wallpaper error: screenHex not defined', + ); + } + const { decodeJpegToRgba } = await import('./jpegRgbaUtils'); + const decoded = decodeJpegToRgba({ + imageHex: finallyScreenHex, + expectedWidth: 604, + expectedHeight: 1024, + label: 'Pro2 wallpaper', + }); + const compatibleConnectId = + await this.serviceHardware.getCompatibleConnectId({ + connectId: device.connectId, + featuresDeviceId: device.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + }); + const response = await convertDeviceResponse(() => + hardwareSDK.deviceUploadWallpaper(compatibleConnectId, { + width: decoded.width, + height: decoded.height, + rgba: decoded.data, + fileName: screenItem.id.replace(/[^A-Za-z0-9_-]/g, '-'), + }), + ); + return { + ...response, + message: response.message ?? 'Success', + applyScreen: true, + }; + } if (!finallyThumbnailHex) { throw new OneKeyLocalError( 'Upload screen item error: thumbnailHex not defined', @@ -547,7 +611,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { connectId: compatibleConnectId, }); const uploadResParams: DeviceUploadResourceParams = { - resType: ResourceType.WallPaper, + resType: getWallpaperResourceType(), suffix: 'jpeg', dataHex: finallyScreenHex, thumbnailDataHex: finallyThumbnailHex, @@ -616,22 +680,13 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.setPassphraseEnabled', - action: async (sdk, compatibleConnectId, targetDevice) => - sdk - .deviceSettings(compatibleConnectId, { - usePassphrase: passphraseEnabled, - }) - .then(async (res) => { - if (res.success && targetDevice.featuresInfo) { - await localDb.updateDevice({ - features: targetDevice.featuresInfo, - preciseUpdateFields: { - passphrase_protection: passphraseEnabled, - }, - }); - } - return res; - }), + preciseUpdateFields: { + passphrase_protection: passphraseEnabled, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + usePassphrase: passphraseEnabled, + }), }); } @@ -666,22 +721,13 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.setAutoLockDelayMs', - action: async (sdk, compatibleConnectId, targetDevice) => - sdk - .deviceSettings(compatibleConnectId, { - autoLockDelayMs, - }) - .then(async (res) => { - if (res.success && targetDevice.featuresInfo) { - await localDb.updateDevice({ - features: targetDevice.featuresInfo, - preciseUpdateFields: { - auto_lock_delay_ms: autoLockDelayMs, - }, - }); - } - return res; - }), + preciseUpdateFields: { + auto_lock_delay_ms: autoLockDelayMs, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + autoLockDelayMs, + }), }); } @@ -706,22 +752,13 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.setAutoShutDownDelayMs', - action: async (sdk, compatibleConnectId, targetDevice) => - sdk - .deviceSettings(compatibleConnectId, { - autoShutdownDelayMs, - }) - .then(async (res) => { - if (res.success && targetDevice.featuresInfo) { - await localDb.updateDevice({ - features: targetDevice.featuresInfo, - preciseUpdateFields: { - auto_shutdown_delay_ms: autoShutdownDelayMs, - }, - }); - } - return res; - }), + preciseUpdateFields: { + auto_shutdown_delay_ms: autoShutdownDelayMs, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + autoShutdownDelayMs, + }), }); } @@ -756,22 +793,13 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.setLanguage', - action: async (sdk, compatibleConnectId, targetDevice) => - sdk - .deviceSettings(compatibleConnectId, { - language, - }) - .then(async (res) => { - if (res.success && targetDevice.featuresInfo) { - await localDb.updateDevice({ - features: targetDevice.featuresInfo, - preciseUpdateFields: { - language, - }, - }); - } - return res; - }), + preciseUpdateFields: { + language, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + language, + }), }); } @@ -780,7 +808,8 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { walletId, connectId, featuresDeviceId, - }: IBaseDeviceProcessingParams) { + brightness, + }: ISetBrightnessParams) { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -813,9 +842,12 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { dbDevice: device, debugMethodName: 'deviceSettings.setBrightness', action: async (sdk, compatibleConnectId, _device) => - sdk.deviceSettings(compatibleConnectId, { - changeBrightness: true, - }), + sdk.deviceSettings( + compatibleConnectId, + typeof brightness === 'number' + ? { brightness } + : { changeBrightness: true }, + ), }); } @@ -850,22 +882,13 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.setHapticFeedback', - action: async (sdk, compatibleConnectId, targetDevice) => - sdk - .deviceSettings(compatibleConnectId, { - hapticFeedback, - }) - .then(async (res) => { - if (res.success && targetDevice.featuresInfo) { - await localDb.updateDevice({ - features: targetDevice.featuresInfo, - preciseUpdateFields: { - haptic_feedback: hapticFeedback, - }, - }); - } - return res; - }), + preciseUpdateFields: { + haptic_feedback: hapticFeedback, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + hapticFeedback, + }), }); } diff --git a/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.test.ts b/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.test.ts new file mode 100644 index 000000000000..d32074b60e66 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.test.ts @@ -0,0 +1,67 @@ +import { HardwareAllNetworkGetAddressResponse } from './HardwareAllNetworkGetAddressResponse'; + +import type { IHwAllNetworkPrepareAccountsItem } from '../../vaults/types'; + +describe('HardwareAllNetworkGetAddressResponse', () => { + const request = { + path: "m/44'/60'/0'/0/0", + hwSdkNetwork: 'evm' as const, + }; + + test('rejects a pending item that is absent from the completed SDK response', async () => { + const response = new HardwareAllNetworkGetAddressResponse(); + const pendingItem = response.getItem(request); + + response.completeSdkResponse(); + + await expect(pendingItem).rejects.toThrow( + 'SDK all-network response is missing requested address', + ); + }); + + test('rejects an absent item requested after the SDK response completed', async () => { + const response = new HardwareAllNetworkGetAddressResponse(); + + response.completeSdkResponse(); + + await expect(response.getItem(request)).rejects.toThrow( + 'SDK all-network response is missing requested address', + ); + }); + + test('keeps a received item available after the SDK response completed', async () => { + const response = new HardwareAllNetworkGetAddressResponse(); + const item: IHwAllNetworkPrepareAccountsItem = { + path: request.path, + network: request.hwSdkNetwork, + success: true as const, + }; + + response.onSdkItemCallResponse(item); + response.completeSdkResponse(); + + await expect(response.getItem(request)).resolves.toBe(item); + }); + + test('keeps loop items pending until the callback response completes', async () => { + const response = new HardwareAllNetworkGetAddressResponse(); + let settled = false; + const pendingItem = response.getItem(request).finally(() => { + settled = true; + }); + + response.onSdkResponse({ items: [], completed: false }); + await Promise.resolve(); + + expect(settled).toBe(false); + + const item: IHwAllNetworkPrepareAccountsItem = { + path: request.path, + network: request.hwSdkNetwork, + success: true as const, + }; + response.onSdkResponse({ items: [item], completed: true }); + + await expect(pendingItem).resolves.toBe(item); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.ts b/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.ts index 097d05b86a06..720474f4abb3 100644 --- a/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.ts +++ b/packages/kit-bg/src/services/ServiceHardware/HardwareAllNetworkGetAddressResponse.ts @@ -1,3 +1,4 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import type { IOneKeyError } from '@onekeyhq/shared/src/errors/types/errorTypes'; import { convertDeviceError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import type { PromiseTarget } from '@onekeyhq/shared/src/utils/promiseUtils'; @@ -12,7 +13,23 @@ import type { export class HardwareAllNetworkGetAddressResponse { uuid = stringUtils.generateUUID(); + private sdkResponseCompleted = false; + + private respondedKeys = new Set(); + + private buildMissingResponseError() { + return new OneKeyLocalError( + 'SDK all-network response is missing requested address', + ); + } + onSdkItemCallResponse(item: IHwAllNetworkPrepareAccountsItem) { + const key = this.buildItemPromiseTargetKey({ + path: item.path, + hwSdkNetwork: item.network, + useTweak: item.useTweak, + }); + this.respondedKeys.add(key); const promiseTarget = this.getOrCreateItemPromiseTarget({ path: item.path, hwSdkNetwork: item.network, @@ -40,6 +57,30 @@ export class HardwareAllNetworkGetAddressResponse { } } + onSdkResponse({ + items, + completed, + }: { + items: IHwAllNetworkPrepareAccountsItem[]; + completed: boolean; + }) { + for (const item of items) { + this.onSdkItemCallResponse(item); + } + if (completed) { + this.completeSdkResponse(); + } + } + + completeSdkResponse() { + this.sdkResponseCompleted = true; + Object.entries(this.promiseTargets).forEach(([key, target]) => { + if (!this.respondedKeys.has(key)) { + target.rejectTarget(this.buildMissingResponseError()); + } + }); + } + _rejectAllResponseError: IOneKeyError | undefined = undefined; rejectAllResponse(error: IOneKeyError) { @@ -56,6 +97,8 @@ export class HardwareAllNetworkGetAddressResponse { this.promiseTargets = {}; this.bundleLength = 0; this._rejectAllResponseError = undefined; + this.sdkResponseCompleted = false; + this.respondedKeys.clear(); } promiseTargets: Record< @@ -93,6 +136,8 @@ export class HardwareAllNetworkGetAddressResponse { if (this._rejectAllResponseError) { promiseTarget.rejectTarget(this._rejectAllResponseError); + } else if (this.sdkResponseCompleted && !this.respondedKeys.has(key)) { + promiseTarget.rejectTarget(this.buildMissingResponseError()); } return promiseTarget; diff --git a/packages/kit-bg/src/services/ServiceHardware/HardwareConnectionManager.ts b/packages/kit-bg/src/services/ServiceHardware/HardwareConnectionManager.ts index 624255f86bd8..15b39fc1f1d3 100644 --- a/packages/kit-bg/src/services/ServiceHardware/HardwareConnectionManager.ts +++ b/packages/kit-bg/src/services/ServiceHardware/HardwareConnectionManager.ts @@ -1,8 +1,13 @@ -import { EDeviceType, ONEKEY_WEBUSB_FILTER } from '@onekeyfe/hd-shared'; +import { + EDeviceType, + type HardwareConnectProtocol, + ONEKEY_WEBUSB_FILTER, +} from '@onekeyfe/hd-shared'; import axios from 'axios'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { memoizee } from '@onekeyhq/shared/src/utils/cacheUtils'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import type { IHardwareCallContext } from '@onekeyhq/shared/types/device'; @@ -52,14 +57,22 @@ export class HardwareConnectionManager { HardwareConnectionManager.instance = null; } - private async getDesktopUsbSetting(): Promise< - 'webusb' | 'bridge' | undefined - > { + private async getDesktopUsbSetting( + connectProtocol?: HardwareConnectProtocol, + ): Promise<'webusb' | 'bridge' | undefined> { try { const dev = await this.backgroundApi.serviceDevSetting.getDevSetting(); - return dev?.settings?.usbCommunicationMode; + return deviceUtils.getDesktopUsbTransportType({ + usbCommunicationMode: dev?.settings?.usbCommunicationMode, + connectProtocol, + }) === EHardwareTransportType.Bridge + ? 'bridge' + : 'webusb'; } catch { - return undefined; + return deviceUtils.getDesktopUsbTransportType({ connectProtocol }) === + EHardwareTransportType.Bridge + ? 'bridge' + : 'webusb'; } } @@ -107,7 +120,7 @@ export class HardwareConnectionManager { } // WebUSB detection - async detectWebUSBAvailability(): Promise { + async detectWebUSBAvailability(connectId?: string): Promise { if (!platformEnv.isSupportDesktopBle) return true; try { const usb = globalThis?.navigator?.usb; @@ -119,13 +132,20 @@ export class HardwareConnectionManager { ); return isOneKey; }); - return onekeyDevices.length > 0; + const normalizedConnectId = connectId?.trim().toLowerCase(); + if (!normalizedConnectId) { + return onekeyDevices.length > 0; + } + return onekeyDevices.some( + (device) => + device.serialNumber?.trim().toLowerCase() === normalizedConnectId, + ); } catch { return false; } } - async detectBridgeAvailability(): Promise { + async detectBridgeAvailability(connectId?: string): Promise { if (!platformEnv.isSupportDesktopBle) { return true; } @@ -139,22 +159,35 @@ export class HardwareConnectionManager { }, ); - const devices = response.data as unknown[]; - const isAvailable = Array.isArray(devices) && devices.length > 0; - return isAvailable; + const devices = response.data as Array<{ path?: unknown }>; + if (!Array.isArray(devices)) { + return false; + } + const normalizedConnectId = connectId?.trim().toLowerCase(); + if (!normalizedConnectId) { + return devices.length > 0; + } + return devices.some( + (device) => + typeof device?.path === 'string' && + device.path.trim().toLowerCase() === normalizedConnectId, + ); } catch (_error) { return false; } } // Checking USB availability based on DevSetting - async detectUSBDeviceAvailability(): Promise { + async detectUSBDeviceAvailability( + connectId?: string, + connectProtocol?: HardwareConnectProtocol, + ): Promise { if (!platformEnv.isSupportDesktopBle) return true; - const mode = await this.getDesktopUsbSetting(); + const mode = await this.getDesktopUsbSetting(connectProtocol); if (mode === 'bridge') { - return this.detectBridgeAvailability(); + return this.detectBridgeAvailability(connectId); } - return this.detectWebUSBAvailability(); + return this.detectWebUSBAvailability(connectId); } // Trezor-scoped USB presence. detectUSBDeviceAvailability answers "is any @@ -284,14 +317,19 @@ export class HardwareConnectionManager { async determineOptimalTransportType( hardwareCallContext?: IHardwareCallContext, + connectId?: string, + connectProtocol?: HardwareConnectProtocol, ): Promise { const currentSettingType = await this.backgroundApi.serviceSetting.getHardwareTransportType(); if (platformEnv.isSupportDesktopBle) { - const mode = await this.getDesktopUsbSetting(); - const webUsbAvailable = await this.detectUSBDeviceAvailability(); - if (webUsbAvailable) { + const mode = await this.getDesktopUsbSetting(connectProtocol); + const usbAvailable = await this.detectUSBDeviceAvailability( + connectId, + connectProtocol, + ); + if (usbAvailable) { return mode === 'bridge' ? EHardwareTransportType.Bridge : EHardwareTransportType.WEBUSB; @@ -315,9 +353,11 @@ export class HardwareConnectionManager { shouldSwitchTransportType = memoizee( async ({ connectId, + connectProtocol, hardwareCallContext, }: { connectId?: string; + connectProtocol?: HardwareConnectProtocol; hardwareCallContext?: IHardwareCallContext; }): Promise<{ shouldSwitch: boolean; @@ -331,24 +371,30 @@ export class HardwareConnectionManager { // If a specific transport type is forced (e.g., for onboarding), use it directly if (forceTransportType) { - const shouldSwitch = this.actualTransportType !== forceTransportType; + const targetType = + deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: forceTransportType, + connectProtocol, + }); + const shouldSwitch = this.actualTransportType !== targetType; return { shouldSwitch, - targetType: forceTransportType, + targetType, }; } // quick detect mini device const isMiniDevice = connectId && connectId.startsWith('MI'); - // mini device should always use bridge transport type + // Mini does not support BLE, so it must always use the configured USB transport. if (isMiniDevice) { - const usbSetting = await this.getDesktopUsbSetting(); + const usbSetting = await this.getDesktopUsbSetting(connectProtocol); + const targetType = + usbSetting === 'webusb' + ? EHardwareTransportType.WEBUSB + : EHardwareTransportType.Bridge; return { - shouldSwitch: false, - targetType: - usbSetting === 'webusb' - ? EHardwareTransportType.WEBUSB - : EHardwareTransportType.Bridge, + shouldSwitch: this.actualTransportType !== targetType, + targetType, }; } @@ -356,6 +402,7 @@ export class HardwareConnectionManager { if ( [ EHardwareCallContext.BACKGROUND_TASK, + EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, EHardwareCallContext.SDK_INITIALIZATION, EHardwareCallContext.SILENT_CALL, ].includes(hardwareCallContext || EHardwareCallContext.USER_INTERACTION) @@ -372,8 +419,11 @@ export class HardwareConnectionManager { }; } - const optimalType = - await this.determineOptimalTransportType(hardwareCallContext); + const optimalType = await this.determineOptimalTransportType( + hardwareCallContext, + connectId, + connectProtocol, + ); const shouldSwitch = this.actualTransportType !== optimalType; console.log( @@ -392,7 +442,12 @@ export class HardwareConnectionManager { promise: true, maxAge: timerUtils.getTimeDurationMs({ seconds: 2 }), max: 1, - normalizer: (args) => args[0].hardwareCallContext || 'default', + normalizer: (args) => + JSON.stringify([ + args[0].hardwareCallContext || 'default', + args[0].connectId?.trim().toLowerCase() || '', + args[0].connectProtocol || '', + ]), }, ); diff --git a/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts b/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts index 365b2d9156d7..b4294c10cefd 100644 --- a/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts +++ b/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts @@ -53,7 +53,61 @@ export type IFirmwareAuthenticateParams = { const deviceCheckingCodes = new Set([10_104, 10_105, 10_106, 10_107]); +function buildSkippedFirmwareAuthenticateResult( + device: SearchDevice | IDBDevice, +): IFirmwareVerifyResult { + return { + verified: false, + skipVerification: true, + device, + payload: { + deviceType: device.deviceType, + data: '', + cert: '', + signature: '', + }, + result: { + code: 0, + message: 'Firmware authentication skipped', + }, + }; +} + +function buildSkippedFirmwareHashResult( + onekeyFeatures: OnekeyFeatures | undefined, +): IDeviceVerifyVersionCompareResult { + const localVerifyInfos = onekeyFeatures + ? deviceUtils.parseLocalDeviceVersions({ onekeyFeatures }) + : undefined; + + return { + certificate: { + isMatch: false, + format: onekeyFeatures?.onekey_serial_no ?? '', + }, + firmware: { + isMatch: false, + format: localVerifyInfos?.firmware.formatted ?? '', + releaseUrl: localVerifyInfos?.firmware.releaseUrl, + }, + bluetooth: { + isMatch: false, + format: localVerifyInfos?.bluetooth.formatted ?? '', + releaseUrl: localVerifyInfos?.bluetooth.releaseUrl, + }, + bootloader: { + isMatch: false, + format: localVerifyInfos?.bootloader.formatted ?? '', + releaseUrl: localVerifyInfos?.bootloader.releaseUrl, + }, + }; +} + export class HardwareVerifyManager extends ServiceHardwareManagerBase { + private isFirmwareVerificationEnabled(deviceType?: IDeviceType) { + return deviceUtils.isFirmwareVerifySupported(deviceType); + } + @backgroundMethod() async getDeviceCertWithSig({ connectId, @@ -79,9 +133,15 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { async shouldAuthenticateFirmware({ device, }: IShouldAuthenticateFirmwareParams) { + if (!this.isFirmwareVerificationEnabled(device.deviceType)) { + return false; + } + const dbDevice: IDBDevice | undefined = await localDb.getExistingDevice({ rawDeviceId: device.deviceId || '', - uuid: device.uuid, + uuid: + (device as SearchDevice & { serialNo?: string | null }).serialNo || + device.uuid, }); // const versionText = deviceUtils.getDeviceVersionStr(device); // return dbDevice?.verifiedAtVersion !== versionText; @@ -102,6 +162,10 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { skipDeviceCancel, }: IFirmwareAuthenticateParams): Promise { const { connectId, deviceType } = device; + if (!this.isFirmwareVerificationEnabled(deviceType)) { + return buildSkippedFirmwareAuthenticateResult(device); + } + if (!connectId) { throw new OneKeyLocalError( 'firmwareAuthenticate ERROR: device connectId is undefined', @@ -204,12 +268,13 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { }: { features: IOneKeyDeviceFeatures | undefined; }) { - // onekey_firmware_version - // onekey_firmware_hash - // onekey_ble_version - // onekey_ble_hash - // onekey_boot_version - // onekey_boot_hash + const deviceType = features + ? await deviceUtils.getDeviceTypeFromFeatures({ features }) + : undefined; + if (!this.isFirmwareVerificationEnabled(deviceType)) { + return false; + } + if (!features) { return false; } @@ -267,6 +332,10 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { async fetchFirmwareVerifyHash( params: IFetchFirmwareVerifyHashParams, ): Promise { + if (!this.isFirmwareVerificationEnabled(params.deviceType)) { + return []; + } + try { return await this.fetchFirmwareVerifyHashWithCache(params); } catch { @@ -313,6 +382,10 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { deviceType: IDeviceType; onekeyFeatures: OnekeyFeatures | undefined; }): Promise { + if (!this.isFirmwareVerificationEnabled(deviceType)) { + return buildSkippedFirmwareHashResult(onekeyFeatures); + } + const defaultResult = { certificate: { isMatch: true, @@ -328,8 +401,8 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { } const verifyVersions = - await deviceUtils.getDeviceVerifyVersionsFromFeatures({ - features: onekeyFeatures, + await deviceUtils.getDeviceVerifyVersionsFromRawOnekeyFeatures({ + onekeyFeatures, deviceType, }); if (!verifyVersions) { @@ -370,7 +443,7 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { return { certificate: { isMatch: true, - format: onekeyFeatures?.onekey_serial_no ?? '', + format: onekeyFeatures.onekey_serial_no ?? '', }, firmware: { isMatch: firmwareMatch, diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts new file mode 100644 index 000000000000..a86cfa867309 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts @@ -0,0 +1,715 @@ +import { + checkBLEPermissions, + checkBLEState, +} from '@onekeyhq/shared/src/hardware/blePermissions'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import { + EHardwareCallContext, + EHardwareVendor, +} from '@onekeyhq/shared/types/device'; + +import localDb from '../../dbs/local/localDb'; +import simpleDb from '../../dbs/simple/simpleDb'; + +import { HardwareConnectionManager } from './HardwareConnectionManager'; +import ServiceHardware from './ServiceHardware'; + +import type { IBackgroundApi } from '../../apis/IBackgroundApi'; +import type { IDBDevice, IDBWallet } from '../../dbs/local/types'; +import type { ISimpleDBAppStatus } from '../../dbs/simple/entity/SimpleDbEntityAppStatus'; +import type { + Features, + SearchDevice, + UiResponseEvent, +} from '@onekeyfe/hd-core'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + SyncDeviceLabelToWalletName: 'SyncDeviceLabelToWalletName', + UpdateWalletAvatarByDeviceSerialNo: 'UpdateWalletAvatarByDeviceSerialNo', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isDesktop: true, + isJest: true, + isNative: false, + isNativeAndroid: false, + isSupportDesktopBle: false, + }, +})); + +jest.mock('@onekeyhq/shared/src/hardware/blePermissions', () => ({ + checkBLEPermissions: jest.fn(), + checkBLEState: jest.fn(), +})); + +jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ + __esModule: true, + DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, + T1_HOME_SCREEN_DEFAULT_IMAGES: [], + default: {}, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getAllDevices: jest.fn(), + getAllWallets: jest.fn(), + getDeviceByQuery: jest.fn(), + updateDeviceConnectProtocol: jest.fn(), + }, +})); + +jest.mock('../../dbs/simple/simpleDb', () => ({ + __esModule: true, + default: { + appStatus: { + getRawData: jest.fn(), + setRawData: jest.fn(), + }, + }, +})); + +jest.mock('../../states/jotai/atoms', () => ({ + EHardwareUiStateAction: {}, + hardwareForceTransportAtom: { + get: jest.fn(async () => ({ forceTransportType: undefined })), + }, + hardwareUiStateAtom: {}, + hardwareUiStateCompletedAtom: {}, + settingsPersistAtom: {}, +})); + +const mutablePlatformEnv = platformEnv as unknown as { + isNative: boolean; + isNativeAndroid: boolean; + isSupportDesktopBle: boolean; +}; +const mockedLocalDb = jest.mocked(localDb); +const mockedAppStatus = jest.mocked(simpleDb.appStatus); +const mockedCheckBLEPermissions = jest.mocked(checkBLEPermissions); +const mockedCheckBLEState = jest.mocked(checkBLEState); +let appStatusData: ISimpleDBAppStatus; + +function buildDevice({ + features, + connectProtocol, +}: { + features?: Features; + connectProtocol?: 'V1' | 'V2'; +}) { + return { + connectId: 'USB_SERIAL', + uuid: 'DEVICE_SERIAL', + deviceId: 'DEVICE_ID', + deviceType: 'pro', + name: 'OneKey Pro', + commType: 'webusb', + features, + connectProtocol, + } as unknown as SearchDevice; +} + +describe('ServiceHardware.connect WebUSB reuse', () => { + beforeEach(() => { + jest.clearAllMocks(); + HardwareConnectionManager.resetInstance(); + mutablePlatformEnv.isNative = false; + mutablePlatformEnv.isNativeAndroid = false; + mutablePlatformEnv.isSupportDesktopBle = false; + mockedLocalDb.getAllDevices.mockResolvedValue({ devices: [] }); + mockedLocalDb.getAllWallets.mockResolvedValue({ wallets: [] }); + mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + mockedLocalDb.updateDeviceConnectProtocol.mockResolvedValue(undefined); + appStatusData = { + hardwareConnectProtocolMigrationVersion: 1, + } as ISimpleDBAppStatus; + mockedAppStatus.getRawData.mockImplementation(() => + Promise.resolve(appStatusData), + ); + mockedAppStatus.setRawData.mockImplementation((dataOrBuilder) => { + const nextValue = + typeof dataOrBuilder === 'function' + ? dataOrBuilder(appStatusData) + : dataOrBuilder; + return Promise.resolve(nextValue).then((value) => { + appStatusData = value; + return value; + }); + }); + mockedCheckBLEPermissions.mockResolvedValue(true); + mockedCheckBLEState.mockResolvedValue(true); + }); + + it('升级时仅迁移历史 OneKey 硬件设备的连接协议', async () => { + appStatusData = {}; + mockedLocalDb.getAllWallets.mockResolvedValue({ + wallets: [ + { + id: 'hw-wallet-legacy', + type: 'hw', + associatedDevice: 'legacy-onekey-device', + } as IDBWallet, + { + id: 'hw-wallet-observed-v2', + type: 'hw', + associatedDevice: 'observed-v2-device', + } as IDBWallet, + { + id: 'hw-wallet-prefilled', + type: 'hw', + associatedDevice: 'prefilled-device', + } as IDBWallet, + { + id: 'qr-wallet', + type: 'qr', + associatedDevice: 'qr-device', + } as IDBWallet, + { + id: 'hw-wallet-ledger', + type: 'hw', + associatedDevice: 'ledger-device', + } as IDBWallet, + ], + }); + mockedLocalDb.getAllDevices.mockResolvedValue({ + devices: [ + { + id: 'legacy-onekey-device', + vendor: EHardwareVendor.onekey, + } as IDBDevice, + { + id: 'observed-v2-device', + vendor: EHardwareVendor.onekey, + deviceStateInfo: { protocol: 'V2' }, + } as IDBDevice, + { + id: 'prefilled-device', + vendor: EHardwareVendor.onekey, + connectProtocol: 'V2', + } as IDBDevice, + { + id: 'qr-device', + vendor: EHardwareVendor.onekey, + } as IDBDevice, + { + id: 'ledger-device', + vendor: EHardwareVendor.ledger, + } as IDBDevice, + ], + }); + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + + await service.migrateExistingDeviceConnectProtocols(); + + expect(mockedLocalDb.updateDeviceConnectProtocol.mock.calls).toEqual([ + [ + { + dbDeviceId: 'legacy-onekey-device', + connectProtocol: 'V1', + }, + ], + [ + { + dbDeviceId: 'observed-v2-device', + connectProtocol: 'V2', + }, + ], + ]); + expect(appStatusData).toMatchObject({ + hardwareConnectProtocolMigrationVersion: 1, + }); + }); + + it('连接协议迁移完成后不重复扫描数据库', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + + await service.migrateExistingDeviceConnectProtocols(); + await service.migrateExistingDeviceConnectProtocols(); + + expect(mockedLocalDb.getAllDevices.mock.calls).toHaveLength(0); + expect(mockedLocalDb.getAllWallets.mock.calls).toHaveLength(0); + expect(mockedLocalDb.updateDeviceConnectProtocol.mock.calls).toHaveLength( + 0, + ); + }); + + it('连接协议迁移失败时保留重试机会且不写完成标记', async () => { + appStatusData = {}; + mockedLocalDb.getAllWallets.mockResolvedValue({ + wallets: [ + { + id: 'hw-wallet-legacy', + type: 'hw', + associatedDevice: 'legacy-onekey-device', + } as IDBWallet, + ], + }); + mockedLocalDb.getAllDevices.mockResolvedValue({ + devices: [ + { + id: 'legacy-onekey-device', + vendor: EHardwareVendor.onekey, + } as IDBDevice, + ], + }); + mockedLocalDb.updateDeviceConnectProtocol.mockRejectedValueOnce( + new Error('db write failed'), + ); + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + + await expect( + service.migrateExistingDeviceConnectProtocols(), + ).rejects.toThrow('db write failed'); + expect(appStatusData).not.toMatchObject({ + hardwareConnectProtocolMigrationVersion: 1, + }); + + await expect( + service.migrateExistingDeviceConnectProtocols(), + ).resolves.toBeUndefined(); + expect(mockedLocalDb.updateDeviceConnectProtocol.mock.calls).toHaveLength( + 2, + ); + expect(appStatusData).toMatchObject({ + hardwareConnectProtocolMigrationVersion: 1, + }); + }); + + it('复用首次 WebUSB 通讯结果,后续调用固定已探测协议', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('USB_SERIAL'); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey Pro' } as Features); + const features = { label: 'OneKey Pro' } as Features; + + await expect( + service.connect({ + device: buildDevice({ features, connectProtocol: 'V1' }), + }), + ).resolves.toBe(features); + expect(connectDevice).not.toHaveBeenCalled(); + + await service.connect({ + device: buildDevice({}), + }); + expect(connectDevice).toHaveBeenCalledWith({ + connectId: 'USB_SERIAL', + params: { connectProtocol: 'V1' }, + }); + }); + + it('onboarding 复用 WebUSB 搜索结果后持久化已确认协议', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('USB_SERIAL'); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey Pro 2' } as Features); + const features = { label: 'OneKey Pro 2' } as Features; + + await expect( + service.connect({ + device: buildDevice({ features, connectProtocol: 'V2' }), + forceProtocolDetection: true, + }), + ).resolves.toBe(features); + expect(connectDevice).not.toHaveBeenCalled(); + expect(appStatusData.hardwareConnectProtocolByConnectId).toMatchObject({ + usb_serial: { protocol: 'V2' }, + device_serial: { protocol: 'V2' }, + }); + + await service.connect({ device: buildDevice({}) }); + expect(connectDevice).toHaveBeenCalledWith({ + connectId: 'USB_SERIAL', + params: { connectProtocol: 'V2' }, + }); + }); + + it('不再吞掉 WebUSB 重连错误并伪装成成功', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('USB_SERIAL'); + jest + .spyOn(service, 'connectDevice') + .mockRejectedValue(new Error('WebUSB reconnect failed')); + + await expect( + service.connect({ device: buildDevice({ connectProtocol: 'V1' }) }), + ).rejects.toThrow('WebUSB reconnect failed'); + }); + + it('桌面 BLE 搜索结果直接使用 Noble peripheral id,不替换成 USB 序列号', async () => { + mutablePlatformEnv.isSupportDesktopBle = true; + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const getCompatibleConnectId = jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('PRB50B0127B'); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey Pro' } as Features); + const blePeripheralId = '7d0dce8f968b0d819cd4ed8aab37f1e5'; + + await service.connect({ + device: { + connectId: blePeripheralId, + uuid: blePeripheralId, + deviceId: null, + deviceType: 'pro', + name: 'Pro 9B6B', + commType: 'electron-ble', + } as unknown as SearchDevice, + }); + + expect(getCompatibleConnectId).not.toHaveBeenCalled(); + expect(connectDevice).toHaveBeenCalledWith({ + connectId: blePeripheralId, + params: {}, + }); + }); + + it('onboarding 首次连接忽略搜索阶段的协议提示', async () => { + mutablePlatformEnv.isSupportDesktopBle = true; + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey Pro' } as Features); + const blePeripheralId = 'PRO_BLE_ID'; + + await service.connect({ + device: { + connectId: blePeripheralId, + connectProtocol: 'V2', + uuid: blePeripheralId, + deviceId: null, + deviceType: 'pro2', + name: 'Pro 2', + commType: 'electron-ble', + } as SearchDevice, + connectProtocol: 'V2', + forceProtocolDetection: true, + }); + + expect(connectDevice).toHaveBeenCalledWith({ + connectId: blePeripheralId, + params: { forceProtocolDetection: true }, + }); + }); + + it('onboarding 首次连接自动探测协议,并在后续调用固定探测结果', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const connectId = 'PRO_BLE_ID'; + const getDeviceState = jest.fn().mockResolvedValue({ + success: true, + payload: { + protocol: 'V1', + identity: { serialNo: 'PRO_SERIAL' }, + }, + }); + const getFeatures = jest.fn().mockResolvedValue({ + success: true, + payload: { + label: 'OneKey Pro', + protocol: 'V1', + }, + }); + jest.spyOn(service, 'getSDKInstance').mockResolvedValue({ + getDeviceState, + getFeatures, + } as unknown as Awaited>); + ( + service as unknown as { + deviceProtocolByConnectId: Map; + } + ).deviceProtocolByConnectId.set(connectId, 'V2'); + + await service._getFeaturesLowLevel({ + connectId, + params: { forceProtocolDetection: true }, + }); + + expect(getDeviceState).toHaveBeenCalledWith(connectId, { + forceProtocolDetection: true, + }); + expect(getFeatures).toHaveBeenCalledWith(connectId, { + connectProtocol: 'V1', + }); + + getDeviceState.mockClear(); + getFeatures.mockClear(); + await service._getFeaturesLowLevel({ connectId }); + + expect(getDeviceState).not.toHaveBeenCalled(); + expect(getFeatures).toHaveBeenCalledWith(connectId, { + connectProtocol: 'V1', + }); + }); + + it.each([ + { + platformName: 'iOS', + isNativeAndroid: false, + connectId: 'IOS_CBPERIPHERAL_UUID', + storedBleConnectId: 'ios_cbperipheral_uuid', + connectProtocol: 'V1' as const, + deviceType: 'pro', + }, + { + platformName: 'Android', + isNativeAndroid: true, + connectId: 'AA:BB:CC:DD:EE:FF', + storedBleConnectId: 'AA:BB:CC:DD:EE:FF', + connectProtocol: 'V2' as const, + deviceType: 'pro2', + }, + ])( + 'keeps the current $platformName BLE connectId and forwards $connectProtocol', + async ({ + isNativeAndroid, + connectId, + storedBleConnectId, + connectProtocol, + deviceType, + }) => { + mutablePlatformEnv.isNative = true; + mutablePlatformEnv.isNativeAndroid = isNativeAndroid; + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device', + connectId: 'USB_SERIAL', + usbConnectId: 'USB_SERIAL', + bleConnectId: storedBleConnectId, + deviceId: 'DEVICE_ID', + connectProtocol, + vendor: EHardwareVendor.onekey, + name: 'OneKey', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.BLE), + }, + } as unknown as IBackgroundApi, + }); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey' } as Features); + + await service.connect({ + device: { + connectId, + uuid: connectId, + deviceId: 'DEVICE_ID', + deviceType, + name: 'OneKey', + commType: 'ble', + connectProtocol, + } as unknown as SearchDevice, + connectProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + + expect(connectDevice).toHaveBeenCalledWith({ + connectId, + params: { connectProtocol }, + }); + }, + ); + + it('按设备及 USB/BLE 端点隔离绑定已确认协议', async () => { + const setDeviceConnectProtocol = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const internals = service as unknown as { + activeHardwareSDKInstance: { + setDeviceConnectProtocol: typeof setDeviceConnectProtocol; + }; + rememberDeviceProtocol: (params: { + connectIds: string[]; + protocol: 'V1' | 'V2'; + }) => Promise; + }; + internals.activeHardwareSDKInstance = { setDeviceConnectProtocol }; + + await internals.rememberDeviceProtocol({ + connectIds: ['DEVICE_A_USB', 'DEVICE_A_BLE'], + protocol: 'V2', + }); + await internals.rememberDeviceProtocol({ + connectIds: ['DEVICE_B_USB', 'DEVICE_B_BLE'], + protocol: 'V1', + }); + + expect(setDeviceConnectProtocol.mock.calls).toEqual([ + ['DEVICE_A_USB', 'V2'], + ['DEVICE_A_BLE', 'V2'], + ['DEVICE_B_USB', 'V1'], + ['DEVICE_B_BLE', 'V1'], + ]); + }); + + it('钱包设备记录创建前也持久化端点协议,并可由新服务实例恢复', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const internals = service as unknown as { + rememberDeviceProtocol: (params: { + connectIds: string[]; + protocol: 'V1' | 'V2'; + }) => Promise; + }; + + await internals.rememberDeviceProtocol({ + connectIds: ['DEVICE_USB', 'DEVICE_BLE'], + protocol: 'V2', + }); + + expect(appStatusData.hardwareConnectProtocolByConnectId).toMatchObject({ + device_usb: { protocol: 'V2' }, + device_ble: { protocol: 'V2' }, + }); + + const restoredService = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const restoredInternals = restoredService as unknown as { + getKnownDeviceProtocol: ( + connectId: string, + ) => Promise<'V1' | 'V2' | undefined>; + }; + await expect( + restoredInternals.getKnownDeviceProtocol('DEVICE_BLE'), + ).resolves.toBe('V2'); + }); + + it('冷启动时从持久化恢复协议并绑定同一设备的 USB/BLE 端点', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device', + connectId: 'DEVICE_USB', + usbConnectId: 'DEVICE_USB', + bleConnectId: 'DEVICE_BLE', + deviceId: 'DEVICE_ID', + connectProtocol: 'V2', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + const setDeviceConnectProtocol = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const internals = service as unknown as { + getKnownDeviceProtocol: (connectId: string) => Promise<'V1' | 'V2'>; + bindRememberedDeviceProtocols: (instance: { + setDeviceConnectProtocol: typeof setDeviceConnectProtocol; + }) => void; + }; + + await expect(internals.getKnownDeviceProtocol('DEVICE_USB')).resolves.toBe( + 'V2', + ); + internals.bindRememberedDeviceProtocols({ setDeviceConnectProtocol }); + + expect(setDeviceConnectProtocol).toHaveBeenCalledWith('DEVICE_USB', 'V2'); + expect(setDeviceConnectProtocol).toHaveBeenCalledWith('DEVICE_BLE', 'V2'); + }); + + it('普通设备调用缺少数据库或缓存协议时拒绝初始化 SDK', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + + await expect( + service.getSDKInstance({ connectId: 'UNKNOWN_DEVICE' }), + ).rejects.toThrow('Hardware connect protocol is unavailable'); + }); + + it('Passphrase 回包直接发送给当前 SDK,不重新执行传输选择', async () => { + const uiResponse = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const shouldSwitchTransportType = jest.spyOn( + service.connectionManager, + 'shouldSwitchTransportType', + ); + const internals = service as unknown as { + activeHardwareSDKInstance: { uiResponse: typeof uiResponse }; + sendUiResponseToActiveSdk?: (response: UiResponseEvent) => Promise; + }; + internals.activeHardwareSDKInstance = { uiResponse }; + const response = { + type: 'ui-receive_passphrase', + payload: { + value: 'hidden wallet', + passphraseOnDevice: false, + attachPinOnDevice: false, + save: false, + }, + interactionId: 'pro-ble-interaction', + deviceId: 'pro-device', + } as UiResponseEvent; + + expect(typeof internals.sendUiResponseToActiveSdk).toBe('function'); + await internals.sendUiResponseToActiveSdk?.(response); + + expect(uiResponse).toHaveBeenCalledWith(response); + expect(shouldSwitchTransportType).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts index 28777c1c844c..936eb6ef3919 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts @@ -1,7 +1,6 @@ -import { - EAppEventBusNames, - appEventBus, -} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; +import axios from 'axios'; + import { checkBLEPermissions, checkBLEState, @@ -12,6 +11,7 @@ import { EHardwareCallContext, EHardwareVendor, } from '@onekeyhq/shared/types/device'; +import type { IOneKeyDeviceFeaturesWithAppParams } from '@onekeyhq/shared/types/device'; import localDb from '../../dbs/local/localDb'; @@ -63,6 +63,18 @@ jest.mock('@onekeyhq/shared/src/hardware/blePermissions', () => ({ checkBLEState: jest.fn(), })); +jest.mock('axios', () => ({ + __esModule: true, + default: { + post: jest.fn(), + }, +})); + +jest.mock('@onekeyhq/shared/src/hardware/blePermissions', () => ({ + checkBLEPermissions: jest.fn(), + checkBLEState: jest.fn(), +})); + jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ __esModule: true, DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, @@ -77,6 +89,16 @@ jest.mock('../../dbs/local/localDb', () => ({ }, })); +jest.mock('../../dbs/simple/simpleDb', () => ({ + __esModule: true, + default: { + appStatus: { + getRawData: jest.fn().mockResolvedValue({}), + setRawData: jest.fn().mockResolvedValue({}), + }, + }, +})); + jest.mock('../../states/jotai/atoms', () => ({ EHardwareUiStateAction: { BLUETOOTH_PERMISSION: 'ui-bluetooth_permission', @@ -91,10 +113,21 @@ jest.mock('../../states/jotai/atoms', () => ({ settingsPersistAtom: {}, })); +jest.mock('../../states/jotai/atoms/desktopBluetooth', () => ({ + desktopBluetoothAtom: { + get: jest.fn(async () => ({ isRequestedPermission: true })), + set: jest.fn(), + }, + hardwareForceTransportAtom: { + get: jest.fn(async () => ({ forceTransportType: undefined })), + set: jest.fn(), + }, +})); + const mockedLocalDb = jest.mocked(localDb); const mockedCheckBLEPermissions = jest.mocked(checkBLEPermissions); const mockedCheckBLEState = jest.mocked(checkBLEState); -const mockedAppEventBus = jest.mocked(appEventBus); +const mockedAxios = jest.mocked(axios); const mutablePlatformEnv = platformEnv as unknown as { isDesktop: boolean; isJest: boolean; @@ -117,6 +150,152 @@ describe('ServiceHardware.getCompatibleConnectId', () => { mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); mockedCheckBLEPermissions.mockResolvedValue(true); mockedCheckBLEState.mockResolvedValue(true); + mockedAxios.post.mockReset(); + }); + + it.each([ + { + platformName: 'iOS', + isNativeAndroid: false, + bleConnectId: 'F7E44000-1D2C-1C79-509D-55DFDC8201FF', + }, + { + platformName: 'Android', + isNativeAndroid: true, + bleConnectId: 'AA:BB:CC:DD:EE:FF', + }, + ])( + 'uses the bound BLE connectId on $platformName', + async ({ isNativeAndroid, bleConnectId }) => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid, + }); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + await expect( + new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }).getCompatibleConnectId({ + connectId: 'PRB09B0088A', + featuresDeviceId: 'STALE_DEVICE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(bleConnectId); + + expect(mockedLocalDb.getDeviceByQuery.mock.calls).toEqual([ + [{ connectId: 'PRB09B0088A' }], + ]); + }, + ); + + it('uses the bound Noble peripheral ID before stale device info on desktop', async () => { + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: true, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + + await expect( + service.getCompatibleConnectId({ + connectId: 'PRB09B0088A', + featuresDeviceId: 'STALE_DEVICE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(bleConnectId); + + expect(mockedLocalDb.getDeviceByQuery.mock.calls[0]).toEqual([ + { connectId: 'PRB09B0088A' }, + ]); + }); + + it('falls back to deviceId without combining legacy features', async () => { + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const device = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{"$app_firmware_type":"universal"}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery + .mockResolvedValueOnce(undefined) + .mockResolvedValue(device); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: true, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + + await expect( + service.getCompatibleConnectId({ + connectId: 'STALE_CONNECT_ID', + featuresDeviceId: 'PRO2_DEVICE_ID', + features: { + $app_firmware_type: 'universal', + } as IOneKeyDeviceFeaturesWithAppParams, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(bleConnectId); + + expect(mockedLocalDb.getDeviceByQuery.mock.calls[0]).toEqual([ + { connectId: 'STALE_CONNECT_ID' }, + ]); + expect(mockedLocalDb.getDeviceByQuery.mock.calls[1]).toEqual([ + { featuresDeviceId: 'PRO2_DEVICE_ID' }, + ]); }); it('uses a bound Trezor BLE connectId when desktop BLE is selected', async () => { @@ -163,6 +342,211 @@ describe('ServiceHardware.getCompatibleConnectId', () => { ).resolves.toBe('BLE_ID'); }); + it('does not let an unrelated Pro 2 WebUSB device take over a Pro BLE call', async () => { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + 'navigator', + ); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + usb: { + getDevices: jest.fn().mockResolvedValue([ + { + vendorId: 0x12_09, + productId: 0x4f_4c, + serialNumber: 'PRO2_USB_ID', + }, + ]), + }, + }, + }); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro-device', + connectId: 'PRB50B0127B', + usbConnectId: 'PRB50B0127B', + bleConnectId: 'PRO_BLE_PERIPHERAL_ID', + deviceId: 'PRO_FEATURES_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + try { + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + } as unknown as IBackgroundApi, + }); + const detectBluetoothAvailability = jest + .spyOn(service.connectionManager, 'detectBluetoothAvailability') + .mockResolvedValue(true); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'PRO2_USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toMatchObject({ + targetType: EHardwareTransportType.WEBUSB, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'PRB50B0127B', + featuresDeviceId: 'PRO_FEATURES_DEVICE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe('PRO_BLE_PERIPHERAL_ID'); + expect(detectBluetoothAvailability).toHaveBeenCalledTimes(1); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator); + } else { + delete (globalThis as { navigator?: Navigator }).navigator; + } + } + }); + + it('does not let an unrelated Bridge device take over a Pro BLE call', async () => { + mockedAxios.post.mockResolvedValue({ + data: [{ path: 'UNRELATED_USB_ID' }], + }); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro-device', + connectId: 'PRB50B0127B', + usbConnectId: 'PRB50B0127B', + bleConnectId: 'PRO_BLE_PERIPHERAL_ID', + deviceId: 'PRO_FEATURES_DEVICE_ID', + connectProtocol: 'V1', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'bridge' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.Bridge), + }, + } as unknown as IBackgroundApi, + }); + const detectBluetoothAvailability = jest + .spyOn(service.connectionManager, 'detectBluetoothAvailability') + .mockResolvedValue(true); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'UNRELATED_USB_ID', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toMatchObject({ + targetType: EHardwareTransportType.Bridge, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'PRB50B0127B', + featuresDeviceId: 'PRO_FEATURES_DEVICE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe('PRO_BLE_PERIPHERAL_ID'); + expect(detectBluetoothAvailability).toHaveBeenCalledTimes(1); + }); + + it('switches Mini back to the configured USB transport after BLE was active', async () => { + const setHardwareTransportType = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + setHardwareTransportType, + }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.setCurrentTransportType( + EHardwareTransportType.DesktopWebBle, + ); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'MI123456789', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toEqual({ + shouldSwitch: true, + targetType: EHardwareTransportType.WEBUSB, + }); + }); + + it('uses WebUSB for Protocol V2 even when Bridge is configured', async () => { + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'bridge' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.Bridge), + }, + } as unknown as IBackgroundApi, + }); + const detectWebUSBAvailability = jest + .spyOn(service.connectionManager, 'detectWebUSBAvailability') + .mockResolvedValue(true); + const detectBridgeAvailability = jest.spyOn( + service.connectionManager, + 'detectBridgeAvailability', + ); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'PRO2_USB_ID', + connectProtocol: 'V2', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toEqual({ + shouldSwitch: true, + targetType: EHardwareTransportType.WEBUSB, + }); + expect(detectWebUSBAvailability).toHaveBeenCalledWith('PRO2_USB_ID'); + expect(detectBridgeAvailability).not.toHaveBeenCalled(); + }); + it('rejects a stored third-party connectId before initializing OneKey SDK', async () => { mockedLocalDb.getDeviceByQuery.mockResolvedValue({ id: 'db-device-1', @@ -360,4 +744,217 @@ describe('ServiceHardware.getCompatibleConnectId', () => { }, ); }); + + it('uploads a portfolio package through the SDK with a silent context', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const getCompatibleConnectId = jest.fn().mockResolvedValue('ONEKEY_USB'); + const uploadPortfolio = jest.fn().mockResolvedValue({ + success: true, + payload: { portfolioUpdated: true }, + }); + const getSDKInstance = jest.fn().mockResolvedValue({ + uploadPortfolio, + } as unknown as Awaited>); + service.getCompatibleConnectId = getCompatibleConnectId; + service.getSDKInstance = getSDKInstance; + + const packageBytes = new Uint8Array([1, 2, 3]).buffer; + + await expect( + service.uploadPortfolioPackage({ + connectId: 'ONEKEY_USB', + packageBytes, + }), + ).resolves.toEqual({ portfolioUpdated: true }); + + expect(getCompatibleConnectId).toHaveBeenCalledWith({ + connectId: 'ONEKEY_USB', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + }); + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: 'ONEKEY_USB', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + }); + expect(uploadPortfolio).toHaveBeenCalledWith('ONEKEY_USB', { + packageBytes, + }); + }); + + it('ignores an explicit protocol selection during Pro 2 discovery', async () => { + const searchDevices = jest.fn().mockResolvedValue({ + success: true, + payload: [], + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const getSDKInstance = jest.fn().mockResolvedValue({ + searchDevices, + } as unknown as Awaited>); + service.getSDKInstance = getSDKInstance; + + await expect( + service.searchDevices({ connectProtocol: 'V2' }), + ).resolves.toEqual({ success: true, payload: [] }); + + expect(searchDevices).toHaveBeenCalledWith(); + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: undefined, + }); + }); +}); + +describe('ServiceHardware.getDeviceStateWithUnlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('unlocks once and rereads the canonical state', async () => { + const lockedState = { + status: { initialized: true, unlocked: false }, + } as Awaited>; + const unlockedState = { + status: { initialized: true, unlocked: true }, + } as Awaited>; + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { + runExclusiveOneKeyOperation: jest.fn( + async (operation: (lease: object) => Promise) => + operation({ deviceKey: 'PRO2_USB', owner: Symbol('test') }), + ), + }, + } as unknown as IBackgroundApi, + }); + + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getDeviceState = jest + .fn() + .mockResolvedValueOnce(lockedState) + .mockResolvedValueOnce(unlockedState); + const unlockDevice = jest + .spyOn(service, 'unlockDevice') + .mockResolvedValue({} as never); + + await expect( + service.getDeviceStateWithUnlock({ + connectId: 'ORIGINAL_CONNECT_ID', + params: { scope: 'runtime' }, + }), + ).resolves.toBe(unlockedState); + + expect(unlockDevice).toHaveBeenCalledTimes(1); + expect(unlockDevice).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(service.getDeviceState).toHaveBeenCalledTimes(2); + }); + + it('forwards an explicit PIN type before rereading the canonical state', async () => { + const lockedState = { + status: { initialized: true, unlocked: false }, + } as Awaited>; + const unlockedState = { + status: { + initialized: true, + unlocked: true, + unlockedAttachPin: true, + }, + } as Awaited>; + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { + runExclusiveOneKeyOperation: jest.fn( + async (operation: (lease: object) => Promise) => + operation({ deviceKey: 'PRO2_USB', owner: Symbol('test') }), + ), + }, + } as unknown as IBackgroundApi, + }); + + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getDeviceState = jest + .fn() + .mockResolvedValueOnce(lockedState) + .mockResolvedValueOnce(unlockedState); + const unlockDevice = jest + .spyOn(service, 'unlockDevice') + .mockResolvedValue({} as never); + + await expect( + service.getDeviceStateWithUnlock({ + connectId: 'ORIGINAL_CONNECT_ID', + pinType: DeviceSessionPinType.Any, + params: { scope: 'runtime' }, + }), + ).resolves.toBe(unlockedState); + + expect(unlockDevice).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + pinType: DeviceSessionPinType.Any, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(service.getDeviceState).toHaveBeenCalledTimes(2); + }); + + it('does not request an unlock before the device wallet is initialized', async () => { + const uninitializedState = { + status: { initialized: false, unlocked: false }, + } as Awaited>; + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { + runExclusiveOneKeyOperation: jest.fn( + async (operation: (lease: object) => Promise) => + operation({ deviceKey: 'PRO2_USB', owner: Symbol('test') }), + ), + }, + } as unknown as IBackgroundApi, + }); + + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getDeviceState = jest.fn().mockResolvedValue(uninitializedState); + const unlockDevice = jest + .spyOn(service, 'unlockDevice') + .mockResolvedValue({} as never); + + await expect( + service.getDeviceStateWithUnlock({ + connectId: 'ORIGINAL_CONNECT_ID', + params: { scope: 'runtime' }, + }), + ).rejects.toThrow('Device is not initialized'); + + expect(unlockDevice).not.toHaveBeenCalled(); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(service.getDeviceState).toHaveBeenCalledTimes(1); + }); +}); + +describe('ServiceHardware.unlockDevice', () => { + it('passes the explicit PIN type to the hardware SDK', async () => { + const deviceUnlock = jest.fn().mockResolvedValue({ + success: true, + payload: { unlocked: true }, + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + deviceUnlock, + } as unknown as Awaited>); + + await service.unlockDevice({ + connectId: 'ORIGINAL_CONNECT_ID', + pinType: DeviceSessionPinType.Any, + }); + + expect(deviceUnlock).toHaveBeenCalledWith('PRO2_USB', { + pinType: DeviceSessionPinType.Any, + }); + }); }); diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ledgerBle.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ledgerBle.test.ts index ceed6894a175..039b1b13308b 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ledgerBle.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ledgerBle.test.ts @@ -132,6 +132,25 @@ describe('ServiceHardware Ledger BLE device mapping', () => { expect(rawDeviceId).toBe('TREZOR-FEATURES-DEVICE-ID'); }); + it('preserves the Ledger camelCase feature identity fallback', () => { + const rawDeviceId = deviceUtils.getRawDeviceId({ + device: { + connectId: 'LEDGER-BLE-CONNECT-ID', + deviceId: '', + name: 'Ledger Nano X', + deviceType: 'unknown', + uuid: '', + } as never, + features: { + vendor: 'ledger', + deviceId: 'LEDGER-FEATURES-DEVICE-ID', + } as never, + isThirdParty: true, + }); + + expect(rawDeviceId).toBe('LEDGER-FEATURES-DEVICE-ID'); + }); + it('rejects explicit BLE devices when connectId is empty', () => { expect(() => mapThirdPartyDeviceToSearchDevice({ diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.preInitializeDeviceForSign.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.preInitializeDeviceForSign.test.ts index 286bbb152bcd..762514bbd21a 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.preInitializeDeviceForSign.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.preInitializeDeviceForSign.test.ts @@ -87,12 +87,18 @@ function buildDevice(vendor: EHardwareVendor): IDBDevice { } as IDBDevice; } -function buildService(device: IDBDevice) { +function buildService( + device: IDBDevice, + deviceCommonParams: { + passphraseState?: string; + useEmptyPassphrase?: boolean; + } = { + passphraseState: 'PASSPHRASE_STATE', + }, +) { const getWalletDeviceParams = jest.fn(async () => ({ dbDevice: device, - deviceCommonParams: { - passphraseState: 'PASSPHRASE_STATE', - }, + deviceCommonParams, })); const service = new ServiceHardware({ backgroundApi: { @@ -145,4 +151,21 @@ describe('ServiceHardware.preInitializeDeviceForSign', () => { passphraseState: 'PASSPHRASE_STATE', }); }); + + it('pre-initializes a standard wallet without passphraseState', async () => { + const { preInitialize, service } = buildService( + buildDevice(EHardwareVendor.onekey), + { + passphraseState: undefined, + useEmptyPassphrase: true, + }, + ); + + await service.preInitializeDeviceForSign({ walletId: 'hw-standard' }); + + expect(preInitialize).toHaveBeenCalledWith('USB_ID', { + passphraseState: undefined, + useEmptyPassphrase: true, + }); + }); }); diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2DeviceManagement.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2DeviceManagement.test.ts new file mode 100644 index 000000000000..8ccbc31f9187 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2DeviceManagement.test.ts @@ -0,0 +1,1452 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Jest mock functions do not use this binding. */ +import { DEVICE, LOG_EVENT, UI_EVENT, UI_REQUEST } from '@onekeyfe/hd-core'; +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { + LogLevel, + NativeLogger, +} from '@onekeyhq/shared/src/modules3rdParty/react-native-file-logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EHardwareCallContext } from '@onekeyhq/shared/types/device'; +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; + +import localDb from '../../dbs/local/localDb'; +import { hardwareUiStateAtom } from '../../states/jotai/atoms'; + +import ServiceHardware from './ServiceHardware'; +import serviceHardwareUtils from './serviceHardwareUtils'; + +import type { IBackgroundApi } from '../../apis/IBackgroundApi'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + HardwareDeviceStateUpdate: 'HardwareDeviceStateUpdate', + SyncDeviceLabelToWalletName: 'SyncDeviceLabelToWalletName', + WalletUpdate: 'WalletUpdate', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock( + '@onekeyhq/shared/src/modules3rdParty/react-native-file-logger', + () => ({ + LogLevel: { Debug: 0, Info: 1, Warning: 2, Error: 3 }, + NativeLogger: { write: jest.fn() }, + }), +); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isDesktop: true, + isDev: true, + isJest: true, + isNative: false, + isSupportDesktopBle: false, + }, +})); + +jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ + __esModule: true, + DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, + T1_HOME_SCREEN_DEFAULT_IMAGES: [], + default: {}, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getDeviceByQuery: jest.fn(), + updateDeviceState: jest.fn(), + }, +})); + +jest.mock('../../dbs/simple/simpleDb', () => ({ + __esModule: true, + default: { + appStatus: { + getRawData: jest.fn().mockResolvedValue({}), + setRawData: jest.fn().mockResolvedValue({}), + }, + }, +})); + +jest.mock('../../states/jotai/atoms', () => { + const { EHardwareUiStateAction: HardwareUiStateAction } = jest.requireActual( + '@onekeyhq/shared/types/hardwareUi', + ); + return { + EHardwareUiStateAction: HardwareUiStateAction, + hardwareForceTransportAtom: { + get: jest.fn(async () => ({ forceTransportType: undefined })), + }, + hardwareUiStateAtom: { + set: jest.fn(async () => undefined), + }, + hardwareUiStateCompletedAtom: { + set: jest.fn(async () => undefined), + }, + settingsPersistAtom: {}, + }; +}); + +const createService = ({ + unlocked, + mode = 'normal', + passphraseState = 'PRO2_PASSPHRASE_STATE', +}: { + unlocked: boolean; + mode?: 'normal' | 'bootloader' | 'romloader'; + passphraseState?: string | null; +}) => { + const state = { + revision: 1, + protocol: 'V2', + identity: { + deviceId: 'PRO2_DEVICE_ID', + serialNo: 'PRO2_SERIAL', + label: 'OneKey Pro 2', + bleName: 'Pro2 6136', + displayName: 'OneKey Pro 2', + deviceType: EDeviceType.Pro2, + }, + status: { mode, unlocked }, + settings: { language: 'en-US' }, + versions: { firmware: '1.0.0' }, + }; + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_USB', + connectProtocol: 'V2', + deviceStateInfo: state, + } as never); + const getDeviceState = jest.fn().mockResolvedValue({ + success: true, + payload: state, + }); + const getFeatures = jest.fn().mockResolvedValue({ + success: true, + payload: { protocol: 'V1', label: 'SDK legacy projection' }, + }); + const openWalletSession = jest.fn().mockImplementation((_connectId, params) => + Promise.resolve({ + success: true, + payload: { + deviceId: 'PRO2_DEVICE_ID', + walletType: params.mode === 'select-hidden' ? 'hidden' : 'standard', + passphraseState: + params.mode === 'select-hidden' + ? passphraseState + : 'PRO2_STANDARD_STATE', + }, + }), + ); + const getPassphraseState = jest.fn().mockResolvedValue({ + success: true, + payload: 'V1_PASSPHRASE_STATE', + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + getDeviceState, + getFeatures, + openWalletSession, + getPassphraseState, + } as unknown as Awaited>); + + return { + service, + getDeviceState, + getFeatures, + openWalletSession, + getPassphraseState, + state, + }; +}; + +describe('ServiceHardware SDK debug logging', () => { + const mutablePlatformEnv = platformEnv as { + isDesktop: boolean; + isDev: boolean; + isNative: boolean; + }; + const originalPlatformEnv = { + isDesktop: mutablePlatformEnv.isDesktop, + isDev: mutablePlatformEnv.isDev, + isNative: mutablePlatformEnv.isNative, + }; + + beforeEach(() => { + mutablePlatformEnv.isDesktop = true; + mutablePlatformEnv.isDev = true; + mutablePlatformEnv.isNative = false; + jest.mocked(NativeLogger.write).mockClear(); + }); + + afterEach(() => { + mutablePlatformEnv.isDesktop = originalPlatformEnv.isDesktop; + mutablePlatformEnv.isDev = originalPlatformEnv.isDev; + mutablePlatformEnv.isNative = originalPlatformEnv.isNative; + jest.restoreAllMocks(); + }); + + const registerSdkDebugLogListener = async ({ + showSdkDebugLogs, + }: { + showSdkDebugLogs: boolean; + }) => { + const listeners = new Map void>(); + const instance = { + on: jest.fn((event: string, listener: (payload: unknown) => void) => { + listeners.set(event, listener); + }), + }; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const registerSdkEvents = service.registerSdkEvents.bind(service) as ( + sdkInstance: never, + options: { showSdkDebugLogs: boolean }, + ) => Promise; + + await registerSdkEvents(instance as never, { showSdkDebugLogs }); + return listeners; + }; + + it('writes every SDK log event to the native sink when native debug logging is enabled', async () => { + mutablePlatformEnv.isDesktop = false; + mutablePlatformEnv.isNative = true; + const listeners = await registerSdkDebugLogListener({ + showSdkDebugLogs: true, + }); + + listeners.get(LOG_EVENT)?.({ + event: LOG_EVENT, + type: 'log', + payload: ['DevicePool', 'scan started'], + }); + + expect(NativeLogger.write).toHaveBeenCalledWith( + LogLevel.Info, + '[HardwareSDK][bg] DevicePool scan started', + ); + }); + + it('writes every SDK log event to the Desktop console when Desktop debug logging is enabled', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + const listeners = await registerSdkDebugLogListener({ + showSdkDebugLogs: true, + }); + + listeners.get(LOG_EVENT)?.({ + event: LOG_EVENT, + type: 'log', + payload: ['DevicePool', 'scan started'], + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + '[HardwareSDK][bg] DevicePool scan started', + ); + expect(NativeLogger.write).not.toHaveBeenCalled(); + }); + + it('does not write SDK log events when Desktop debug logging is disabled', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + const listeners = await registerSdkDebugLogListener({ + showSdkDebugLogs: false, + }); + + listeners.get(LOG_EVENT)?.({ + event: LOG_EVENT, + type: 'log', + payload: ['DevicePool', 'scan started'], + }); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(NativeLogger.write).not.toHaveBeenCalled(); + }); +}); + +describe('ServiceHardware wallet session compatibility', () => { + it('skips unavailable Pro2 firmware attestation', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const getSDKInstanceSpy = jest.spyOn(service, 'getSDKInstance'); + + await expect( + service.firmwareAuthenticate({ + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + } as never, + }), + ).resolves.toEqual( + expect.objectContaining({ + skipVerification: true, + verified: false, + }), + ); + expect(getSDKInstanceSpy).not.toHaveBeenCalled(); + }); + + it('does not require unavailable Pro2 attestation before wallet creation', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + + await expect( + service.shouldAuthenticateFirmware({ + device: { + connectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Pro2, + } as never, + }), + ).resolves.toBe(false); + }); + + it('uses the GetFeatures-only state scope for Classic-family firmware verification', async () => { + const { service, state } = createService({ unlocked: true }); + state.protocol = 'V1'; + state.identity.deviceType = EDeviceType.Classic1s; + service.getDeviceState = jest.fn().mockResolvedValue({ + ...state, + versions: { ...state.versions, se: '1.1.0.2' }, + } as never); + + await expect( + service.getFirmwareVerificationFeatures({ + connectId: 'CLASSIC', + deviceType: EDeviceType.Classic1s, + }), + ).resolves.toMatchObject({ + onekey_firmware_version: '1.0.0', + onekey_se01_version: '1.1.0.2', + }); + + expect(service.getDeviceState).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + params: { scope: 'runtime' }, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + }); + + it('opens the Protocol V2 wallet selector after device unlock', async () => { + const { service, openWalletSession, getPassphraseState } = createService({ + unlocked: false, + }); + + await expect( + service.getPassphraseStateBase({ + connectId: 'PRO2_USB', + forceInputPassphrase: true, + }), + ).resolves.toBe('PRO2_PASSPHRASE_STATE'); + + expect(openWalletSession).toHaveBeenCalledWith('PRO2_USB', { + mode: 'select-hidden', + }); + expect(getPassphraseState).not.toHaveBeenCalled(); + }); + + it('rejects a hidden-wallet response without passphraseState', async () => { + const { service, openWalletSession, getPassphraseState } = createService({ + unlocked: true, + passphraseState: null, + }); + + await expect( + service.getPassphraseStateBase({ + connectId: 'PRO2_USB', + forceInputPassphrase: true, + }), + ).rejects.toThrow( + 'Protocol V2 hidden wallet response is missing passphraseState', + ); + + expect(openWalletSession).toHaveBeenCalledWith('PRO2_USB', { + mode: 'select-hidden', + }); + expect(getPassphraseState).not.toHaveBeenCalled(); + }); + + it('uses walletType to keep a Protocol V2 standard wallet out of hidden-wallet storage', async () => { + const { service, openWalletSession, getPassphraseState } = createService({ + unlocked: true, + passphraseState: null, + }); + + await expect( + service.getPassphraseStateBase({ + connectId: 'PRO2_USB', + forceInputPassphrase: false, + useEmptyPassphrase: true, + }), + ).resolves.toBeUndefined(); + + expect(openWalletSession).toHaveBeenCalledWith('PRO2_USB', { + mode: 'standard', + }); + expect(getPassphraseState).not.toHaveBeenCalled(); + }); + + it('fails closed when the loaded SDK does not expose the Protocol V2 wallet session API', async () => { + const { service, getDeviceState, getPassphraseState } = createService({ + unlocked: true, + }); + service.getSDKInstance = jest.fn().mockResolvedValue({ + getDeviceState, + getPassphraseState, + } as unknown as Awaited>); + + await expect( + service.getPassphraseStateBase({ + connectId: 'PRO2_USB', + forceInputPassphrase: true, + }), + ).rejects.toThrow( + 'Protocol V2 wallet session API is unavailable in the loaded hardware SDK', + ); + + expect(getPassphraseState).not.toHaveBeenCalled(); + }); + + it('restores the Protocol V1 constraint for getPassphraseState from the device database', async () => { + const { service, openWalletSession, getPassphraseState } = createService({ + unlocked: true, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValueOnce({ + id: 'db-classic-device-1', + connectId: 'CLASSIC', + connectProtocol: 'V1', + deviceStateInfo: { protocol: 'V1' }, + } as never); + + await expect( + service.getPassphraseStateBase({ + connectId: 'CLASSIC', + forceInputPassphrase: true, + useEmptyPassphrase: true, + }), + ).resolves.toBe('V1_PASSPHRASE_STATE'); + + expect(getPassphraseState).toHaveBeenCalledWith('CLASSIC', { + initSession: true, + useEmptyPassphrase: true, + connectProtocol: 'V1', + }); + expect(openWalletSession).not.toHaveBeenCalled(); + }); + + it('does not detect the protocol while opening a wallet session', async () => { + const { service, getDeviceState, openWalletSession } = createService({ + unlocked: true, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValue(undefined); + + await expect( + service.getPassphraseStateBase({ + connectId: 'NEW_PRO2', + forceInputPassphrase: true, + }), + ).rejects.toThrow('Hardware connect protocol is unavailable'); + + expect(getDeviceState).not.toHaveBeenCalled(); + expect(openWalletSession).not.toHaveBeenCalled(); + }); +}); + +describe('ServiceHardware.getDeviceState', () => { + it('does not detect or infer the protocol during a normal device-state call', async () => { + const { service, getDeviceState } = createService({ unlocked: false }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValueOnce({ + id: 'db-device-without-protocol', + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + } as never); + + await expect( + service.getDeviceState({ connectId: 'PRO2_USB' }), + ).rejects.toThrow('Hardware connect protocol is unavailable'); + + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('queries the live canonical SDK state', async () => { + const { service, getDeviceState, state } = createService({ + unlocked: false, + }); + + await expect( + service.getDeviceState({ connectId: 'ORIGINAL_ID' }), + ).resolves.toBe(state); + + expect(getDeviceState).toHaveBeenCalledWith('PRO2_USB', { + connectProtocol: 'V2', + }); + }); + + it('forwards a semantic scope through the same state API', async () => { + const { service, getDeviceState } = createService({ unlocked: false }); + + await service.getDeviceState({ + connectId: 'PRO2', + params: { scope: 'firmware' }, + }); + + expect(getDeviceState).toHaveBeenCalledWith('PRO2_USB', { + connectProtocol: 'V2', + scope: 'firmware', + }); + }); + + it('projects legacy App features from DeviceState without calling SDK getFeatures', async () => { + const { service, getDeviceState } = createService({ unlocked: true }); + + await expect( + service.getFeaturesWithoutCache({ connectId: 'PRO2' }), + ).resolves.toMatchObject({ + deviceId: 'PRO2_DEVICE_ID', + label: 'OneKey Pro 2', + }); + expect(getDeviceState).toHaveBeenCalledWith('PRO2', { + connectProtocol: 'V2', + }); + }); + + it('keeps the x-branch BLE-only result contract while using Protocol V2 state API', async () => { + const { service, getDeviceState, getFeatures } = createService({ + unlocked: true, + }); + getDeviceState.mockResolvedValue({ + success: true, + payload: null, + } as never); + + await expect( + service.getFeaturesWithoutCache({ + connectId: 'PRO2', + params: { + connectProtocol: 'V2', + retryCount: 0, + onlyConnectBleDevice: true, + }, + }), + ).resolves.toBeNull(); + + expect(getDeviceState).toHaveBeenCalledWith('PRO2', { + connectProtocol: 'V2', + retryCount: 0, + onlyConnectBleDevice: true, + }); + expect(getFeatures).not.toHaveBeenCalled(); + }); + + it('delegates Protocol V1 compatibility projection to the SDK', async () => { + const { service, getDeviceState, getFeatures } = createService({ + unlocked: true, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.getDeviceByQuery).mockResolvedValueOnce({ + id: 'db-classic-device-1', + connectId: 'CLASSIC', + deviceStateInfo: { protocol: 'V1' }, + } as never); + getDeviceState.mockResolvedValue({ + success: true, + payload: { + schemaVersion: 1, + revision: 2, + updatedAt: 2, + protocol: 'V1', + identity: { + deviceId: 'CLASSIC_DEVICE_ID', + serialNo: 'CLASSIC_SERIAL', + label: 'Classic Wallet', + bleName: null, + displayName: 'Classic Wallet', + deviceType: EDeviceType.Classic1s, + firmwareType: 'universal', + model: '1', + vendor: 'onekey.so', + }, + status: { mode: 'normal', initialized: true }, + settings: { language: 'en-US' }, + versions: { firmware: '3.11.0', se01Boot: '1.2.0' }, + verification: { + firmwareBuildId: 'firmware-build', + se01BootHash: 'abcd', + }, + capabilities: [], + }, + } as never); + getFeatures.mockResolvedValue({ + success: true, + payload: { + protocol: 'V1', + deviceType: EDeviceType.Classic1s, + onekey_firmware_version: '3.11.0', + onekey_firmware_build_id: 'firmware-build', + onekey_se01_boot_version: '1.2.0', + onekey_se01_boot_hash: 'abcd', + }, + }); + + await expect( + service.getFeaturesWithoutCache({ connectId: 'CLASSIC' }), + ).resolves.toMatchObject({ + protocol: 'V1', + deviceType: EDeviceType.Classic1s, + onekey_firmware_version: '3.11.0', + onekey_firmware_build_id: 'firmware-build', + onekey_se01_boot_version: '1.2.0', + onekey_se01_boot_hash: 'abcd', + }); + expect(getFeatures).toHaveBeenCalledWith('CLASSIC', { + connectProtocol: 'V1', + }); + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('projects romloader mode to both legacy bootloader flags', async () => { + const { service } = createService({ unlocked: false, mode: 'romloader' }); + + await expect( + service.getFeaturesWithoutCache({ connectId: 'PRO2' }), + ).resolves.toMatchObject({ + bootloaderMode: true, + bootloader_mode: true, + }); + }); + + it('detects romloader as a legacy bootloader device', async () => { + const { service } = createService({ unlocked: false, mode: 'romloader' }); + + await expect( + service.getFeaturesWithoutCache({ + connectId: 'PRO2', + params: { detectBootloaderDevice: true }, + }), + ).rejects.toBeDefined(); + }); +}); + +describe('ServiceHardware.getDeviceManagementSnapshot', () => { + it('refreshes readable settings on the initial device-details load', async () => { + const { service, getDeviceState } = createService({ + unlocked: true, + }); + + await service.getDeviceManagementSnapshot({ connectId: 'PRO2' }); + + expect(getDeviceState).toHaveBeenCalledTimes(1); + expect(getDeviceState).toHaveBeenCalledWith('PRO2_USB', { + connectProtocol: 'V2', + scope: 'settings', + }); + }); + + it('falls back to live runtime when settings cannot be read', async () => { + const { service } = createService({ unlocked: false }); + const baseState = { + protocol: 'V2', + identity: { serialNo: 'PRO2_SERIAL', displayName: 'OneKey Pro 2' }, + status: { mode: 'normal', unlocked: false }, + settings: {}, + versions: {}, + }; + const getDeviceState = jest + .fn() + .mockRejectedValueOnce(new Error('Settings unavailable')) + .mockResolvedValueOnce(baseState); + service.getDeviceState = getDeviceState; + + await expect( + service.getDeviceManagementSnapshot({ connectId: 'PRO2' }), + ).resolves.toEqual({ state: baseState }); + expect(getDeviceState).toHaveBeenNthCalledWith(1, { + connectId: 'PRO2_USB', + params: { scope: 'settings' }, + hardwareCallContext: 'user_interaction_no_ble_dialog', + silentMode: true, + }); + expect(getDeviceState).toHaveBeenNthCalledWith(2, { + connectId: 'PRO2_USB', + params: undefined, + hardwareCallContext: 'user_interaction_no_ble_dialog', + silentMode: true, + }); + }); + + it('does not coalesce settings and firmware refreshes for the same device', async () => { + const { service, state } = createService({ unlocked: true }); + let resolveFirstSettings: ((value: typeof state) => void) | undefined; + let settingsCalls = 0; + const getDeviceState = jest.fn( + ({ params }: { params?: { scope?: string } }) => { + if (params?.scope === 'settings') { + settingsCalls += 1; + if (settingsCalls === 1) { + return new Promise((resolve) => { + resolveFirstSettings = resolve; + }); + } + } + return Promise.resolve(state); + }, + ); + service.getDeviceState = getDeviceState as never; + + const settingsRequest = service.getDeviceManagementSnapshot({ + connectId: 'PRO2', + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + const firmwareRequest = service.getDeviceManagementSnapshot({ + connectId: 'PRO2', + refreshInfo: true, + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(getDeviceState).toHaveBeenCalledWith( + expect.objectContaining({ params: { scope: 'firmware' } }), + ); + resolveFirstSettings?.(state); + await Promise.all([settingsRequest, firmwareRequest]); + }); +}); + +describe('ServiceHardware SDK DeviceState synchronization', () => { + it('普通断连后保留已确认协议,供重连继续固定使用', async () => { + const listeners = new Map void>(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn((event: string, listener: (payload: unknown) => void) => + listeners.set(event, listener), + ), + } as never); + const internals = service as unknown as { + deviceProtocolByConnectId: Map; + rememberDeviceProtocol: (params: { + connectIds: string[]; + protocol: 'V1' | 'V2'; + }) => Promise; + }; + await internals.rememberDeviceProtocol({ + connectIds: ['PRO2_USB'], + protocol: 'V2', + }); + + listeners.get(DEVICE.DISCONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + + expect(internals.deviceProtocolByConnectId.get('PRO2_USB')).toBe('V2'); + }); + + it('deprecates wallets immediately after a successful device wipe', async () => { + const updateWalletsDeprecatedState = jest.fn().mockResolvedValue(true); + const getWalletDevice = jest.fn().mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_USB', + deviceId: 'OLD_DEVICE_ID', + }); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + getWalletDevice, + getAllHwQrWalletWithDevice: jest.fn().mockResolvedValue({ + 'hw-wallet-1': { + wallet: { + id: 'hw-wallet-1', + associatedDevice: 'db-device-1', + }, + device: { id: 'db-device-1' }, + }, + }), + updateWalletsDeprecatedState, + }, + } as unknown as IBackgroundApi, + }); + service.deviceSettingsManager.wipeDevice = jest + .fn() + .mockResolvedValue({ message: 'Success' }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on this binding. + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockClear(); + + await expect( + service.wipeDevice({ + walletId: 'hw-wallet-1', + connectId: 'PRO2_USB', + }), + ).resolves.toEqual({ message: 'Success' }); + + expect(getWalletDevice).toHaveBeenCalledWith({ walletId: 'hw-wallet-1' }); + expect(updateWalletsDeprecatedState).toHaveBeenCalledWith({ + willUpdateDeprecateMap: { 'hw-wallet-1': true }, + }); + expect(emitMock).toHaveBeenCalledWith( + EAppEventBusNames.WalletUpdate, + undefined, + ); + }); + + it('applies async hardware UI events in SDK arrival order', async () => { + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + ( + event: string, + listener: (payload: unknown) => void | Promise, + ) => { + listeners.set(event, listener); + }, + ), + }; + const setHardwareUiStateMock = jest.mocked(hardwareUiStateAtom.set); + setHardwareUiStateMock.mockClear(); + const getDeviceByQueryMock = jest.mocked(localDb.getDeviceByQuery); + getDeviceByQueryMock.mockReset(); + let resolvePinDevice: + | ((value: Awaited>) => void) + | undefined; + getDeviceByQueryMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePinDevice = resolve; + }), + ); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents(instance as never); + const listener = listeners.get(UI_EVENT); + + const pinTask = listener?.({ + type: UI_REQUEST.REQUEST_PIN, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + features: { mode: 'normal' }, + }, + }, + }); + const passphraseTask = listener?.({ + type: UI_REQUEST.REQUEST_PASSPHRASE, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + features: { mode: 'normal' }, + }, + }, + }); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(setHardwareUiStateMock).not.toHaveBeenCalled(); + + resolvePinDevice?.({ + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + settings: { inputPinOnSoftware: false }, + } as never); + await Promise.all([pinTask, passphraseTask]); + + const actions = setHardwareUiStateMock.mock.calls.map(([updater]) => + typeof updater === 'function' + ? updater(undefined)?.action + : updater?.action, + ); + expect(actions).toEqual([ + EHardwareUiStateAction.EnterPinOnDevice, + EHardwareUiStateAction.REQUEST_PASSPHRASE, + ]); + }); + + it('forwards device transfer progress to the hardware UI state', async () => { + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + ( + event: string, + listener: (payload: unknown) => void | Promise, + ) => { + listeners.set(event, listener); + }, + ), + }; + const setHardwareUiStateMock = jest.mocked(hardwareUiStateAtom.set); + setHardwareUiStateMock.mockClear(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents(instance as never); + + await listeners.get(UI_EVENT)?.({ + type: UI_REQUEST.DEVICE_PROGRESS, + payload: { + progress: 42, + transferredBytes: 420, + totalBytes: 1000, + rateBytesPerSecond: 210, + elapsedMs: 2000, + }, + }); + + const updater = setHardwareUiStateMock.mock.calls.at(-1)?.[0]; + const state = typeof updater === 'function' ? updater(undefined) : updater; + expect(state).toMatchObject({ + action: EHardwareUiStateAction.DEVICE_PROGRESS, + payload: { + deviceProgress: { + progress: 42, + transferredBytes: 420, + totalBytes: 1000, + rateBytesPerSecond: 210, + elapsedMs: 2000, + }, + }, + }); + }); + + it('clears device progress when the matching Protocol V2 interaction closes', async () => { + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + ( + event: string, + listener: (payload: unknown) => void | Promise, + ) => { + listeners.set(event, listener); + }, + ), + }; + const setHardwareUiStateMock = jest.mocked(hardwareUiStateAtom.set); + setHardwareUiStateMock.mockClear(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents(instance as never); + const listener = listeners.get(UI_EVENT); + + await listener?.({ + type: UI_REQUEST.DEVICE_PROGRESS, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + interaction: { + interactionId: 'interaction-progress', + phaseId: 'interaction-progress:phase-1', + sequence: 1, + phase: 'processing', + transition: 'start', + protocol: 'V2', + }, + progress: 100, + }, + }); + await listener?.({ + type: UI_REQUEST.CLOSE_UI_WINDOW, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + interactionId: 'interaction-progress', + phaseId: 'interaction-progress:phase-1', + sequence: 2, + phase: 'processing', + transition: 'finish', + outcome: 'succeeded', + protocol: 'V2', + }, + }); + + expect(setHardwareUiStateMock).toHaveBeenLastCalledWith(undefined); + }); + + it('persists and broadcasts canonical SDK state events', async () => { + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + ( + event: string, + listener: (payload: unknown) => void | Promise, + ) => { + listeners.set(event, listener); + }, + ), + }; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const hardwareLogSpy = jest + .spyOn(serviceHardwareUtils, 'hardwareLog') + .mockImplementation(() => undefined); + await service.registerSdkEvents(instance as never); + + const state = { + revision: 2, + protocol: 'V2', + identity: { + deviceId: 'PRO2_DEVICE_ID', + serialNo: 'PRO2_SERIAL', + label: 'Renamed Pro 2', + displayName: 'Renamed Pro 2', + }, + }; + await listeners.get('state')?.({ + connectId: 'PRO2_USB', + state, + revision: 2, + source: 'apply-settings', + changedKeys: ['identity.label'], + }); + + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDeviceState).toHaveBeenCalledWith( + expect.objectContaining({ + changedKeys: ['identity.label'], + connectId: 'PRO2_USB', + revision: 2, + sdkEventSequence: 1, + sdkInstanceEpoch: 1, + source: 'apply-settings', + state, + }), + ); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + expect.objectContaining({ state, revision: 2 }), + ); + expect(hardwareLogSpy).toHaveBeenCalledWith('device state update', { + changedKeys: ['identity.label'], + revision: 2, + source: 'apply-settings', + }); + expect(JSON.stringify(hardwareLogSpy.mock.calls)).not.toContain( + 'PRO2_SERIAL', + ); + hardwareLogSpy.mockRestore(); + }); + + it('still broadcasts the in-memory state when persistence fails', async () => { + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const updateDeviceStateMock = jest.mocked(localDb.updateDeviceState); + updateDeviceStateMock.mockReset(); + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + (event: string, listener: (payload: unknown) => void | Promise) => + listeners.set(event, listener), + ), + }; + updateDeviceStateMock.mockRejectedValueOnce(new Error('DB unavailable')); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents(instance as never); + const event = { + connectId: 'PRO2_USB', + state: { + revision: 2, + updatedAt: 2, + protocol: 'V2', + identity: { deviceId: 'device-1', serialNo: 'serial-1' }, + }, + revision: 2, + source: 'apply-settings', + changedKeys: ['identity.label'], + }; + + await expect(listeners.get('state')?.(event)).resolves.toBeUndefined(); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + event, + ); + }); + + it('does not broadcast an event rejected as stale by persistence', async () => { + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const updateDeviceStateMock = jest.mocked(localDb.updateDeviceState); + updateDeviceStateMock.mockReset(); + updateDeviceStateMock.mockResolvedValueOnce({ + kind: 'ignored', + reason: 'stale', + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockClear(); + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn( + (event: string, listener: (payload: unknown) => void | Promise) => + listeners.set(event, listener), + ), + } as never); + const event = { + connectId: 'PRO2_USB', + revision: 1, + changedKeys: ['status.unlocked'], + source: 'device-status', + state: { + revision: 1, + updatedAt: 1, + protocol: 'V2', + identity: { serialNo: 'PRO2_SERIAL', deviceId: 'PRO2_DEVICE_ID' }, + }, + }; + + await listeners.get('state')?.(event); + + expect(emitMock).not.toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + event, + ); + expect( + ( + service as unknown as { + deviceProtocolByConnectId: Map; + } + ).deviceProtocolByConnectId.has('PRO2_USB'), + ).toBe(false); + }); + + it('cleans the device event queue when an App subscriber throws', async () => { + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(localDb.updateDeviceState).mockResolvedValueOnce({ + kind: 'updated', + deviceDbId: 'db-device-1', + state: {} as never, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockImplementationOnce(() => { + throw new OneKeyLocalError('Subscriber failed'); + }); + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn( + (event: string, listener: (payload: unknown) => void | Promise) => + listeners.set(event, listener), + ), + } as never); + + await expect( + listeners.get('state')?.({ + connectId: 'PRO2_USB', + revision: 2, + changedKeys: ['status.unlocked'], + source: 'device-status', + state: { + revision: 2, + updatedAt: 2, + protocol: 'V2', + identity: { + serialNo: 'PRO2_SERIAL', + deviceId: 'PRO2_DEVICE_ID', + }, + }, + }), + ).resolves.toBeUndefined(); + expect( + ( + service as unknown as { + deviceStateSyncQueues: Map>; + } + ).deviceStateSyncQueues.size, + ).toBe(0); + }); + + it('deprecates the old wallet and suppresses a reset identity event', async () => { + // oxlint-disable-next-line typescript/unbound-method -- Jest mocks do not depend on this binding. + const updateDeviceStateMock = jest.mocked(localDb.updateDeviceState); + updateDeviceStateMock.mockReset(); + updateDeviceStateMock.mockResolvedValueOnce({ + kind: 'identity-mismatch', + deviceDbId: 'db-device-1', + currentDeviceId: 'OLD_DEVICE_ID', + incomingDeviceId: 'NEW_DEVICE_ID', + } as never); + // oxlint-disable-next-line typescript/unbound-method -- Jest mocks do not depend on this binding. + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockClear(); + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const updateWalletsDeprecatedState = jest.fn().mockResolvedValue(true); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + getAllHwQrWalletWithDevice: jest.fn().mockResolvedValue({ + 'hw-wallet-1': { + wallet: { + id: 'hw-wallet-1', + associatedDevice: 'db-device-1', + }, + device: { id: 'db-device-1' }, + }, + }), + updateWalletsDeprecatedState, + }, + } as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn( + (event: string, listener: (payload: unknown) => void | Promise) => + listeners.set(event, listener), + ), + } as never); + const event = { + connectId: 'PRO2_USB', + revision: 4, + changedKeys: ['identity.deviceId'], + state: { + revision: 4, + updatedAt: 4, + identity: { + serialNo: 'PRO2_SERIAL', + deviceId: 'NEW_DEVICE_ID', + }, + }, + }; + + await listeners.get('state')?.(event); + + expect(updateWalletsDeprecatedState).toHaveBeenCalledWith({ + willUpdateDeprecateMap: { 'hw-wallet-1': true }, + }); + expect(emitMock).toHaveBeenCalledWith( + EAppEventBusNames.WalletUpdate, + undefined, + ); + expect(emitMock).not.toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + event, + ); + }); + + it('serializes state persistence in SDK event order', async () => { + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const updateDeviceStateMock = jest.mocked(localDb.updateDeviceState); + updateDeviceStateMock.mockReset(); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockClear(); + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + const instance = { + on: jest.fn( + (event: string, listener: (payload: unknown) => void | Promise) => + listeners.set(event, listener), + ), + }; + let resolveFirst: + | ((value: { kind: 'ignored'; reason: 'device-not-found' }) => void) + | undefined; + updateDeviceStateMock.mockImplementationOnce( + () => + new Promise<{ + kind: 'ignored'; + reason: 'device-not-found'; + }>((resolve) => { + resolveFirst = resolve; + }), + ); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + await service.registerSdkEvents(instance as never); + const listener = listeners.get('state'); + const first = listener?.({ + connectId: 'PRO2_USB', + state: { + revision: 1, + updatedAt: 1, + identity: { serialNo: 'PRO2_SERIAL' }, + }, + revision: 1, + source: 'device-info', + changedKeys: ['identity.bleName'], + }); + const second = listener?.({ + connectId: 'PRO2_BLE', + state: { + revision: 2, + updatedAt: 2, + identity: { serialNo: 'PRO2_SERIAL' }, + }, + revision: 2, + source: 'apply-settings', + changedKeys: ['identity.label'], + }); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDeviceState).toHaveBeenCalledTimes(1); + expect(emitMock).not.toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + expect.anything(), + ); + resolveFirst?.({ kind: 'ignored', reason: 'device-not-found' }); + await Promise.all([first, second]); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDeviceState).toHaveBeenCalledTimes(2); + expect(emitMock).toHaveBeenCalledTimes(2); + }); + + it('does not duplicate label persistence after the SDK state event', async () => { + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + getWalletSafe: jest.fn().mockResolvedValue({ + associatedDevice: 'db-device-1', + name: 'Wallet', + }), + }, + } as unknown as IBackgroundApi, + }); + service.deviceSettingsManager.setDeviceLabel = jest + .fn() + .mockResolvedValue({ message: 'Success' }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + jest.mocked(appEventBus.emit).mockClear(); + await service.setDeviceLabel({ + walletId: 'wallet-1', + label: 'Renamed Pro 2', + }); + + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(appEventBus.emit).not.toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + expect.anything(), + ); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.SyncDeviceLabelToWalletName, + expect.objectContaining({ label: 'Renamed Pro 2' }), + ); + }); +}); + +describe('ServiceHardware.fetchHardwareHomeScreen', () => { + it('uses Pro as the server device type for Pro 2', async () => { + const get = jest.fn().mockResolvedValue({ + data: { + data: [ + { + id: 'pro-wallpaper', + wallpaperType: 'default', + resType: 'system', + url: 'https://example.com/pro-wallpaper.png', + deviceTypes: [EDeviceType.Pro], + }, + ], + }, + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + Object.defineProperty(service, 'getClient', { + value: jest.fn().mockResolvedValue({ get }), + }); + + await expect( + service.fetchHardwareHomeScreen({ + deviceType: EDeviceType.Pro2, + serialNumber: 'PR9999999999', + firmwareVersion: '1.0.0', + }), + ).resolves.toEqual([ + { + id: 'pro-wallpaper', + wallpaperType: 'default', + resType: 'system', + url: 'https://example.com/pro-wallpaper.png', + screenHex: undefined, + nameHex: undefined, + }, + ]); + expect(get).toHaveBeenCalledWith('/utility/v1/wallet-homescreen/list', { + params: { + deviceType: EDeviceType.Pro, + serialNumber: 'PR9999999999', + firmwareVersion: '1.0.0', + }, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2Onboarding.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2Onboarding.test.ts new file mode 100644 index 000000000000..e314517f86a3 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2Onboarding.test.ts @@ -0,0 +1,113 @@ +import { + OnboardingPhase, + OnboardingSetupKind, + OnboardingSetupMethod, + OnboardingStep, +} from '@onekeyfe/hd-transport'; + +import ServiceHardware from './ServiceHardware'; + +import type { IBackgroundApi } from '../../apis/IBackgroundApi'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: {}, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isDesktop: true, + isJest: true, + isSupportDesktopBle: false, + }, +})); + +jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ + __esModule: true, + DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, + T1_HOME_SCREEN_DEFAULT_IMAGES: [], + default: {}, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getDeviceByQuery: jest.fn(), + }, +})); + +jest.mock('../../states/jotai/atoms', () => ({ + EHardwareUiStateAction: {}, + hardwareForceTransportAtom: { + get: jest.fn(async () => ({ forceTransportType: undefined })), + }, + hardwareUiStateAtom: {}, + hardwareUiStateCompletedAtom: {}, + settingsPersistAtom: {}, +})); + +describe('ServiceHardware.getPro2OnboardingStatus', () => { + it('uses the current Pro 2 onboarding protobuf contract', () => { + expect(OnboardingStep.ONBOARDING_STEP_DONE).toBe(5); + expect(OnboardingPhase.ONBOARDING_PHASE_SEEDCARD_BACKUP).toBe(13); + expect(OnboardingSetupKind.ONBOARDING_SETUP_KIND_RESTORE).toBe(3); + }); + + it('uses the compatible connect ID and forces Protocol V2', async () => { + const deviceGetOnboardingStatus = jest.fn().mockResolvedValue({ + success: true, + payload: { + step: OnboardingStep.ONBOARDING_STEP_SETUP, + phase: OnboardingPhase.ONBOARDING_PHASE_SETUP_CHOICE, + setup: { + kind: OnboardingSetupKind.ONBOARDING_SETUP_KIND_CHOICE, + method: OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_UNKNOWN, + }, + pin_set: true, + wallet_initialized: false, + }, + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_USB'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + deviceGetOnboardingStatus, + } as unknown as Awaited>); + + await expect( + service.getPro2OnboardingStatus({ connectId: 'ORIGINAL_ID' }), + ).resolves.toEqual({ + step: OnboardingStep.ONBOARDING_STEP_SETUP, + phase: OnboardingPhase.ONBOARDING_PHASE_SETUP_CHOICE, + setup: { + kind: OnboardingSetupKind.ONBOARDING_SETUP_KIND_CHOICE, + method: OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_UNKNOWN, + }, + pin_set: true, + wallet_initialized: false, + }); + + expect(deviceGetOnboardingStatus).toHaveBeenCalledWith('PRO2_USB', { + connectProtocol: 'V2', + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts index d85792922048..87f750650137 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts @@ -11,6 +11,7 @@ import { import { makeTimeoutPromise } from '@onekeyhq/shared/src/background/backgroundUtils'; import { HARDWARE_SDK_VERSION } from '@onekeyhq/shared/src/config/appConfig'; import { BTC_FIRST_TAPROOT_PATH } from '@onekeyhq/shared/src/consts/chainConsts'; +import { WALLET_TYPE_HW } from '@onekeyhq/shared/src/consts/dbConsts'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import * as deviceErrors from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; import { convertDeviceResponse } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; @@ -22,6 +23,7 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { checkBLEPermissions, checkBLEState, @@ -33,6 +35,10 @@ import { } from '@onekeyhq/shared/src/hardware/instance'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import { + LogLevel, + NativeLogger, +} from '@onekeyhq/shared/src/modules3rdParty/react-native-file-logger'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { checkIsDefined } from '@onekeyhq/shared/src/utils/assertUtils'; @@ -54,6 +60,7 @@ import type { IFirmwareReleasePayload, IHardwareCallContext, IOneKeyDeviceFeatures, + IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import { EHardwareCallContext, @@ -77,6 +84,12 @@ import ServiceBase from '../ServiceBase'; import { DeviceSettingsManager } from './DeviceSettingsManager'; import { HardwareConnectionManager } from './HardwareConnectionManager'; +import { + HardwareUiEventQueue, + createHardwareUiEventState, + reduceHardwareUiEventState, +} from './hardwareUiEventStateMachine'; +import { copyWalletSessionUiMetadata } from './hardwareUiPayloadUtils'; import { HardwareVerifyManager } from './HardwareVerifyManager'; import serviceHardwareUtils from './serviceHardwareUtils'; @@ -85,7 +98,6 @@ import type { IThirdPartyHardwareAdapter, } from './adapters/types'; import type { - IBaseDeviceProcessingParams, IChangePinParams, IDeviceHomeScreenConfig, IGetDeviceAdvanceSettingsParams, @@ -93,6 +105,7 @@ import type { IHardwareHomeScreenData, ISetAutoLockDelayMsParams, ISetAutoShutDownDelayMsParams, + ISetBrightnessParams, ISetDeviceHomeScreenParams, ISetDeviceLabelParams, ISetHapticFeedbackParams, @@ -117,21 +130,72 @@ import type { } from '../../states/jotai/atoms'; import type { IServiceBaseProps } from '../ServiceBase'; import type { IUpdateFirmwareWorkflowParams } from '../ServiceFirmwareUpdate/ServiceFirmwareUpdate'; +import type { IOneKeyHardwareOperationLease } from '../ServiceHardwareUI/HardwareProcessingManager'; import type { CommonParams, CoreApi, CoreMessage, + DeviceStateEvent, DeviceSupportFeaturesPayload, DeviceUploadResourceParams, Features, + GetDeviceStateParams, + Response as HardwareResponse, IDeviceType, KnownDevice, OnekeyFeatures, - Response, SearchDevice, UiEvent, + UiResponseEvent, } from '@onekeyfe/hd-core'; +import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared'; +import type { DeviceSessionPinType } from '@onekeyfe/hd-transport'; + +const DEVICE_PIN_ON_DEVICE_TYPES = new Set([ + EDeviceType.Touch, + EDeviceType.Pro, + EDeviceType.Pro2, +]); +const SKIP_APP_FIRMWARE_UPDATE_EVENT = true; +const MAX_PERSISTED_DEVICE_PROTOCOL_ENTRIES = 128; +const HARDWARE_CONNECT_PROTOCOL_MIGRATION_VERSION = 1; +const HARDWARE_SDK_DEBUG_LOG_PREFIX = '[HardwareSDK][bg]'; +const HARDWARE_CONNECT_PROTOCOL_UNAVAILABLE_MESSAGE = + 'Hardware connect protocol is unavailable. Reconnect the device through onboarding.'; + +function writeHardwareSdkDebugLog(message: string) { + if (!platformEnv.isDev) { + return; + } + + const formattedMessage = `${HARDWARE_SDK_DEBUG_LOG_PREFIX} ${message}`; + if (platformEnv.isNative) { + NativeLogger.write(LogLevel.Info, formattedMessage); + return; + } + + if (platformEnv.isDesktop) { + // eslint-disable-next-line no-console + console.log(formattedMessage); + } +} +type IProtocolAwareCoreApi = CoreApi & { + setDeviceConnectProtocol?: ( + connectId: string, + connectProtocol: HardwareConnectProtocol | undefined, + ) => void; +}; + +function isHardwareConnectProtocol( + protocol: unknown, +): protocol is HardwareConnectProtocol { + return protocol === 'V1' || protocol === 'V2'; +} + +/** + * @deprecated New code should use IDeviceGetStateOptions; retained for legacy Features compatibility. + */ export type IDeviceGetFeaturesOptions = { connectId: string | undefined; vendor?: EHardwareVendor; @@ -139,10 +203,90 @@ export type IDeviceGetFeaturesOptions = { silentMode?: boolean; params?: CommonParams & { allowEmptyConnectId?: boolean; + forceProtocolDetection?: boolean; }; hardwareCallContext?: IHardwareCallContext; }; +export type IDeviceGetStateOptions = Omit< + IDeviceGetFeaturesOptions, + 'params' +> & { + params?: GetDeviceStateParams & { + allowEmptyConnectId?: boolean; + }; +}; + +export type IDeviceManagementSnapshot = { + state: IOneKeyDeviceState; +}; + +export type IUploadPro2NftParams = { + connectId: string; + imageHex: string; + thumbnailHex: string; + title: string; + subtitle: string; + timestampMs?: number; +}; + +const nullableToUndefined = (value?: string | null) => value ?? undefined; + +const isOneKeyLoaderMode = (mode?: string | null) => + mode === EOneKeyDeviceMode.bootloader || mode === EOneKeyDeviceMode.romloader; + +const supportsDedicatedFirmwareFeatures = (deviceType: IDeviceType) => + deviceType === EDeviceType.Touch || + deviceType === EDeviceType.Pro || + deviceType === EDeviceType.Pro2; + +function buildOnekeyFeaturesFromState( + state: IOneKeyDeviceState, +): OnekeyFeatures { + const { verification: verify, versions } = state; + + return { + onekey_serial_no: state.identity.serialNo, + onekey_ble_name: state.identity.bleName || '', + onekey_firmware_version: nullableToUndefined(versions.firmware), + onekey_boot_version: nullableToUndefined(versions.bootloader), + onekey_board_version: nullableToUndefined(versions.board), + onekey_ble_version: nullableToUndefined(versions.ble), + onekey_firmware_hash: verify?.firmwareHash, + onekey_boot_hash: verify?.bootloaderHash, + onekey_board_hash: verify?.boardHash, + onekey_ble_hash: verify?.bleHash, + onekey_firmware_build_id: verify?.firmwareBuildId, + onekey_boot_build_id: verify?.bootloaderBuildId, + onekey_board_build_id: verify?.boardBuildId, + onekey_ble_build_id: verify?.bleBuildId, + onekey_se01_version: nullableToUndefined(versions.se01 ?? versions.se), + onekey_se02_version: nullableToUndefined(versions.se02), + onekey_se03_version: nullableToUndefined(versions.se03), + onekey_se04_version: nullableToUndefined(versions.se04), + onekey_se01_hash: verify?.se01Hash, + onekey_se02_hash: verify?.se02Hash, + onekey_se03_hash: verify?.se03Hash, + onekey_se04_hash: verify?.se04Hash, + onekey_se01_build_id: verify?.se01BuildId, + onekey_se02_build_id: verify?.se02BuildId, + onekey_se03_build_id: verify?.se03BuildId, + onekey_se04_build_id: verify?.se04BuildId, + onekey_se01_boot_version: nullableToUndefined(versions.se01Boot), + onekey_se02_boot_version: nullableToUndefined(versions.se02Boot), + onekey_se03_boot_version: nullableToUndefined(versions.se03Boot), + onekey_se04_boot_version: nullableToUndefined(versions.se04Boot), + onekey_se01_boot_hash: verify?.se01BootHash, + onekey_se02_boot_hash: verify?.se02BootHash, + onekey_se03_boot_hash: verify?.se03BootHash, + onekey_se04_boot_hash: verify?.se04BootHash, + onekey_se01_boot_build_id: verify?.se01BootBuildId, + onekey_se02_boot_build_id: verify?.se02BootBuildId, + onekey_se03_boot_build_id: verify?.se03BootBuildId, + onekey_se04_boot_build_id: verify?.se04BootBuildId, + }; +} + type IHandleLinuxWebUsbAccessDeniedErrorParams = { error?: unknown; }; @@ -167,6 +311,195 @@ const LINUX_UDEV_RULES_INSTALL_MAX_ATTEMPTS = 2; @backgroundClass() class ServiceHardware extends ServiceBase { + private deviceStateSyncQueues = new Map>(); + + private getDeviceStateSyncKeys(values: Array) { + const keys = values + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)); + return [...new Set(keys)]; + } + + async waitForDeviceStateSync({ + connectIds, + }: { + connectIds: Array; + }): Promise { + // SDK events are emitted before the corresponding call resolves. Yield once + // so split background runtimes can register the event persistence task. + await Promise.resolve(); + const queueKeys = this.getDeviceStateSyncKeys(connectIds); + let tasks = queueKeys + .map((key) => this.deviceStateSyncQueues.get(key)) + .filter((task): task is Promise => Boolean(task)); + while (tasks.length > 0) { + await Promise.all(new Set(tasks)); + tasks = queueKeys + .map((key) => this.deviceStateSyncQueues.get(key)) + .filter((task): task is Promise => Boolean(task)); + } + } + + private deviceProtocolByConnectId = new Map(); + + private connectProtocolMigrationPromise: Promise | undefined; + + private activeHardwareSDKInstance: IProtocolAwareCoreApi | undefined; + + private bindDeviceProtocolToSDK({ + connectId, + protocol, + instance = this.activeHardwareSDKInstance, + }: { + connectId?: string | null; + protocol?: string | null; + instance?: IProtocolAwareCoreApi; + }) { + if (!connectId || (protocol !== 'V1' && protocol !== 'V2')) { + return; + } + instance?.setDeviceConnectProtocol?.(connectId, protocol); + } + + private bindRememberedDeviceProtocols(instance: IProtocolAwareCoreApi) { + for (const [connectId, protocol] of this.deviceProtocolByConnectId) { + this.bindDeviceProtocolToSDK({ connectId, protocol, instance }); + } + } + + private async persistDeviceProtocols({ + connectIds, + protocol, + }: { + connectIds: string[]; + protocol: 'V1' | 'V2'; + }) { + const normalizedConnectIds = [ + ...new Set( + connectIds + .map((connectId) => connectId.trim().toLowerCase()) + .filter(Boolean), + ), + ]; + if (normalizedConnectIds.length === 0) { + return; + } + const updatedAt = Date.now(); + await simpleDb.appStatus.setRawData((value): ISimpleDBAppStatus => { + const protocolByConnectId = { + ...value?.hardwareConnectProtocolByConnectId, + }; + for (const connectId of normalizedConnectIds) { + protocolByConnectId[connectId] = { protocol, updatedAt }; + } + const boundedProtocolByConnectId = Object.fromEntries( + Object.entries(protocolByConnectId) + .toSorted(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, MAX_PERSISTED_DEVICE_PROTOCOL_ENTRIES), + ); + return { + ...value, + hardwareConnectProtocolByConnectId: boundedProtocolByConnectId, + }; + }); + } + + private async getPersistedDeviceProtocol(connectId: string) { + try { + const appStatus = await simpleDb.appStatus.getRawData(); + return appStatus?.hardwareConnectProtocolByConnectId?.[ + connectId.trim().toLowerCase() + ]?.protocol; + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'restore device protocol from simple db failed', + error, + ); + return undefined; + } + } + + private async runExistingDeviceConnectProtocolMigration(): Promise { + const appStatus = await simpleDb.appStatus.getRawData(); + if ( + (appStatus?.hardwareConnectProtocolMigrationVersion ?? 0) >= + HARDWARE_CONNECT_PROTOCOL_MIGRATION_VERSION + ) { + return; + } + + const [{ devices }, { wallets }] = await Promise.all([ + localDb.getAllDevices(), + localDb.getAllWallets(), + ]); + const hardwareDeviceIds = new Set( + wallets + .filter((wallet) => wallet.type === WALLET_TYPE_HW) + .map((wallet) => wallet.associatedDevice) + .filter((deviceId): deviceId is string => Boolean(deviceId)), + ); + const migrations = devices.flatMap((device) => { + if ( + !hardwareDeviceIds.has(device.id) || + (device.vendor ?? EHardwareVendor.onekey) !== EHardwareVendor.onekey || + isHardwareConnectProtocol(device.connectProtocol) + ) { + return []; + } + const observedProtocol = [ + device.deviceStateInfo?.protocol, + device.featuresInfo?.protocol, + ].find(isHardwareConnectProtocol); + return [ + { + dbDeviceId: device.id, + // All devices from the released app use V1. Keep explicit protocol + // evidence for internal builds without overwriting it during upgrade. + connectProtocol: observedProtocol ?? ('V1' as const), + }, + ]; + }); + + for (const migration of migrations) { + await localDb.updateDeviceConnectProtocol(migration); + } + await simpleDb.appStatus.setRawData( + (value): ISimpleDBAppStatus => ({ + ...value, + hardwareConnectProtocolMigrationVersion: Math.max( + value?.hardwareConnectProtocolMigrationVersion ?? 0, + HARDWARE_CONNECT_PROTOCOL_MIGRATION_VERSION, + ), + }), + ); + serviceHardwareUtils.hardwareLog( + 'migrated existing device connect protocols', + { migratedCount: migrations.length }, + ); + } + + private ensureExistingDeviceConnectProtocolMigration(): Promise { + if (!this.connectProtocolMigrationPromise) { + this.connectProtocolMigrationPromise = + this.runExistingDeviceConnectProtocolMigration().catch((error) => { + this.connectProtocolMigrationPromise = undefined; + throw error; + }); + } + return this.connectProtocolMigrationPromise; + } + + @backgroundMethod() + async migrateExistingDeviceConnectProtocols(): Promise { + await this.ensureExistingDeviceConnectProtocolMigration(); + } + + /** Coalesce concurrent device-management reads for the same connection. */ + private deviceManagementSnapshotInFlight = new Map< + string, + Promise + >(); + private bridgeAvailabilityChecked = false; private linuxUdevRulesReadyPromise: Promise | undefined; @@ -191,6 +524,117 @@ class ServiceHardware extends ServiceBase { ); } + private async rememberDeviceProtocol({ + connectIds, + protocol, + }: { + connectIds: Array; + protocol?: string | null; + }) { + if (protocol !== 'V1' && protocol !== 'V2') { + return; + } + const changedConnectIds: string[] = []; + for (const connectId of connectIds) { + if (connectId) { + const previousProtocol = + this.deviceProtocolByConnectId.get(connectId) ?? + this.deviceProtocolByConnectId.get(connectId.trim().toLowerCase()); + this.deviceProtocolByConnectId.set(connectId, protocol); + const normalizedConnectId = connectId.trim().toLowerCase(); + if (normalizedConnectId && normalizedConnectId !== connectId) { + this.deviceProtocolByConnectId.set(normalizedConnectId, protocol); + } + this.bindDeviceProtocolToSDK({ connectId, protocol }); + if (previousProtocol !== protocol) { + changedConnectIds.push(connectId); + } + } + } + if (changedConnectIds.length > 0) { + try { + await this.persistDeviceProtocols({ + connectIds: changedConnectIds, + protocol, + }); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'persist device protocol to simple db failed', + error, + ); + } + } + } + + private async getKnownDeviceProtocol(connectId?: string) { + if (!connectId) { + return undefined; + } + try { + await this.ensureExistingDeviceConnectProtocolMigration(); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'migrate existing device connect protocols failed', + error, + ); + } + const cachedProtocol = + this.deviceProtocolByConnectId.get(connectId) ?? + this.deviceProtocolByConnectId.get(connectId.trim().toLowerCase()); + if (cachedProtocol) { + return cachedProtocol; + } + try { + const device = await localDb.getDeviceByQuery({ connectId }); + let protocol = + device?.connectProtocol ?? device?.deviceStateInfo?.protocol; + if (protocol !== 'V1' && protocol !== 'V2') { + protocol = await this.getPersistedDeviceProtocol(connectId); + } + if ( + device?.id && + !device.connectProtocol && + (protocol === 'V1' || protocol === 'V2') + ) { + try { + await localDb.updateDeviceConnectProtocol?.({ + dbDeviceId: device.id, + connectProtocol: protocol, + }); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'backfill device connect protocol failed', + error, + ); + } + } + await this.rememberDeviceProtocol({ + connectIds: [ + connectId, + device?.connectId, + device?.usbConnectId, + device?.bleConnectId, + ], + protocol, + }); + return protocol === 'V1' || protocol === 'V2' ? protocol : undefined; + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'restore device protocol from persistence failed', + error, + ); + const protocol = await this.getPersistedDeviceProtocol(connectId); + if (protocol === 'V1' || protocol === 'V2') { + await this.rememberDeviceProtocol({ + connectIds: [connectId], + protocol, + }); + return protocol; + } + return undefined; + } + } + handleHardwareLabelChanged = cacheUtils.memoizee( async ({ walletId, @@ -276,8 +720,21 @@ class ServiceHardware extends ServiceBase { private registeredEvents = false; + private registeredSdkDebugLogging = false; + + private sdkInstanceEpoch = 0; + + private hardwareUiEventQueue = new HardwareUiEventQueue(); + + private hardwareUiEventState = createHardwareUiEventState(); + private connectedDeviceTracked = new Set(); + private resetHardwareUiEventQueue() { + this.hardwareUiEventQueue.reset(); + this.hardwareUiEventState = createHardwareUiEventState(); + } + checkSdkVersionValid() { if (process.env.NODE_ENV !== 'production') { const { @@ -310,6 +767,8 @@ class ServiceHardware extends ServiceBase { async getSDKInstance(options: { connectId: string | undefined; + connectProtocol?: HardwareConnectProtocol; + forceProtocolDetection?: boolean; hardwareCallContext?: EHardwareCallContext; }) { const { hardwareCallContext = EHardwareCallContext.USER_INTERACTION } = @@ -317,6 +776,19 @@ class ServiceHardware extends ServiceBase { this.checkSdkVersionValid(); await this.assertOneKeySdkConnectId(options?.connectId); + // 只有搜索/onboarding 可以显式重新探测;普通业务调用必须恢复已确认协议。 + const resolvedConnectProtocol = options.forceProtocolDetection + ? undefined + : (options.connectProtocol ?? + (await this.getKnownDeviceProtocol(options.connectId))); + if ( + options.connectId && + !resolvedConnectProtocol && + options.forceProtocolDetection !== true + ) { + throw new OneKeyLocalError(HARDWARE_CONNECT_PROTOCOL_UNAVAILABLE_MESSAGE); + } + const { hardwareConnectSrc } = await settingsPersistAtom.get(); const isPreRelease = await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( @@ -326,6 +798,19 @@ class ServiceHardware extends ServiceBase { await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( 'showDeviceDebugLogs', ); + const showSdkDebugLogs = + platformEnv.isDev === true && + (platformEnv.isNative === true || platformEnv.isDesktop === true) && + debugMode === true; + + if ( + this.registeredEvents && + this.registeredSdkDebugLogging !== showSdkDebugLogs + ) { + this.resetHardwareUiEventQueue(); + this.registeredEvents = false; + } + this.registeredSdkDebugLogging = showSdkDebugLogs; let hardwareTransportType = await this.connectionManager.getCurrentTransportType(); @@ -336,6 +821,7 @@ class ServiceHardware extends ServiceBase { // Check if we should switch transport type based on optimal connection strategy const result = await this.connectionManager.shouldSwitchTransportType({ connectId: options?.connectId, + connectProtocol: resolvedConnectProtocol, hardwareCallContext, }); shouldSwitch = result.shouldSwitch; @@ -350,6 +836,7 @@ class ServiceHardware extends ServiceBase { } → ${hardwareTransportType}`, ); // Reset SDK instance to use new transport type + this.resetHardwareUiEventQueue(); await resetHardwareSDKInstance(); this.registeredEvents = false; @@ -374,19 +861,43 @@ class ServiceHardware extends ServiceBase { await this.checkBridgeAndFallbackToWebUSB({ hardwareSDKInstance: instance, }); - await this.registerSdkEvents(instance); + await this.registerSdkEvents(instance, { showSdkDebugLogs }); + + const protocolAwareInstance = instance as IProtocolAwareCoreApi; + this.activeHardwareSDKInstance = protocolAwareInstance; + this.bindRememberedDeviceProtocols(protocolAwareInstance); + this.bindDeviceProtocolToSDK({ + connectId: options.connectId, + protocol: resolvedConnectProtocol, + instance: protocolAwareInstance, + }); return instance; } catch (error) { - // always show error toast when sdk init, so user can report to us - void this.backgroundApi.serviceApp.showToast({ - method: 'error', - title: (error as Error)?.message || 'Hardware SDK init failed', - }); + if ( + hardwareCallContext !== EHardwareCallContext.BACKGROUND_NON_INTERACTIVE + ) { + void this.backgroundApi.serviceApp.showToast({ + method: 'error', + title: (error as Error)?.message || 'Hardware SDK init failed', + }); + } throw error; } } + @backgroundMethod() + async sendUiResponseToActiveSdk(response: UiResponseEvent): Promise { + // UI 回包属于当前硬件调用的延续,不能重新执行传输探测或重建 SDK。 + const instance = this.activeHardwareSDKInstance; + if (!instance) { + throw new OneKeyLocalError( + 'Hardware SDK active instance is unavailable for UI response.', + ); + } + instance.uiResponse(response); + } + private async assertOneKeySdkConnectId(connectId: string | undefined) { if (!connectId) { return; @@ -404,9 +915,11 @@ class ServiceHardware extends ServiceBase { private async specialProcessingEvent({ originEvent, usedPayload, + isCurrent, }: { originEvent: UiEvent; usedPayload: IHardwareUiPayload; + isCurrent: () => boolean; }): Promise<{ uiRequestType: EHardwareUiStateAction; payload: IHardwareUiPayload; @@ -420,13 +933,19 @@ class ServiceHardware extends ServiceBase { // Handler Request Pin // If the user set is to enter pin on the device, change the event to enter pin on the hardware if (originEvent.type === EHardwareUiStateAction.REQUEST_PIN) { + const { device, type } = originEvent.payload || {}; + const { features } = device || {}; const dbDevice = await localDb.getDeviceByQuery({ connectId: newPayload.connectId, }); + const payloadDeviceType = features + ? await deviceUtils.getDeviceTypeFromFeatures({ features }) + : undefined; + const requestDeviceType = dbDevice?.deviceType || payloadDeviceType; if ( - dbDevice?.deviceType && - [EDeviceType.Touch, EDeviceType.Pro].includes(dbDevice?.deviceType) + requestDeviceType && + DEVICE_PIN_ON_DEVICE_TYPES.has(requestDeviceType) ) { newUiRequestType = EHardwareUiStateAction.EnterPinOnDevice; if ( @@ -441,10 +960,9 @@ class ServiceHardware extends ServiceBase { newPayload.requestPinType = 'AttachPin'; } } else { - const { device, type } = originEvent.payload || {}; - const { features } = device || {}; - - const inputPinOnSoftware = supportInputPinOnSoftwareSdk(features); + const inputPinOnSoftware = features + ? supportInputPinOnSoftwareSdk(features) + : { support: false }; const supportInputPinOnSoftware = dbDevice?.settings?.inputPinOnSoftware !== false && inputPinOnSoftware.support; @@ -452,8 +970,10 @@ class ServiceHardware extends ServiceBase { const isAttachPin = type === 'PinMatrixRequestType_AttachToPin'; newPayload.requestPinType = isAttachPin ? 'AttachPin' : undefined; - if (!supportInputPinOnSoftware) { - await this.backgroundApi.serviceHardwareUI.showEnterPinOnDevice(); + if (!supportInputPinOnSoftware && isCurrent()) { + await this.backgroundApi.serviceHardwareUI.showEnterPinOnDevice({ + responseCorrelation: newPayload.uiResponseCorrelation, + }); newUiRequestType = EHardwareUiStateAction.EnterPinOnDevice; } } @@ -468,8 +988,25 @@ class ServiceHardware extends ServiceBase { newPayload.firmwareProgressType = originEvent.payload.progressType; } + if (originEvent.type === EHardwareUiStateAction.DEVICE_PROGRESS) { + const { + progress, + transferredBytes, + totalBytes, + rateBytesPerSecond, + elapsedMs, + } = originEvent.payload; + newPayload.deviceProgress = { + progress, + transferredBytes, + totalBytes, + rateBytesPerSecond, + elapsedMs, + }; + } + if (originEvent.type === EHardwareUiStateAction.REQUEST_PASSPHRASE) { - newPayload.existsAttachPinUser = originEvent.payload.existsAttachPinUser; + copyWalletSessionUiMetadata(newPayload, originEvent.payload); } return { @@ -478,9 +1015,21 @@ class ServiceHardware extends ServiceBase { }; } - async registerSdkEvents(instance: CoreApi) { + async registerSdkEvents( + instance: CoreApi, + { + showSdkDebugLogs = false, + }: { + showSdkDebugLogs?: boolean; + } = {}, + ) { if (!this.registeredEvents) { + this.resetHardwareUiEventQueue(); this.registeredEvents = true; + this.registeredSdkDebugLogging = showSdkDebugLogs; + this.sdkInstanceEpoch += 1; + const sdkInstanceEpoch = this.sdkInstanceEpoch; + let deviceStateEventSequence = 0; const { UI_EVENT, DEVICE, @@ -489,97 +1038,265 @@ class ServiceHardware extends ServiceBase { FIRMWARE_EVENT, // UI_REQUEST, } = await CoreSDKLoader(); - instance.on(UI_EVENT, async (e) => { - const originEvent = e as UiEvent; - const { type: uiRequestType, payload } = e; - // console.log('=>>>> UI_EVENT: ', uiRequestType, payload); - defaultLogger.hardware.sdkLog.uiEvent(uiRequestType, payload); - - const { device, type: eventType, passphraseState } = payload || {}; - const { deviceType, connectId, deviceId, features } = device || {}; - const deviceMode = await this.getDeviceModeFromFeatures({ - features: features || {}, - }); - const isBootloaderMode = deviceMode === EOneKeyDeviceMode.bootloader; - - const usedPayload: IHardwareUiPayload = { - uiRequestType, - eventType, - deviceType, - deviceId, - connectId, - deviceMode, - isBootloaderMode: Boolean(isBootloaderMode), - passphraseState, - rawPayload: payload, - }; - - const { uiRequestType: newUiRequestType, payload: newPayload } = - await this.specialProcessingEvent({ - originEvent, - usedPayload, - }); - - // >>> mock hardware forceInputOnDevice - // if (usedPayload) { - // usedPayload.supportInputPinOnSoftware = false; - // } + instance.on(UI_EVENT, (e) => + this.hardwareUiEventQueue + .enqueue(e as UiEvent, async (queuedEvent, { isCurrent }) => { + const originEvent = queuedEvent; + const { type: uiRequestType, payload } = queuedEvent; + // console.log('=>>>> UI_EVENT: ', uiRequestType, payload); + defaultLogger.hardware.sdkLog.uiEvent(uiRequestType, payload); + + const eventPayload = + payload && typeof payload === 'object' + ? (payload as { + device?: { + deviceType?: IDeviceType | null; + connectId?: string | null; + deviceId?: string | null; + features?: IOneKeyDeviceFeatures; + }; + type?: string; + passphraseState?: string; + responseCorrelation?: { + interactionId?: unknown; + deviceId?: unknown; + }; + }) + : undefined; + const { + device, + type: eventType, + passphraseState, + responseCorrelation, + } = eventPayload || {}; + const { deviceType, connectId, deviceId, features } = device || {}; + const deviceMode = features + ? await this.getDeviceModeFromFeatures({ features }) + : EOneKeyDeviceMode.normal; + if (!isCurrent()) { + return; + } + const isBootloaderMode = isOneKeyLoaderMode(deviceMode); + + const usedPayload: IHardwareUiPayload = { + uiRequestType, + eventType: eventType ?? '', + deviceType: deviceType ?? EDeviceType.Unknown, + deviceId: deviceId ?? '', + connectId: connectId ?? '', + deviceMode, + isBootloaderMode: Boolean(isBootloaderMode), + passphraseState, + uiResponseCorrelation: + typeof responseCorrelation?.interactionId === 'string' && + typeof responseCorrelation.deviceId === 'string' + ? { + interactionId: responseCorrelation.interactionId, + deviceId: responseCorrelation.deviceId, + } + : undefined, + rawPayload: payload, + }; + + const { uiRequestType: newUiRequestType, payload: newPayload } = + await this.specialProcessingEvent({ + originEvent, + usedPayload, + isCurrent, + }); + if (!isCurrent()) { + return; + } - // skip ui-close_window event, which cause infinite loop - // ( emit ui-close_window -> Dialog close -> sdk cancel -> emit ui-close_window ) - if (!SKIPPED_EVENTS.has(newUiRequestType)) { - defaultLogger.hardware.sdkLog.updateHardwareUiStateAtom({ - action: newUiRequestType, - connectId, - payload: newPayload, - }); + const reduction = reduceHardwareUiEventState( + this.hardwareUiEventState, + { + type: uiRequestType as EHardwareUiStateAction, + renderAction: newUiRequestType, + connectId: connectId ?? undefined, + payload, + }, + ); + this.hardwareUiEventState = reduction.state; + if (!reduction.applied || !reduction.action) { + return; + } + const appliedUiRequestType = reduction.action; + const appliedConnectId = + reduction.connectId ?? connectId ?? newPayload.connectId; + const appliedPayload: IHardwareUiPayload = + appliedUiRequestType === EHardwareUiStateAction.ProcessLoading + ? { + ...newPayload, + uiRequestType: appliedUiRequestType, + connectId: appliedConnectId, + } + : newPayload; + + // >>> mock hardware forceInputOnDevice + // if (usedPayload) { + // usedPayload.supportInputPinOnSoftware = false; + // } + + // Matching Protocol V2 closes clear the active state directly. + // Legacy metadata-less closes remain skipped to avoid the old + // close -> cancel -> close loop. + if (reduction.shouldClearUiState) { + await hardwareUiStateAtom.set(undefined); + } else if (!SKIPPED_EVENTS.has(appliedUiRequestType)) { + defaultLogger.hardware.sdkLog.updateHardwareUiStateAtom({ + action: appliedUiRequestType, + connectId: appliedConnectId, + payload: appliedPayload, + }); - if (NEW_DIALOG_EVENTS.has(newUiRequestType)) { - appEventBus.emit(EAppEventBusNames.RequestHardwareUIDialog, { - uiRequestType: newUiRequestType, + if (NEW_DIALOG_EVENTS.has(appliedUiRequestType)) { + appEventBus.emit(EAppEventBusNames.RequestHardwareUIDialog, { + uiRequestType: appliedUiRequestType, + }); + } else if ( + appliedUiRequestType === + EHardwareUiStateAction.REQUEST_DEVICE_IN_BOOTLOADER_FOR_WEB_DEVICE + ) { + appEventBus.emit( + EAppEventBusNames.RequestDeviceInBootloaderForWebDevice, + undefined, + ); + } else if ( + appliedUiRequestType === + EHardwareUiStateAction.REQUEST_DEVICE_FOR_SWITCH_FIRMWARE_WEB_DEVICE + ) { + appEventBus.emit( + EAppEventBusNames.RequestDeviceForSwitchFirmwareWebDevice, + undefined, + ); + } else { + // show hardware ui dialog + await hardwareUiStateAtom.set( + (): IHardwareUiState => ({ + action: appliedUiRequestType, + connectId: appliedConnectId, + payload: appliedPayload, + }), + ); + if (!isCurrent()) { + return; + } + } + } + if (!isCurrent()) { + return; + } + await hardwareUiStateCompletedAtom.set({ + action: appliedUiRequestType, + connectId: appliedConnectId, + payload: appliedPayload, }); - } else if ( - newUiRequestType === - EHardwareUiStateAction.REQUEST_DEVICE_IN_BOOTLOADER_FOR_WEB_DEVICE - ) { - appEventBus.emit( - EAppEventBusNames.RequestDeviceInBootloaderForWebDevice, - undefined, - ); - } else if ( - newUiRequestType === - EHardwareUiStateAction.REQUEST_DEVICE_FOR_SWITCH_FIRMWARE_WEB_DEVICE - ) { - appEventBus.emit( - EAppEventBusNames.RequestDeviceForSwitchFirmwareWebDevice, - undefined, + }) + .catch((error: unknown) => { + defaultLogger.hardware.sdkLog.log( + 'hardware-ui-event-queue', + error instanceof Error ? error.message : 'Unknown event error', ); - } else { - if (newUiRequestType === ('ui-device_progress' as any)) { - console.log('ui-device_progress', originEvent); + }), + ); + + instance.on(DEVICE.STATE, async (event: DeviceStateEvent) => { + deviceStateEventSequence += 1; + const sdkEventSequence = deviceStateEventSequence; + serviceHardwareUtils.hardwareLog('device state update', { + revision: event.revision, + source: event.source, + changedKeys: event.changedKeys, + }); + const queueKeys = this.getDeviceStateSyncKeys([ + event.state.identity.serialNo, + event.state.identity.deviceId, + event.connectId, + ]); + const previousTasks = queueKeys + .map((key) => this.deviceStateSyncQueues.get(key)) + .filter((task): task is Promise => Boolean(task)); + const task = Promise.all(new Set(previousTasks)) + .catch(() => undefined) + .then(async () => { + let persistenceResult: + | Awaited> + | undefined; + try { + persistenceResult = await localDb.updateDeviceState({ + ...event, + sdkEventSequence, + sdkInstanceEpoch, + }); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'device state persistence failed', + error, + ); + } + if (persistenceResult?.kind === 'identity-mismatch') { + try { + await this.deprecateWalletsForResetDevice( + persistenceResult.deviceDbId, + ); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'device reset wallet isolation failed', + error, + ); + } + return; + } + if ( + persistenceResult?.kind === 'ignored' && + persistenceResult.reason === 'stale' + ) { + return; + } + await this.rememberDeviceProtocol({ + connectIds: [event.connectId, event.state.identity.serialNo], + protocol: event.state.protocol, + }); + try { + appEventBus.emit( + EAppEventBusNames.HardwareDeviceStateUpdate, + event, + ); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'device state subscriber failed', + error, + ); + } + }); + for (const queueKey of queueKeys) { + this.deviceStateSyncQueues.set(queueKey, task); + } + try { + await task; + } finally { + for (const queueKey of queueKeys) { + if (this.deviceStateSyncQueues.get(queueKey) === task) { + this.deviceStateSyncQueues.delete(queueKey); } - // show hardware ui dialog - await hardwareUiStateAtom.set( - (): IHardwareUiState => ({ - action: newUiRequestType, - connectId, - payload: newPayload, - }), - ); } } - await hardwareUiStateCompletedAtom.set({ - action: newUiRequestType, - connectId, - payload: newPayload, - }); }); instance.on( DEVICE.SUPPORT_FEATURES, (message: DeviceSupportFeaturesPayload) => { const { features } = message.device || {}; - if (!features || !features.device_id) return; + if ( + !features || + !deviceUtils.getRawDeviceId({ + device: message.device as any, + features, + }) + ) { + return; + } // TODO: save features to dbDevice serviceHardwareUtils.hardwareLog('features update', features); @@ -591,9 +1308,24 @@ class ServiceHardware extends ServiceBase { ); instance.on(DEVICE.CONNECT, (message: { device: KnownDevice }) => { + const activeConnectId = message.device?.connectId; + const serialNo = ( + message.device as KnownDevice & { + serialNo?: string; + } + )?.serialNo; const { features } = message.device || {}; - if (!features || !features.device_id) return; - const { device_id: deviceId } = features; + void this.rememberDeviceProtocol({ + connectIds: [activeConnectId, serialNo || message.device?.uuid], + protocol: message.device?.state?.protocol ?? features?.protocol, + }); + const deviceId = features + ? deviceUtils.getRawDeviceId({ + device: message.device as any, + features, + }) + : ''; + if (!features || !deviceId) return; void (async () => { try { @@ -633,9 +1365,22 @@ class ServiceHardware extends ServiceBase { })(); }); + instance.on(DEVICE.DISCONNECT, (message: { device: KnownDevice }) => { + const activeConnectId = message.device?.connectId; + if (activeConnectId) { + if (this.hardwareUiEventState.connectId === activeConnectId) { + this.resetHardwareUiEventQueue(); + } + } + }); + // TODO how to emit this event? // call getFeatures() or checkFirmwareRelease(); instance.on(FIRMWARE_EVENT, (messages: CoreMessage) => { + if (SKIP_APP_FIRMWARE_UPDATE_EVENT) { + return; + } + if (messages.type === FIRMWARE.RELEASE_INFO) { const payload: IFirmwareReleasePayload = { ...messages.payload, @@ -671,22 +1416,56 @@ class ServiceHardware extends ServiceBase { (messages: { event: string; type: string; payload: string[] }) => { const messageType = messages.payload.length > 0 ? messages.payload[0] : ''; + const message = messages.payload.join(' '); + + if (showSdkDebugLogs) { + try { + writeHardwareSdkDebugLog(message); + } catch { + // Debug logging must never interrupt hardware communication. + } + } if ( messageType.includes('@onekey/hd-core') || messageType.includes('@onekey/hd-transport') || messageType.includes('@onekey/hd-ble-transport') ) { - defaultLogger.hardware.sdkLog.log( - messages.event, - messages.payload.join(' '), - ); + defaultLogger.hardware.sdkLog.log(messages.event, message); } }, ); } } + private async deprecateWalletsForResetDevice(deviceDbId: string) { + const allHwWallets = + await this.backgroundApi.serviceAccount.getAllHwQrWalletWithDevice({ + filterHiddenWallet: false, + filterQrWallet: true, + }); + const willUpdateDeprecateMap: Record = {}; + for (const walletWithDevice of Object.values(allHwWallets)) { + const { wallet, device } = walletWithDevice; + if ( + wallet?.id && + (wallet.associatedDevice === deviceDbId || device?.id === deviceDbId) + ) { + willUpdateDeprecateMap[wallet.id] = true; + } + } + if (Object.keys(willUpdateDeprecateMap).length === 0) { + return; + } + const updated = + await this.backgroundApi.serviceAccount.updateWalletsDeprecatedState({ + willUpdateDeprecateMap, + }); + if (updated) { + appEventBus.emit(EAppEventBusNames.WalletUpdate, undefined); + } + } + @backgroundMethod() async init() { await this.getSDKInstance({ @@ -695,6 +1474,13 @@ class ServiceHardware extends ServiceBase { }); } + @backgroundMethod() + async resetHardwareSDK() { + this.resetHardwareUiEventQueue(); + this.registeredEvents = false; + await resetHardwareSDKInstance(); + } + @backgroundMethod() async passHardwareEventsFromOffscreenToBackground(eventMessage: CoreMessage) { const sdk = await this.getSDKInstance({ @@ -775,6 +1561,7 @@ class ServiceHardware extends ServiceBase { // TODO use convertDeviceResponse() @backgroundMethod() async searchDevices(params?: { + connectProtocol?: HardwareConnectProtocol; vendor?: EHardwareVendor; resetSession?: boolean; waitForAllTransports?: boolean; @@ -1085,6 +1872,90 @@ class ServiceHardware extends ServiceBase { return this.getFeaturesWithoutCache(params); } + @backgroundMethod() + async getPro2OnboardingStatus({ connectId }: { connectId: string }) { + const hardwareCallContext = + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG; + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + connectProtocol: 'V2', + hardwareCallContext, + }); + return convertDeviceResponse(() => + hardwareSDK.deviceGetOnboardingStatus(compatibleConnectId, { + connectProtocol: 'V2', + }), + ); + } + + @backgroundMethod() + async getDeviceManagementSnapshot({ + connectId, + refreshInfo = false, + }: { + connectId: string; + refreshInfo?: boolean; + }): Promise { + const hardwareCallContext = + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG; + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext, + }); + const snapshotKey = `${compatibleConnectId}:${ + refreshInfo ? 'firmware-and-settings' : 'settings' + }`; + const existingRequest = + this.deviceManagementSnapshotInFlight.get(snapshotKey); + if (existingRequest) { + return existingRequest; + } + + const request = (async () => { + let state: IOneKeyDeviceState; + try { + state = await this.getDeviceState({ + connectId: compatibleConnectId, + params: { scope: refreshInfo ? 'firmware' : 'settings' }, + hardwareCallContext, + silentMode: true, + }); + if (refreshInfo) { + state = await this.getDeviceState({ + connectId: compatibleConnectId, + params: { scope: 'settings' }, + hardwareCallContext, + silentMode: true, + }); + } + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'device settings snapshot unavailable', + error, + ); + state = await this.getDeviceState({ + connectId: compatibleConnectId, + hardwareCallContext, + silentMode: true, + }); + } + return { state }; + })(); + this.deviceManagementSnapshotInFlight.set(snapshotKey, request); + + try { + return await request; + } finally { + if (this.deviceManagementSnapshotInFlight.get(snapshotKey) === request) { + this.deviceManagementSnapshotInFlight.delete(snapshotKey); + } + } + } + private handlerConnectError = (e: any) => { const error: deviceErrors.OneKeyHardwareError | undefined = e as deviceErrors.OneKeyHardwareError; @@ -1102,9 +1973,13 @@ class ServiceHardware extends ServiceBase { async connect({ device, hardwareCallContext, + connectProtocol, + forceProtocolDetection, }: { device: SearchDevice; hardwareCallContext?: EHardwareCallContext; + connectProtocol?: HardwareConnectProtocol; + forceProtocolDetection?: boolean; }): Promise { const vendor = (device as SearchDevice & { vendor?: string }).vendor; if (vendor && vendor !== EHardwareVendor.onekey) { @@ -1126,43 +2001,86 @@ class ServiceHardware extends ServiceBase { ); } - // Get compatible connectId for the current transport type - const compatibleConnectId = await this.getCompatibleConnectId({ - connectId: connectId || undefined, - featuresDeviceId: device.deviceId, - hardwareCallContext: - hardwareCallContext || EHardwareCallContext.USER_INTERACTION, - }); + // Electron BLE discovery returns the Noble peripheral ID as the canonical + // connection identifier. Replacing it with the Pro USB serial number would + // make Noble run a targeted scan for PRB... and never find the peripheral. + // Keep the existing compatibility lookup for native transports only. + const isDesktopBleSearchDevice = + platformEnv.isSupportDesktopBle && + deviceUtils.isBluetoothSearchDevice(device); + const compatibleConnectId = isDesktopBleSearchDevice + ? connectId || undefined + : await this.getCompatibleConnectId({ + connectId: connectId || undefined, + featuresDeviceId: device.deviceId, + hardwareCallContext: + hardwareCallContext || EHardwareCallContext.USER_INTERACTION, + }); + const protocolAwareDevice = device as SearchDevice & { + connectProtocol?: HardwareConnectProtocol; + state?: { protocol?: HardwareConnectProtocol | null }; + }; + const resolvedConnectProtocol = forceProtocolDetection + ? undefined + : (connectProtocol ?? + protocolAwareDevice.connectProtocol ?? + protocolAwareDevice.state?.protocol ?? + (await this.getKnownDeviceProtocol(compatibleConnectId))); + + const knownFeatures = (device as KnownDevice).features; + if (!platformEnv.isNative && knownFeatures) { + // WebUSB 搜索已完成真实通讯;复用结果,并在成功后保存已确认协议。 + await this.rememberDeviceProtocol({ + connectIds: [ + connectId, + compatibleConnectId, + (device as SearchDevice & { serialNo?: string }).serialNo, + device.uuid, + ], + protocol: + protocolAwareDevice.state?.protocol ?? + protocolAwareDevice.connectProtocol ?? + knownFeatures.protocol, + }); + return knownFeatures; + } + + const params = { + ...(forceProtocolDetection ? { forceProtocolDetection: true } : {}), + ...(resolvedConnectProtocol + ? { connectProtocol: resolvedConnectProtocol } + : {}), + ...(hardwareCallContext === EHardwareCallContext.UPDATE_FIRMWARE + ? { allowEmptyConnectId: true } + : {}), + } as IDeviceGetFeaturesOptions['params']; if (platformEnv.isNative) { try { return await this.connectDevice({ connectId: compatibleConnectId, + params, }); } catch (e: any) { this.handlerConnectError(e); } } else { - /** - * USB does not need the extra getFeatures call - */ - try { - return await this.connectDevice({ - connectId: compatibleConnectId, - params: { - allowEmptyConnectId: - hardwareCallContext === EHardwareCallContext.UPDATE_FIRMWARE, - }, - }); - } catch (_e: any) { - return (device as KnownDevice).features; - } + return this.connectDevice({ + connectId: compatibleConnectId, + params, + }); } } @backgroundMethod() @toastIfError() - async unlockDevice({ connectId }: { connectId: string }) { + async unlockDevice({ + connectId, + pinType, + }: { + connectId: string; + pinType?: DeviceSessionPinType; + }) { const hardwareSDK = await this.getSDKInstance({ connectId, }); @@ -1170,31 +2088,14 @@ class ServiceHardware extends ServiceBase { connectId, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); + const unlockParams: CommonParams & { + pinType?: DeviceSessionPinType; + } = pinType === undefined ? {} : { pinType }; return convertDeviceResponse(() => - hardwareSDK?.deviceUnlock(compatibleConnectId, {}), + hardwareSDK?.deviceUnlock(compatibleConnectId, unlockParams), ); } - @backgroundMethod() - async getFeaturesWithUnlock({ connectId }: { connectId: string }) { - const compatibleConnectId = await this.getCompatibleConnectId({ - connectId, - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, - }); - let features = await this.getFeaturesWithoutCache({ - connectId: compatibleConnectId, - }); - - if (!features.unlocked) { - // unlock device - features = await this.unlockDevice({ - connectId: compatibleConnectId, - }); - } - - return features; - } - cancelTimer: ReturnType | undefined; lastCancelAt: Record = {}; @@ -1289,10 +2190,10 @@ class ServiceHardware extends ServiceBase { } } - // TODO get connectId from SDK: connectId = getDeviceUUID() only works on usb sdk - // connectId: DataManager.isBleConnect(env) ? this.mainId || null : getDeviceUUID(this.features), + // TODO get connectId from SDK: USB connectId should use the standard device identity helper. + // For App-side compatibility use deviceUtils.buildDeviceUSBConnectId({ features }). // TODO uuid is equal to connectId in ble sdk? - // const connectId = getDeviceUUID(features); + // const connectId = await deviceUtils.buildDeviceUSBConnectId({ features }); // if (connectId) { // return connectId; // } @@ -1324,21 +2225,95 @@ class ServiceHardware extends ServiceBase { _getFeaturesLowLevel = async (options: IDeviceGetFeaturesOptions) => { const { connectId, params, silentMode, hardwareCallContext } = options; - serviceHardwareUtils.hardwareLog('call getFeatures()', connectId); - if (!params?.allowEmptyConnectId && !connectId) { + const { + allowEmptyConnectId, + detectBootloaderDevice, + forceProtocolDetection, + ...sdkParams + } = params ?? {}; + serviceHardwareUtils.hardwareLog('read legacy app features', connectId); + if (!allowEmptyConnectId && !connectId) { throw new OneKeyLocalError( 'hardware getFeatures ERROR: connectId is undefined', ); } + const knownProtocol = forceProtocolDetection + ? undefined + : (sdkParams.connectProtocol ?? + (await this.getKnownDeviceProtocol(connectId ?? undefined))); const hardwareSDK = await this.getSDKInstance({ connectId, + connectProtocol: knownProtocol, + forceProtocolDetection, hardwareCallContext, }); - const features = await convertDeviceResponse( - () => hardwareSDK?.getFeatures(connectId, params), + const getFeaturesParams = { + ...sdkParams, + ...(knownProtocol ? { connectProtocol: knownProtocol } : {}), + ...(forceProtocolDetection && !knownProtocol + ? { forceProtocolDetection: true } + : {}), + ...(detectBootloaderDevice ? { detectBootloaderDevice: true } : {}), + }; + const readV1Features = async (confirmedProtocol?: 'V1') => { + const effectiveGetFeaturesParams = confirmedProtocol + ? { + ...sdkParams, + connectProtocol: confirmedProtocol, + ...(detectBootloaderDevice ? { detectBootloaderDevice: true } : {}), + } + : getFeaturesParams; + const features = await convertDeviceResponse( + () => + hardwareSDK?.getFeatures( + connectId as string, + Object.keys(effectiveGetFeaturesParams).length > 0 + ? effectiveGetFeaturesParams + : undefined, + ), + { silentMode }, + ); + await this.rememberDeviceProtocol({ + connectIds: [connectId], + protocol: 'V1', + }); + return features; + }; + if (knownProtocol === 'V1') { + return readV1Features(); + } + let readParams: + | (CommonParams & { forceProtocolDetection?: boolean }) + | undefined = Object.keys(sdkParams).length > 0 ? sdkParams : undefined; + if (knownProtocol) { + readParams = { ...sdkParams, connectProtocol: knownProtocol }; + } else if (forceProtocolDetection) { + readParams = { ...sdkParams, forceProtocolDetection: true }; + } + const currentState = await convertDeviceResponse( + () => hardwareSDK?.getDeviceState(connectId as string, readParams), { silentMode }, ); - return features; + if (sdkParams.onlyConnectBleDevice) { + // Preserve the x-branch connection-only contract: the SDK returns an + // empty payload after establishing BLE. Pro 2 still enters through the + // V2 getDeviceState API, but the expected null is not a full DeviceState. + return currentState as unknown as IOneKeyDeviceFeatures; + } + await this.rememberDeviceProtocol({ + connectIds: [connectId, currentState.identity.serialNo], + protocol: currentState.protocol, + }); + if (currentState.protocol === 'V1') { + return readV1Features('V1'); + } + if ( + detectBootloaderDevice && + isOneKeyLoaderMode(currentState.status.mode) + ) { + throw new deviceErrors.DeviceDetectInBootloaderMode(); + } + return projectLegacyDeviceFeaturesFromState(currentState); }; _getFeaturesWithTimeout = makeTimeoutPromise({ @@ -1376,18 +2351,150 @@ class ServiceHardware extends ServiceBase { }, ); + _getDeviceStateLowLevel = async (options: IDeviceGetStateOptions) => { + const { connectId, params, silentMode, hardwareCallContext } = options; + const { allowEmptyConnectId, ...sdkParams } = params ?? {}; + serviceHardwareUtils.hardwareLog('call getDeviceState()', connectId); + if (!allowEmptyConnectId && !connectId) { + throw new OneKeyLocalError( + 'hardware getDeviceState ERROR: connectId is undefined', + ); + } + const knownProtocol = + sdkParams.connectProtocol ?? + (await this.getKnownDeviceProtocol(connectId ?? undefined)); + if (connectId && !knownProtocol) { + throw new OneKeyLocalError(HARDWARE_CONNECT_PROTOCOL_UNAVAILABLE_MESSAGE); + } + const normalizedSdkParams = + params || knownProtocol + ? { + ...sdkParams, + ...(knownProtocol ? { connectProtocol: knownProtocol } : {}), + } + : undefined; + const hardwareSDK = await this.getSDKInstance({ + connectId, + connectProtocol: knownProtocol, + hardwareCallContext, + }); + const state = await convertDeviceResponse( + () => hardwareSDK.getDeviceState(connectId, normalizedSdkParams), + { silentMode }, + ); + await this.rememberDeviceProtocol({ + connectIds: [connectId, state.identity.serialNo], + protocol: state.protocol, + }); + return state; + }; + + _getDeviceStateWithTimeout = makeTimeoutPromise({ + asyncFunc: this._getDeviceStateLowLevel, + timeout: timerUtils.getTimeDurationMs({ seconds: 60 }), + timeoutRejectError: new deviceErrors.DeviceMethodCallTimeout(), + }); + + _getDeviceStateWithMutex = async ( + options: IDeviceGetStateOptions, + ): Promise => + this.getFeaturesMutex.runExclusive(async () => + this._getDeviceStateWithTimeout(options), + ); + + @backgroundMethod() + async getDeviceState(options: IDeviceGetStateOptions) { + const hardwareCallContext = + options.hardwareCallContext ?? + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG; + const compatibleConnectId = options.connectId + ? await this.getCompatibleConnectId({ + connectId: options.connectId, + hardwareCallContext, + }) + : options.connectId; + return this._getDeviceStateWithMutex({ + ...options, + connectId: compatibleConnectId, + hardwareCallContext, + }); + } + + @backgroundMethod() + async getDeviceStateWithUnlock({ + connectId, + oneKeyOperationLease, + pinType, + params, + }: { + connectId: string; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; + pinType?: DeviceSessionPinType; + params?: GetDeviceStateParams; + }) { + const dbDevice = await localDb.getDeviceByQuery({ connectId }); + return this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + () => + this.getDeviceStateWithUnlockInternal({ connectId, pinType, params }), + { + deviceKey: + dbDevice?.id || + dbDevice?.deviceId || + dbDevice?.uuid || + dbDevice?.connectId || + connectId, + lease: oneKeyOperationLease, + }, + ); + } + + private async getDeviceStateWithUnlockInternal({ + connectId, + pinType, + params, + }: { + connectId: string; + pinType?: DeviceSessionPinType; + params?: GetDeviceStateParams; + }) { + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + let state = await this.getDeviceState({ + connectId: compatibleConnectId, + params, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + if (state.status.initialized === false) { + throw new OneKeyLocalError('Device is not initialized'); + } + if (state.status.unlocked === false) { + await this.unlockDevice({ connectId: compatibleConnectId, pinType }); + state = await this.getDeviceState({ + connectId: compatibleConnectId, + params, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + } + return state; + } + + /** @deprecated Use getDeviceState. */ @backgroundMethod() async getFeatures(options: IDeviceGetFeaturesOptions) { const features = await this._getFeaturesWithCache(options); return features; } + /** @deprecated Use getDeviceState. */ @backgroundMethod() async getFeaturesWithoutCache(options: IDeviceGetFeaturesOptions) { const features = await this._getFeaturesWithMutex(options); return features; } + /** @deprecated Use getDeviceStateByWallet. */ @backgroundMethod() async getFeaturesByWallet({ walletId }: { walletId: string }) { const device = await this.backgroundApi.serviceAccount.getWalletDevice({ @@ -1397,6 +2504,24 @@ class ServiceHardware extends ServiceBase { return this.getFeatures({ connectId: device.connectId }); } + @backgroundMethod() + async getDeviceStateByWallet({ + walletId, + params, + }: { + walletId: string; + params?: GetDeviceStateParams; + }) { + const device = await this.backgroundApi.serviceAccount.getWalletDevice({ + walletId, + }); + return this.getDeviceState({ + connectId: device.connectId, + params, + }); + } + + /** @deprecated Use getDeviceState and request the required scope. */ @backgroundMethod() async getAboutDeviceFeatures(params: { connectId: string }) { const dbDevice = await localDb.getDeviceByQuery({ @@ -1476,14 +2601,61 @@ class ServiceHardware extends ServiceBase { forceInputPassphrase: boolean; // not working? useEmptyPassphrase?: boolean; }): Promise { + const protocol = await this.getKnownDeviceProtocol(connectId); + if (!protocol) { + throw new OneKeyLocalError(HARDWARE_CONNECT_PROTOCOL_UNAVAILABLE_MESSAGE); + } const hardwareSDK = await this.getSDKInstance({ connectId, + connectProtocol: protocol, }); + if (protocol === 'V2') { + const openWalletSession = hardwareSDK?.openWalletSession; + if (!openWalletSession) { + throw new OneKeyLocalError( + 'Protocol V2 wallet session API is unavailable in the loaded hardware SDK', + ); + } + const walletSession = await convertDeviceResponse(() => + useEmptyPassphrase + ? openWalletSession(connectId, { mode: 'standard' }) + : openWalletSession(connectId, { mode: 'select-hidden' }), + ); + const expectedWalletType = useEmptyPassphrase ? 'standard' : 'hidden'; + if (walletSession.walletType !== expectedWalletType) { + throw new OneKeyLocalError( + `Protocol V2 wallet type mismatch: expected ${expectedWalletType}, received ${walletSession.walletType}`, + ); + } + if (walletSession.walletType === 'standard') { + return undefined; + } + const passphraseState = nullableToUndefined( + walletSession.passphraseState, + ); + if (!passphraseState) { + throw new OneKeyLocalError( + 'Protocol V2 hidden wallet response is missing passphraseState', + ); + } + return passphraseState; + } + + const getPassphraseState = hardwareSDK?.getPassphraseState as + | (( + targetConnectId: string, + params: CommonParams, + ) => HardwareResponse) + | undefined; + if (!getPassphraseState) { + return undefined; + } return convertDeviceResponse(() => - hardwareSDK?.getPassphraseState(connectId, { + getPassphraseState(connectId, { initSession: forceInputPassphrase, // always re-input passphrase on device useEmptyPassphrase, + connectProtocol: protocol, // deriveCardano, // TODO gePassphraseState different if networkImpl === IMPL_ADA ? }), ); @@ -1514,7 +2686,7 @@ class ServiceHardware extends ServiceBase { @backgroundMethod() @toastIfError() - async setBrightness(p: IBaseDeviceProcessingParams) { + async setBrightness(p: ISetBrightnessParams) { return this.deviceSettingsManager.setBrightness(p); } @@ -1527,35 +2699,30 @@ class ServiceHardware extends ServiceBase { @backgroundMethod() @toastIfError() async wipeDevice(p: IWipeDeviceParams) { - return this.deviceSettingsManager.wipeDevice(p); + let deviceDbId: string | undefined; + if (p.walletId) { + const device = await this.backgroundApi.serviceAccount.getWalletDevice({ + walletId: p.walletId, + }); + deviceDbId = device?.id; + } else if (p.connectId || p.featuresDeviceId) { + const device = await localDb.getDeviceByQuery({ + connectId: p.connectId, + featuresDeviceId: p.featuresDeviceId, + }); + deviceDbId = device?.id; + } + const result = await this.deviceSettingsManager.wipeDevice(p); + if (deviceDbId) { + await this.deprecateWalletsForResetDevice(deviceDbId); + } + return result; } @backgroundMethod() @toastIfError() async setPassphraseEnabled(p: ISetPassphraseEnabledParams) { - const result = await this.deviceSettingsManager.setPassphraseEnabled(p); - if (result.message) { - let dbDeviceId: string | undefined; - if (p.walletId) { - const wallet = await this.backgroundApi.serviceAccount.getWalletSafe({ - walletId: p.walletId, - }); - dbDeviceId = wallet?.associatedDevice; - } else { - const device = await localDb.getDeviceByQuery({ - connectId: p.connectId, - featuresDeviceId: p.featuresDeviceId, - }); - dbDeviceId = device?.id; - } - if (dbDeviceId) { - await localDb.updateDeviceFeaturesPassphraseProtection({ - dbDeviceId, - passphraseProtection: p.passphraseEnabled, - }); - } - } - return result; + return this.deviceSettingsManager.setPassphraseEnabled(p); } @backgroundMethod() @@ -1586,17 +2753,7 @@ class ServiceHardware extends ServiceBase { const walletName = wallet?.name; const dbDeviceId = wallet?.associatedDevice; if (dbDeviceId) { - // update db features label - await localDb.updateDeviceFeaturesLabel({ - dbDeviceId, - label: p.label, - }); - // After device label is updated, notify UI/hardware interaction layer to refresh cached device info, - // otherwise the hardware interaction dialog may keep showing the old name until app restart. - appEventBus.emit(EAppEventBusNames.HardwareFeaturesUpdate, { - deviceId: dbDeviceId, - }); - // update db wallet name + // SDK DEVICE.STATE drives both persistence and UI refreshes. appEventBus.emit(EAppEventBusNames.SyncDeviceLabelToWalletName, { walletId: p.walletId, dbDeviceId, @@ -1674,17 +2831,57 @@ class ServiceHardware extends ServiceBase { homeScreenType, thumbnail: false, }); - const thumbnailSize = getHomeScreenSize({ + let thumbnailSize = getHomeScreenSize({ deviceType: device.deviceType, homeScreenType, thumbnail: true, }); + size = + serviceHardwareUtils.getPro2HomeScreenSizeFallback({ + deviceType: device.deviceType, + thumbnail: false, + }) ?? size; + thumbnailSize = + serviceHardwareUtils.getPro2HomeScreenSizeFallback({ + deviceType: device.deviceType, + thumbnail: true, + }) ?? thumbnailSize; if (!size && isT1Model) { size = DEFAULT_T1_HOME_SCREEN_INFORMATION; } return { names, size, thumbnailSize }; } + @backgroundMethod() + async getDeviceNftConfig({ + dbDeviceId, + }: { + dbDeviceId: string | undefined; + }): Promise { + const { getNftSize } = await CoreSDKLoader(); + const device = await localDb.getDevice(checkIsDefined(dbDeviceId)); + const size = + getNftSize({ + deviceType: device.deviceType, + thumbnail: false, + }) ?? + serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: device.deviceType, + thumbnail: false, + }); + const thumbnailSize = + getNftSize({ + deviceType: device.deviceType, + thumbnail: true, + }) ?? + serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: device.deviceType, + thumbnail: true, + }); + + return { names: [], size, thumbnailSize }; + } + @backgroundMethod() async shouldAuthenticateFirmware(p: IShouldAuthenticateFirmwareParams) { return this.hardwareVerifyManager.shouldAuthenticateFirmware(p); @@ -1730,6 +2927,74 @@ class ServiceHardware extends ServiceBase { ); } + @backgroundMethod() + async uploadPro2Nft({ + connectId, + imageHex, + thumbnailHex, + title, + subtitle, + timestampMs, + }: IUploadPro2NftParams) { + const { decodeJpegToRgba } = await import('./jpegRgbaUtils'); + const image = decodeJpegToRgba({ + imageHex, + expectedWidth: 540, + expectedHeight: 540, + label: 'Pro2 NFT image', + }); + const thumbnail = decodeJpegToRgba({ + imageHex: thumbnailHex, + expectedWidth: 263, + expectedHeight: 263, + label: 'Pro2 NFT thumbnail', + }); + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + return convertDeviceResponse(() => + hardwareSDK.deviceUploadNft(compatibleConnectId, { + image: { width: image.width, height: image.height, rgba: image.data }, + thumbnail: { + width: thumbnail.width, + height: thumbnail.height, + rgba: thumbnail.data, + }, + title, + subtitle, + timestampMs, + }), + ); + } + + @backgroundMethod() + async uploadPortfolioPackage({ + connectId, + packageBytes, + }: { + connectId: string; + packageBytes: ArrayBuffer; + }) { + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + }); + return convertDeviceResponse(() => + hardwareSDK.uploadPortfolio(compatibleConnectId, { + packageBytes, + }), + ); + } + @backgroundMethod() async getLogs(): Promise { const logs: string[] = ['===== device logs =====']; @@ -1746,7 +3011,7 @@ class ServiceHardware extends ServiceBase { } @backgroundMethod() - async getOneKeyFeatures({ + async getFirmwareVerificationFeatures({ connectId, deviceType, }: { @@ -1757,21 +3022,28 @@ class ServiceHardware extends ServiceBase { connectId, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); - const hardwareSDK = await this.getSDKInstance({ + const state = await this.getDeviceState({ connectId: compatibleConnectId, + params: { + scope: supportsDedicatedFirmwareFeatures(deviceType) + ? 'firmware' + : 'runtime', + }, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); - return convertDeviceResponse(() => { - // classic1s does not support getOnekeyFeatures method - if ( - deviceType === EDeviceType.Classic1s || - deviceType === EDeviceType.ClassicPure - ) { - return hardwareSDK?.getFeatures( - compatibleConnectId, - ) as unknown as Response; - } - return hardwareSDK?.getOnekeyFeatures(compatibleConnectId); - }); + return buildOnekeyFeaturesFromState(state); + } + + /** @deprecated Use getFirmwareVerificationFeatures. */ + @backgroundMethod() + async getOneKeyFeatures({ + connectId, + deviceType, + }: { + connectId: string; + deviceType: IDeviceType; + }): Promise { + return this.getFirmwareVerificationFeatures({ connectId, deviceType }); } private fixHardwareBitcoinOnlyState(params: IUpdateFirmwareWorkflowParams) { @@ -1842,26 +3114,22 @@ class ServiceHardware extends ServiceBase { return; } const versionInfo: IDeviceVersionCacheInfo = { - onekey_firmware_version: undefined, - onekey_ble_version: undefined, - ble_ver: undefined, - onekey_boot_version: undefined, - bootloader_version: undefined, + firmwareVersion: undefined, + bleVersion: undefined, + bootloaderVersion: undefined, }; if (params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade) { const bootVersion = params.releaseResult.updateInfos.bootloader?.toVersion; - versionInfo.onekey_boot_version = bootVersion; - versionInfo.bootloader_version = bootVersion; + versionInfo.bootloaderVersion = bootVersion; } if (params?.releaseResult?.updateInfos?.firmware?.hasUpgrade) { - versionInfo.onekey_firmware_version = + versionInfo.firmwareVersion = params.releaseResult.updateInfos.firmware?.toVersion; } if (params?.releaseResult?.updateInfos?.ble?.hasUpgrade) { const bleVersion = params.releaseResult.updateInfos.ble?.toVersion; - versionInfo.onekey_ble_version = bleVersion; - versionInfo.ble_ver = bleVersion; + versionInfo.bleVersion = bleVersion; } const filteredVersionInfo: Partial = {}; @@ -2196,18 +3464,39 @@ class ServiceHardware extends ServiceBase { const hardwareSDK = await this.getSDKInstance({ connectId: undefined, }); + let result: { device: KnownDevice | null }; try { - return await convertDeviceResponse(() => + result = await convertDeviceResponse(() => hardwareSDK?.promptWebDeviceAccess(params), ); } catch (error) { if (await this.recoverLinuxWebUsbAccessDeniedError(error)) { - return convertDeviceResponse(() => + result = await convertDeviceResponse(() => hardwareSDK?.promptWebDeviceAccess(params), ); + } else { + throw error; } - throw error; } + const device = result.device as KnownDevice | undefined; + await this.rememberDeviceProtocol({ + connectIds: [ + params.deviceSerialNumberFromUI, + device?.connectId, + (device as (KnownDevice & { serialNo?: string }) | undefined)?.serialNo, + device?.uuid, + device?.path, + ], + protocol: + device?.state?.protocol ?? + ( + device as + | (KnownDevice & { connectProtocol?: 'V1' | 'V2' }) + | undefined + )?.connectProtocol ?? + device?.features?.protocol, + }); + return result; } private async _needCheckBridgeStatus() { @@ -2294,6 +3583,7 @@ class ServiceHardware extends ServiceBase { ); // Reset event registration flag to allow re-registration + this.resetHardwareUiEventQueue(); this.registeredEvents = false; // 3. Reset SDK instance (clears memoizee cache and cleans up SDK instance) @@ -2321,13 +3611,17 @@ class ServiceHardware extends ServiceBase { }: { forceTransportType: EHardwareTransportType; }) { + const nextForceTransportType = + deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: forceTransportType, + }); const operationId = stringUtils.randomString(12); await hardwareForceTransportAtom.set({ - forceTransportType, + forceTransportType: nextForceTransportType, operationId, }); defaultLogger.setting.device.setForceTransportType({ - forceTransportType, + forceTransportType: nextForceTransportType, operationId, }); } @@ -2407,8 +3701,14 @@ class ServiceHardware extends ServiceBase { } @backgroundMethod() - async detectUSBDeviceAvailability() { - return this.connectionManager.detectUSBDeviceAvailability(); + async detectUSBDeviceAvailability(params?: { + connectId?: string; + connectProtocol?: HardwareConnectProtocol; + }) { + return this.connectionManager.detectUSBDeviceAvailability( + params?.connectId, + params?.connectProtocol, + ); } @backgroundMethod() @@ -2445,7 +3745,7 @@ class ServiceHardware extends ServiceBase { } // Step 2: Get expected device name from features - const expectedDeviceName = features.ble_name; + const expectedDeviceName = features.bleName; // Step 3: Find matching device by name const matchingDevice = searchResult.payload.find((device) => { @@ -2463,16 +3763,23 @@ class ServiceHardware extends ServiceBase { }); } + const expectedDeviceId = + featuresDeviceId || + deviceUtils.getRawDeviceId({ + device: matchingDevice as any, + features, + }); + // Step 4: Try to connect and verify const connectResult = await this.connect({ device: { ...matchingDevice, connectId: matchingDevice.connectId || '', - deviceId: features.device_id, + deviceId: expectedDeviceId, }, }); - if (connectResult && connectResult.device_id === features.device_id) { + if (connectResult && connectResult.deviceId === expectedDeviceId) { // Step 5: Update device in DB with BLE connectId const device = await localDb.getDeviceByQuery({ connectId, @@ -2579,17 +3886,19 @@ class ServiceHardware extends ServiceBase { throw new OneKeyLocalError('connectId is required'); } - // Try to get device from DB first. The vendor filter defaults to OneKey - // (broadening it globally would pull shipped Ledger devices into the - // third-party branch below and change a working flow) — but a caller that - // already knows the vendor may pass it so a third-party device is found and - // its transport-correct connectId (e.g. Trezor bleConnectId) is resolved. - const device = await localDb.getDeviceByQuery({ - connectId, - featuresDeviceId: featuresDeviceId || undefined, - features, - vendor, - }); + // A transport connect ID is already a precise device key. Do not let stale + // device info or legacy feature projections veto a valid USB/BLE ID match. + let device = await localDb.getDeviceByQuery({ connectId }); + if (!device && featuresDeviceId) { + device = await localDb.getDeviceByQuery({ + featuresDeviceId, + }); + } + // Features are not an identity source for DeviceState-backed devices. This + // final fallback only supports legacy records that have not connected yet. + if (!device && features) { + device = await localDb.getDeviceByQuery({ features }); + } // Third-party devices keep USB as the primary connectId, but Trezor can // have a bound BLE connectId after USB->BLE pairing. Prefer the bound BLE @@ -2601,7 +3910,11 @@ class ServiceHardware extends ServiceBase { if (!platformEnv.isSupportDesktopBle) { return device.connectId || connectId; } - if (hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK) { + if ( + hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK || + hardwareCallContext === + EHardwareCallContext.BACKGROUND_NON_INTERACTIVE + ) { const currentTransportType = await this.getCurrentTransportType(); const preferredBle = await this.resolveTrezorPreferredBleConnectId({ device, @@ -2630,10 +3943,24 @@ class ServiceHardware extends ServiceBase { }); if (!platformEnv.isSupportDesktopBle) { + if (platformEnv.isNative) { + if (device?.bleConnectId?.toLowerCase() === connectId.toLowerCase()) { + // Preserve the current scan result, including the UUID casing returned by iOS. + return connectId; + } + return device?.bleConnectId || connectId; + } return device?.connectId || connectId; } - if (hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK) { + const connectProtocol = await this.getKnownDeviceProtocol( + device?.connectId || connectId, + ); + + if ( + hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK || + hardwareCallContext === EHardwareCallContext.BACKGROUND_NON_INTERACTIVE + ) { const currentTransportType = await this.getCurrentTransportType(); if ( currentTransportType === EHardwareTransportType.DesktopWebBle && @@ -2646,6 +3973,7 @@ class ServiceHardware extends ServiceBase { const result = await this.connectionManager.shouldSwitchTransportType({ connectId: device?.connectId || connectId, + connectProtocol, hardwareCallContext, }); const targetTransportType = result.targetType; @@ -2684,7 +4012,12 @@ class ServiceHardware extends ServiceBase { { device, deviceId: - featuresDeviceId || device.featuresInfo?.device_id || '', + featuresDeviceId || + deviceUtils.getRawDeviceId({ + device: deviceUtils.dbDeviceToSearchDevice(device), + features: device.featuresInfo, + }) || + '', usbConnectId: connectId, features: features || device.featuresInfo, promiseId, @@ -2741,18 +4074,20 @@ class ServiceHardware extends ServiceBase { firmwareVersion: string; }): Promise { const client = await this.getClient(EServiceEndpointEnum.Utility); + const serverDeviceType = + serviceHardwareUtils.getHomeScreenServerDeviceType(deviceType); const response = await client.get<{ data: IHardwareHomeScreenResponse[]; }>('/utility/v1/wallet-homescreen/list', { params: { - deviceType, + deviceType: serverDeviceType, serialNumber, firmwareVersion, }, }); const { data } = response.data; return data - .filter((item) => item.deviceTypes.includes(deviceType)) + .filter((item) => item.deviceTypes.includes(serverDeviceType)) .filter( (item) => item.resType === 'system' || diff --git a/packages/kit-bg/src/services/ServiceHardware/getWallpaperResourceType.ts b/packages/kit-bg/src/services/ServiceHardware/getWallpaperResourceType.ts new file mode 100644 index 000000000000..203b2d9dc0c3 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/getWallpaperResourceType.ts @@ -0,0 +1,7 @@ +import type { DeviceUploadResourceParams } from '@onekeyfe/hd-core'; + +export function getWallpaperResourceType(): DeviceUploadResourceParams['resType'] { + // ResourceType.WallPaper is the stable Protocol V1/V2 wire value. Web must + // not load the hardware SDK only to read this protocol constant. + return 0; +} diff --git a/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.test.ts b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.test.ts new file mode 100644 index 000000000000..ba52fc66d824 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.test.ts @@ -0,0 +1,300 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; + +import { + HardwareUiEventQueue, + createHardwareUiEventState, + reduceHardwareUiEventState, +} from './hardwareUiEventStateMachine'; + +const createInteraction = (overrides: Record = {}) => ({ + interactionId: 'interaction-1', + phaseId: 'pin-phase', + sequence: 1, + phase: 'pin', + transition: 'start', + protocol: 'V2', + ...overrides, +}); + +describe('hardware UI event state machine', () => { + it('keeps Passphrase visible when an old PIN completion arrives late', () => { + let state = createHardwareUiEventState(); + + let result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.REQUEST_PIN, + renderAction: EHardwareUiStateAction.EnterPinOnDevice, + connectId: 'PRO2_USB', + payload: { + interaction: createInteraction(), + }, + }); + state = result.state; + + result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + payload: createInteraction({ sequence: 2, transition: 'complete' }), + }); + expect(result.action).toBe(EHardwareUiStateAction.ProcessLoading); + expect(result.state.phase).toBe('processing'); + state = result.state; + + result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.REQUEST_PASSPHRASE, + renderAction: EHardwareUiStateAction.REQUEST_PASSPHRASE, + connectId: 'PRO2_USB', + payload: { + interaction: createInteraction({ + phase: 'passphrase', + phaseId: 'passphrase-phase', + sequence: 3, + }), + }, + }); + state = result.state; + + result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + payload: createInteraction({ sequence: 4, transition: 'complete' }), + }); + + expect(result.applied).toBe(false); + expect(result.action).toBeUndefined(); + expect(result.state.phase).toBe('passphrase'); + }); + + it('serializes async handlers and continues after one handler fails', async () => { + const queue = new HardwareUiEventQueue(); + const order: string[] = []; + let releaseFirst: (() => void) | undefined; + const firstFinished = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = queue.enqueue('pin', async () => { + order.push('pin:start'); + await firstFinished; + order.push('pin:end'); + throw new OneKeyLocalError('expected failure'); + }); + const second = queue.enqueue('passphrase', async () => { + order.push('passphrase'); + }); + + await Promise.resolve(); + expect(order).toEqual(['pin:start']); + + releaseFirst?.(); + await expect(first).rejects.toThrow('expected failure'); + await expect(second).resolves.toBeUndefined(); + expect(order).toEqual(['pin:start', 'pin:end', 'passphrase']); + }); + + it('invalidates a running handler after reset', async () => { + const queue = new HardwareUiEventQueue(); + let releaseHandler: (() => void) | undefined; + const handlerReleased = new Promise((resolve) => { + releaseHandler = resolve; + }); + let isCurrentAfterReset = true; + + const task = queue.enqueue('pin', async (_event, { isCurrent }) => { + await handlerReleased; + isCurrentAfterReset = isCurrent(); + }); + + await Promise.resolve(); + queue.reset(); + releaseHandler?.(); + await task; + + expect(isCurrentAfterReset).toBe(false); + }); + + it('supports Protocol V1 PIN completion without interaction metadata', () => { + let state = createHardwareUiEventState(); + state = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.REQUEST_PIN, + renderAction: EHardwareUiStateAction.REQUEST_PIN, + connectId: 'CLASSIC_USB', + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + }); + + expect(result.applied).toBe(true); + expect(result.action).toBe(EHardwareUiStateAction.ProcessLoading); + expect(result.connectId).toBe('CLASSIC_USB'); + }); + + test.each([ + [ + EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + EHardwareUiStateAction.REQUEST_PIN, + 'pin', + ], + [ + EHardwareUiStateAction.CLOSE_UI_WINDOW, + EHardwareUiStateAction.REQUEST_PASSPHRASE, + 'passphrase', + ], + ] as const)( + 'ignores metadata-less %s from a previous V1 device', + (closeType, requestType, phase) => { + let state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_PIN, + renderAction: EHardwareUiStateAction.REQUEST_PIN, + connectId: 'CLASSIC_USB', + }).state; + state = reduceHardwareUiEventState(state, { + type: requestType, + renderAction: requestType, + connectId: 'PRO2_USB', + payload: { + interaction: createInteraction({ + interactionId: 'interaction-2', + phase, + phaseId: `${phase}-phase`, + }), + }, + }).state; + + const result = reduceHardwareUiEventState(state, { + type: closeType, + renderAction: closeType, + }); + + expect(result.applied).toBe(false); + expect(result.state.connectId).toBe('PRO2_USB'); + expect(result.state.phase).toBe(phase); + }, + ); + + it('ignores a stale close event from an older interaction', () => { + const state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_PASSPHRASE, + renderAction: EHardwareUiStateAction.REQUEST_PASSPHRASE, + connectId: 'PRO2_USB', + payload: { + interaction: createInteraction({ + interactionId: 'interaction-2', + phase: 'passphrase', + phaseId: 'passphrase-phase', + }), + }, + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_WINDOW, + payload: createInteraction({ + interactionId: 'interaction-1', + sequence: 4, + transition: 'finish', + }), + }); + + expect(result.applied).toBe(false); + expect(result.state.phase).toBe('passphrase'); + }); + + it('accepts the final metadata-less close after V2 progress reopens a closed interaction', () => { + let state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB', + payload: { + interaction: createInteraction({ phase: 'button' }), + }, + }).state; + state = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_WINDOW, + payload: createInteraction({ + phase: 'button', + sequence: 2, + transition: 'finish', + }), + }).state; + + expect(state.phase).toBe('closed'); + + state = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.DEVICE_PROGRESS, + renderAction: EHardwareUiStateAction.DEVICE_PROGRESS, + payload: { progress: 100 }, + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_WINDOW, + }); + + expect(result.applied).toBe(true); + expect(result.action).toBe(EHardwareUiStateAction.CLOSE_UI_WINDOW); + expect(result.state.phase).toBe('closed'); + expect(result.connectId).toBe('PRO2_USB'); + }); + + it('accepts a new device after the previous interaction closes', () => { + let state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB_A', + payload: { + interaction: createInteraction({ phase: 'button' }), + }, + }).state; + state = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.CLOSE_UI_WINDOW, + renderAction: EHardwareUiStateAction.CLOSE_UI_WINDOW, + payload: createInteraction({ sequence: 2, transition: 'finish' }), + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB_B', + payload: { + interaction: createInteraction({ + interactionId: 'interaction-2', + phase: 'button', + }), + }, + }); + + expect(result.applied).toBe(true); + expect(result.state.connectId).toBe('PRO2_USB_B'); + expect(result.state.phase).toBe('button'); + }); + + it('lets a new device request replace a stale open interaction', () => { + const state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB_A', + payload: { + interaction: createInteraction({ phase: 'button' }), + }, + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB_B', + payload: { + interaction: createInteraction({ + interactionId: 'interaction-2', + phase: 'button', + }), + }, + }); + + expect(result.applied).toBe(true); + expect(result.state.connectId).toBe('PRO2_USB_B'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.ts b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.ts new file mode 100644 index 000000000000..0eccfa6fb1d5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.ts @@ -0,0 +1,266 @@ +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; + +export type IHardwareUiEventPhase = + | 'idle' + | 'pin' + | 'processing' + | 'passphrase' + | 'passphrase-on-device' + | 'button' + | 'closed'; + +type IHardwareUiInteractionMeta = { + interactionId: string; + phaseId: string; + sequence: number; + phase: + | 'pin' + | 'passphrase' + | 'passphrase-on-device' + | 'button' + | 'processing'; + transition: 'start' | 'complete' | 'finish'; + protocol: 'V2'; +}; + +export type IHardwareUiEventState = { + phase: IHardwareUiEventPhase; + connectId?: string; + interactionId?: string; + phaseId?: string; + lastSequence?: number; +}; + +type IReduceHardwareUiEventParams = { + type: EHardwareUiStateAction; + renderAction: EHardwareUiStateAction; + connectId?: string; + payload?: unknown; +}; + +export type IHardwareUiEventReduction = { + state: IHardwareUiEventState; + applied: boolean; + action?: EHardwareUiStateAction; + connectId?: string; + shouldClearUiState?: boolean; +}; + +const REQUEST_PHASES: Partial< + Record +> = { + [EHardwareUiStateAction.REQUEST_PIN]: 'pin', + [EHardwareUiStateAction.REQUEST_PASSPHRASE]: 'passphrase', + [EHardwareUiStateAction.REQUEST_PASSPHRASE_ON_DEVICE]: 'passphrase-on-device', + [EHardwareUiStateAction.REQUEST_BUTTON]: 'button', +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object'; + +const getInteraction = ( + payload: unknown, +): IHardwareUiInteractionMeta | undefined => { + if (!isRecord(payload)) { + return undefined; + } + const candidate = isRecord(payload.interaction) + ? payload.interaction + : payload; + if ( + typeof candidate.interactionId !== 'string' || + typeof candidate.phaseId !== 'string' || + typeof candidate.sequence !== 'number' || + candidate.protocol !== 'V2' + ) { + return undefined; + } + return candidate as IHardwareUiInteractionMeta; +}; + +const getPayloadConnectId = (payload: unknown): string | undefined => { + if (!isRecord(payload) || !isRecord(payload.device)) { + return undefined; + } + return typeof payload.device.connectId === 'string' + ? payload.device.connectId + : undefined; +}; + +const isSameDevice = ( + state: IHardwareUiEventState, + connectId: string | undefined, +) => !state.connectId || !connectId || state.connectId === connectId; + +const isNewerEvent = ( + state: IHardwareUiEventState, + interaction: IHardwareUiInteractionMeta | undefined, +) => { + if (!interaction) { + return true; + } + if ( + state.interactionId && + state.interactionId !== interaction.interactionId + ) { + return true; + } + return ( + state.lastSequence === undefined || + interaction.sequence > state.lastSequence + ); +}; + +const applyInteraction = ( + state: IHardwareUiEventState, + interaction: IHardwareUiInteractionMeta | undefined, +): IHardwareUiEventState => { + if (!interaction) { + return state; + } + return { + ...state, + interactionId: interaction.interactionId, + phaseId: interaction.phaseId, + lastSequence: interaction.sequence, + }; +}; + +export const createHardwareUiEventState = (): IHardwareUiEventState => ({ + phase: 'idle', +}); + +export const reduceHardwareUiEventState = ( + state: IHardwareUiEventState, + event: IReduceHardwareUiEventParams, +): IHardwareUiEventReduction => { + const interaction = getInteraction(event.payload); + const connectId = + event.connectId ?? getPayloadConnectId(event.payload) ?? state.connectId; + const requestedPhase = REQUEST_PHASES[event.type]; + const isDifferentDevice = + Boolean(state.connectId) && + Boolean(connectId) && + state.connectId !== connectId; + const canSwitchDevice = + isDifferentDevice && (state.phase === 'closed' || Boolean(requestedPhase)); + const currentState = canSwitchDevice ? createHardwareUiEventState() : state; + const isCloseEvent = + event.type === EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW || + event.type === EHardwareUiStateAction.CLOSE_UI_WINDOW; + + if ( + (isCloseEvent && + !interaction && + Boolean(currentState.interactionId) && + currentState.phase !== 'closed') || + !isSameDevice(currentState, connectId) || + !isNewerEvent(currentState, interaction) + ) { + return { state, applied: false }; + } + + if (requestedPhase) { + const nextState = applyInteraction( + { + ...currentState, + phase: requestedPhase, + connectId, + }, + interaction, + ); + return { + state: nextState, + applied: true, + action: event.renderAction, + connectId, + }; + } + + if (event.type === EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW) { + const phaseMatches = currentState.phase === 'pin'; + const interactionMatches = interaction + ? interaction.phase === 'pin' && + (!currentState.interactionId || + currentState.interactionId === interaction.interactionId) && + (!currentState.phaseId || currentState.phaseId === interaction.phaseId) + : true; + if (!phaseMatches || !interactionMatches) { + return { state, applied: false }; + } + + const nextState = applyInteraction( + { + ...currentState, + phase: 'processing', + }, + interaction, + ); + return { + state: nextState, + applied: true, + action: EHardwareUiStateAction.ProcessLoading, + connectId, + }; + } + + if (event.type === EHardwareUiStateAction.CLOSE_UI_WINDOW) { + if ( + interaction && + currentState.interactionId && + currentState.interactionId !== interaction.interactionId + ) { + return { state, applied: false }; + } + return { + state: applyInteraction( + { + ...currentState, + phase: 'closed', + }, + interaction, + ), + applied: true, + action: event.renderAction, + connectId, + shouldClearUiState: Boolean(interaction), + }; + } + + return { + state: currentState, + applied: true, + action: event.renderAction, + connectId, + }; +}; + +export class HardwareUiEventQueue { + private tail: Promise = Promise.resolve(); + + private generation = 0; + + enqueue( + event: TEvent, + handler: ( + event: TEvent, + context: { isCurrent: () => boolean }, + ) => void | Promise, + ): Promise { + const generation = this.generation; + const isCurrent = () => generation === this.generation; + const task = this.tail.then(async () => { + if (!isCurrent()) { + return; + } + await handler(event, { isCurrent }); + }); + this.tail = task.catch(() => undefined); + return task; + } + + reset() { + this.generation += 1; + this.tail = Promise.resolve(); + } +} diff --git a/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.test.ts b/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.test.ts new file mode 100644 index 000000000000..d00322f84fb6 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.test.ts @@ -0,0 +1,62 @@ +import { copyWalletSessionUiMetadata } from './hardwareUiPayloadUtils'; + +describe('copyWalletSessionUiMetadata', () => { + test('保留旧字段并透传钱包会话协调器元数据', () => { + const target = { + passphraseState: 'state-a', + } as any; + + expect( + copyWalletSessionUiMetadata(target, { + existsAttachPinUser: true, + deviceOnly: true, + source: 'wallet-session-coordinator', + reason: 'session-recovery', + expectedPassphraseState: 'state-a', + }), + ).toMatchObject({ + passphraseState: 'state-a', + existsAttachPinUser: true, + deviceOnly: true, + source: 'wallet-session-coordinator', + reason: 'session-recovery', + expectedPassphraseState: 'state-a', + }); + }); + + test('为 Pro2 Host 输入保留 deviceOnly=false', () => { + const target = {} as any; + + expect( + copyWalletSessionUiMetadata(target, { + existsAttachPinUser: true, + deviceOnly: false, + source: 'wallet-session-coordinator', + reason: 'open-wallet', + }), + ).toMatchObject({ + existsAttachPinUser: true, + deviceOnly: false, + source: 'wallet-session-coordinator', + reason: 'open-wallet', + }); + }); + + test('透传 Session 恢复的钱包标识和原因', () => { + const target = {} as any; + + expect( + copyWalletSessionUiMetadata(target, { + deviceOnly: false, + source: 'wallet-session-coordinator', + reason: 'session-recovery', + expectedPassphraseState: 'expected-state', + }), + ).toMatchObject({ + deviceOnly: false, + source: 'wallet-session-coordinator', + reason: 'session-recovery', + expectedPassphraseState: 'expected-state', + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.ts b/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.ts new file mode 100644 index 000000000000..0a2a5103d918 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiPayloadUtils.ts @@ -0,0 +1,21 @@ +import type { IHardwareUiPayload } from '../../states/jotai/atoms'; + +type IWalletSessionUiMetadata = { + existsAttachPinUser?: boolean; + deviceOnly?: boolean; + source?: 'wallet-session-coordinator'; + reason?: 'open-wallet' | 'session-recovery'; + expectedPassphraseState?: string; +}; + +export function copyWalletSessionUiMetadata( + target: IHardwareUiPayload, + source: IWalletSessionUiMetadata, +) { + target.existsAttachPinUser = source.existsAttachPinUser; + target.deviceOnly = source.deviceOnly; + target.source = source.source; + target.reason = source.reason; + target.expectedPassphraseState = source.expectedPassphraseState; + return target; +} diff --git a/packages/kit-bg/src/services/ServiceHardware/jpegRgbaStartupGraph.test.ts b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaStartupGraph.test.ts new file mode 100644 index 000000000000..dc34e9e0f8b7 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaStartupGraph.test.ts @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; + +import { parse } from '@babel/parser'; + +const EAGER_HARDWARE_SERVICE_FILES = [ + 'ServiceHardware.ts', + 'DeviceSettingsManager.ts', +] as const; + +describe('hardware JPEG startup graph', () => { + test.each(EAGER_HARDWARE_SERVICE_FILES)( + '%s does not eagerly import the JPEG decoder', + (fileName) => { + const sourcePath = path.resolve(__dirname, fileName); + const sourceFile = parse(fs.readFileSync(sourcePath, 'utf8'), { + sourceFilename: sourcePath, + sourceType: 'module', + plugins: ['decorators-legacy', 'typescript'], + }); + const eagerImports = sourceFile.program.body + .filter( + (statement) => + statement.type === 'ImportDeclaration' && + statement.source.value === './jpegRgbaUtils', + ) + .map((statement) => `${fileName}:${statement.loc?.start.line ?? 0}`); + + expect(eagerImports).toEqual([]); + }, + ); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.test.ts b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.test.ts new file mode 100644 index 000000000000..8cd3e1164ad6 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.test.ts @@ -0,0 +1,99 @@ +import { decodeJpegToRgba } from './jpegRgbaUtils'; + +jest.mock('jpeg-js', () => ({ + decode: jest.fn(), +})); + +const mockDecode = jest.requireMock('jpeg-js').decode as jest.Mock; + +describe('decodeJpegToRgba', () => { + beforeEach(() => { + mockDecode.mockReset(); + mockDecode.mockReturnValue({ + width: 2, + height: 1, + data: new Uint8Array(8), + }); + }); + + it('returns validated RGBA pixels for any JPEG resource', () => { + expect( + decodeJpegToRgba({ + imageHex: 'ffd8ff', + expectedWidth: 2, + expectedHeight: 1, + label: 'NFT image', + }), + ).toMatchObject({ width: 2, height: 1, data: expect.any(Uint8Array) }); + expect(mockDecode).toHaveBeenCalledWith( + Buffer.from('ffd8ff', 'hex'), + expect.objectContaining({ + maxMemoryUsageInMB: 32, + maxResolutionInMP: 1, + }), + ); + }); + + it('rejects empty image data', () => { + expect(() => + decodeJpegToRgba({ + imageHex: '', + expectedWidth: 2, + expectedHeight: 1, + label: 'wallpaper', + }), + ).toThrow('image is empty'); + }); + + it('rejects an unexpected image size', () => { + expect(() => + decodeJpegToRgba({ + imageHex: 'ffd8ff', + expectedWidth: 1, + expectedHeight: 1, + label: 'NFT image', + }), + ).toThrow('expected 1x1'); + }); + + it('rejects malformed hex before decoding', () => { + expect(() => + decodeJpegToRgba({ + imageHex: 'not-hex', + expectedWidth: 2, + expectedHeight: 1, + label: 'NFT image', + }), + ).toThrow('image hex is invalid'); + expect(mockDecode).not.toHaveBeenCalled(); + }); + + it('rejects an oversized encoded image before decoding', () => { + expect(() => + decodeJpegToRgba({ + imageHex: 'aa'.repeat(64 * 1024 + 17), + expectedWidth: 2, + expectedHeight: 1, + label: 'wallpaper', + }), + ).toThrow('image is too large'); + expect(mockDecode).not.toHaveBeenCalled(); + }); + + it('rejects an invalid RGBA buffer length', () => { + mockDecode.mockReturnValue({ + width: 2, + height: 1, + data: new Uint8Array(4), + }); + + expect(() => + decodeJpegToRgba({ + imageHex: 'ffd8ff', + expectedWidth: 2, + expectedHeight: 1, + label: 'wallpaper', + }), + ).toThrow('RGBA length'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.ts b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.ts new file mode 100644 index 000000000000..de9893f81e5f --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/jpegRgbaUtils.ts @@ -0,0 +1,58 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +const jpeg = require('jpeg-js') as { + decode: ( + data: Uint8Array, + options: { + useTArray: true; + formatAsRGBA: true; + maxResolutionInMP: number; + maxMemoryUsageInMB: number; + }, + ) => { width: number; height: number; data: Uint8Array }; +}; + +const JPEG_MAX_RESOLUTION_IN_MP = 1; +const JPEG_MAX_MEMORY_USAGE_IN_MB = 32; +const JPEG_CONTAINER_OVERHEAD_BYTES = 64 * 1024; + +export function decodeJpegToRgba({ + imageHex, + expectedWidth, + expectedHeight, + label, +}: { + imageHex: string; + expectedWidth: number; + expectedHeight: number; + label: string; +}) { + if (!imageHex) { + throw new OneKeyLocalError(`Upload ${label} error: image is empty`); + } + if (!/^(?:[0-9a-fA-F]{2})+$/.test(imageHex)) { + throw new OneKeyLocalError(`Upload ${label} error: image hex is invalid`); + } + const maxEncodedBytes = + expectedWidth * expectedHeight * 8 + JPEG_CONTAINER_OVERHEAD_BYTES; + if (imageHex.length / 2 > maxEncodedBytes) { + throw new OneKeyLocalError(`Upload ${label} error: image is too large`); + } + const decoded = jpeg.decode(Buffer.from(imageHex, 'hex'), { + useTArray: true, + formatAsRGBA: true, + maxResolutionInMP: JPEG_MAX_RESOLUTION_IN_MP, + maxMemoryUsageInMB: JPEG_MAX_MEMORY_USAGE_IN_MB, + }); + if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) { + throw new OneKeyLocalError( + `Invalid ${label} size: ${decoded.width}x${decoded.height}, expected ${expectedWidth}x${expectedHeight}`, + ); + } + if (decoded.data.byteLength !== expectedWidth * expectedHeight * 4) { + throw new OneKeyLocalError( + `Invalid ${label} RGBA length: ${decoded.data.byteLength}`, + ); + } + return decoded; +} diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts new file mode 100644 index 000000000000..4e48e212de04 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts @@ -0,0 +1,1009 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; +import { debounce, uniq } from 'lodash'; + +import { + backgroundClass, + backgroundMethod, +} from '@onekeyhq/shared/src/background/backgroundDecorators'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; +import { PORTFOLIO_ARCHIVE_MAX_BYTES } from '@onekeyhq/shared/src/utils/portfolioArchive'; +import { + EHardwareCallContext, + EHardwareVendor, +} from '@onekeyhq/shared/types/device'; +import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; + +import localDb from '../../../dbs/local/localDb'; +import { + currencyPersistAtom, + settingsPersistAtom, +} from '../../../states/jotai/atoms'; +import ServiceBase from '../../ServiceBase'; + +import { + buildPortfolioSyncArtifacts, + getPortfolioDisplayTimestamp, + getPortfolioSyncCooldownRemainingMs, +} from './serviceHardwarePortfolioSyncUtils'; + +import type { + IPortfolioSyncArtifacts, + IPortfolioSyncSettledPayload, +} from './serviceHardwarePortfolioSyncUtils'; + +export type IPortfolioSyncStatus = + | 'cooldown' + | 'disabled' + | 'duplicate' + | 'empty' + | 'error' + | 'hardware-busy' + | 'uploaded'; + +export type IPortfolioSyncLastResult = { + contentHash?: string; + cooldownRemainingMs?: number; + deviceConnectId?: string; + errorMessage?: string; + mockArchiveBytesLength?: number; + upload?: { portfolioUpdated: boolean }; + portfolioJsonBytesLength?: number; + serverSubmit?: { + bytesLength: number; + contentHash: string; + serverPackageBase64Length: number; + serverPackageBytesLength: number; + }; + status: IPortfolioSyncStatus; + tokenCount?: number; + totalTokenCount?: number; + updatedAt: number; + walletId?: string; +}; + +type IPortfolioServerSubmitResult = NonNullable< + IPortfolioSyncLastResult['serverSubmit'] +>; + +const LOG_PREFIX = '[PRO2-PORTFOLIO-SYNC]'; +const PORTFOLIO_SYNC_HARDWARE_BUSY_RETRY_MS = 1000; +const PORTFOLIO_PACKAGE_MAX_BYTES = PORTFOLIO_ARCHIVE_MAX_BYTES * 2; +const PORTFOLIO_PACKAGE_MAX_BASE64_LENGTH = + Math.ceil(PORTFOLIO_PACKAGE_MAX_BYTES / 3) * 4; + +export function decodePortfolioPackageBase64(packageBase64: string) { + if (packageBase64.length > PORTFOLIO_PACKAGE_MAX_BASE64_LENGTH) { + throw new OneKeyLocalError('Portfolio pack response is too large'); + } + if ( + packageBase64.length % 4 !== 0 || + !/^[A-Za-z0-9+/]+={0,2}$/.test(packageBase64) + ) { + throw new OneKeyLocalError('Portfolio pack response is invalid'); + } + + const packageBuffer = bufferUtils.toBuffer(packageBase64, 'base64'); + if (packageBuffer.byteLength > PORTFOLIO_PACKAGE_MAX_BYTES) { + throw new OneKeyLocalError('Portfolio pack response is too large'); + } + + const packageBytes = new ArrayBuffer(packageBuffer.byteLength); + new Uint8Array(packageBytes).set(packageBuffer); + return packageBytes; +} + +function stringifyLogValue(value: unknown) { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ + stringifyError: error instanceof Error ? error.message : String(error), + }); + } +} + +function debugPortfolioSyncLog(label: string, value?: unknown) { + if (process.env.NODE_ENV === 'production') { + return; + } + + const valueText = value === undefined ? '' : ` ${stringifyLogValue(value)}`; + defaultLogger.hardware.sdkLog.log(`${LOG_PREFIX} ${label}`, valueText.trim()); +} + +@backgroundClass() +class ServiceHardwarePortfolioSync extends ServiceBase { + private initialized = false; + + // Per-target dedup hash for a snapshot whose async submit/upload is still in + // flight. Runtime-only: a stuck reservation must not survive a restart. The + // durable last-synced hash + cooldown timestamp live in simpleDb + // (hardwarePortfolioSync), keyed per device so multiple simultaneously + // connected devices keep independent dedup/cooldown state. + private inFlightReservationByTargetKey = new Map< + string, + { contentHash: string; generation: number } + >(); + + private syncGenerationByTargetKey = new Map(); + + private notificationSequence = 0; + + private latestNotificationSequenceByWalletId = new Map(); + + private lastArtifacts: IPortfolioSyncArtifacts | undefined; + + private lastResult: IPortfolioSyncLastResult | undefined; + + private pendingCooldownPayloadByConnectId = new Map< + string, + IPortfolioSyncSettledPayload + >(); + + private pendingCooldownTimerByConnectId = new Map< + string, + ReturnType + >(); + + private pendingHardwareRetryTimerByConnectId = new Map< + string, + ReturnType + >(); + + private activeUploadByTargetKey = new Map>(); + + private targetKeyByConnectId = new Map(); + + private syncDebouncedByTargetKey = new Map< + string, + ReturnType + >(); + + constructor({ backgroundApi }: { backgroundApi: any }) { + super({ backgroundApi }); + } + + init() { + if (this.initialized) { + return; + } + this.initialized = true; + debugPortfolioSyncLog('service-init'); + } + + private async resolveAuthorizedPortfolioPayload( + eventPayload: IPortfolioSyncSettledPayload, + ): Promise { + const walletId = eventPayload.walletId; + if (!walletId) { + return undefined; + } + const wallet = await localDb.getWalletSafe({ walletId }); + if ( + !wallet || + wallet.id !== walletId || + !accountUtils.isHwWallet({ walletId: wallet.id }) + ) { + return undefined; + } + + const device = await localDb.getWalletDeviceSafe({ + dbWallet: wallet, + walletId: wallet.id, + }); + const vendor = device?.vendor ?? device?.settings?.vendor; + const isProtocolV2 = + device?.connectProtocol === 'V2' || + device?.deviceStateInfo?.protocol === 'V2'; + if ( + !device || + device.deviceType !== EDeviceType.Pro2 || + !isProtocolV2 || + vendor !== EHardwareVendor.onekey + ) { + return undefined; + } + + const authorizedConnectIds = uniq( + [device.connectId, device.usbConnectId, device.bleConnectId].filter( + Boolean, + ), + ); + if ( + !device.connectId || + (eventPayload.deviceDbId && eventPayload.deviceDbId !== device.id) || + (eventPayload.deviceConnectId && + !authorizedConnectIds.includes(eventPayload.deviceConnectId)) + ) { + return undefined; + } + + for (const authorizedConnectId of authorizedConnectIds) { + this.targetKeyByConnectId.set(authorizedConnectId, device.id); + } + + // All Networks account IDs are runtime-only aggregate accounts. Validate + // ownership with the stable indexed account and rebuild display fields. + const indexedAccountId = eventPayload.indexedAccountId; + if (!indexedAccountId) { + return undefined; + } + const indexedAccount = await localDb.getIndexedAccountSafe({ + id: indexedAccountId, + }); + if (!indexedAccount || indexedAccount.walletId !== wallet.id) { + return undefined; + } + + return { + ...eventPayload, + accountAddress: undefined, + accountName: indexedAccount.name, + deviceConnectId: device.connectId, + deviceDbId: device.id, + indexedAccountId: indexedAccount.id, + indexedAccountIndex: indexedAccount.index, + indexedAccountName: indexedAccount.name, + walletId: wallet.id, + walletType: wallet.type, + }; + } + + private setRejectedPayloadResult(eventPayload: IPortfolioSyncSettledPayload) { + this.setLastResult({ + status: 'disabled', + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + @backgroundMethod() + async notifyAllNetworksTokenListSettled( + eventPayload: IPortfolioSyncSettledPayload, + ) { + const walletId = eventPayload.walletId ?? ''; + this.notificationSequence += 1; + const sequence = this.notificationSequence; + this.latestNotificationSequenceByWalletId.set(walletId, sequence); + const authorizedPayload = + await this.resolveAuthorizedPortfolioPayload(eventPayload); + if (this.latestNotificationSequenceByWalletId.get(walletId) !== sequence) { + return; + } + this.latestNotificationSequenceByWalletId.delete(walletId); + if (!authorizedPayload) { + this.setRejectedPayloadResult(eventPayload); + return; + } + this.handleAllNetworksTokenListSettled(authorizedPayload); + } + + private handleAllNetworksTokenListSettled = ( + eventPayload: IPortfolioSyncSettledPayload, + ) => { + if (eventPayload.deviceConnectId) { + this.cancelHardwareBusyRetry(eventPayload.deviceConnectId); + } + debugPortfolioSyncLog('settled-event', { + hasDeviceConnectId: Boolean(eventPayload.deviceConnectId), + isHardwareWallet: accountUtils.isHwWallet({ + walletId: eventPayload.walletId, + }), + totalTokenCount: eventPayload.tokens.length, + }); + const targetKey = this.getSyncTargetKey(eventPayload); + this.advanceSyncGeneration(targetKey); + let syncDebounced = this.syncDebouncedByTargetKey.get(targetKey); + if (!syncDebounced) { + syncDebounced = debounce((payload: IPortfolioSyncSettledPayload) => { + this.syncDebouncedByTargetKey.delete(targetKey); + const generation = this.syncGenerationByTargetKey.get(targetKey); + if (generation !== undefined) { + void this.syncSettledPortfolio(payload, generation); + } + }, 1000); + this.syncDebouncedByTargetKey.set(targetKey, syncDebounced); + } + syncDebounced(eventPayload); + }; + + private setLastResult(result: IPortfolioSyncLastResult) { + this.lastResult = result; + } + + private get portfolioSyncDb() { + return this.backgroundApi.simpleDb.hardwarePortfolioSync; + } + + // Prefer the persisted device record so USB/BLE transports and hidden-wallet + // views of the same physical device share one ordering domain. + private getSyncTargetKey(eventPayload: IPortfolioSyncSettledPayload): string { + return ( + eventPayload.deviceDbId || + eventPayload.deviceConnectId || + eventPayload.walletId || + '' + ); + } + + private advanceSyncGeneration(targetKey: string) { + const generation = (this.syncGenerationByTargetKey.get(targetKey) ?? 0) + 1; + this.syncGenerationByTargetKey.set(targetKey, generation); + this.inFlightReservationByTargetKey.delete(targetKey); + return generation; + } + + private isCurrentSyncGeneration(targetKey: string, generation: number) { + return this.syncGenerationByTargetKey.get(targetKey) === generation; + } + + private releaseInFlightReservation({ + contentHash, + generation, + targetKey, + }: { + contentHash: string; + generation: number; + targetKey: string; + }) { + const reservation = this.inFlightReservationByTargetKey.get(targetKey); + if ( + reservation?.contentHash === contentHash && + reservation.generation === generation + ) { + this.inFlightReservationByTargetKey.delete(targetKey); + } + } + + private handleSyncError({ + contentHash, + error, + generation, + targetKey, + }: { + contentHash?: string; + error: unknown; + generation: number; + targetKey: string; + }) { + if (contentHash) { + this.releaseInFlightReservation({ contentHash, generation, targetKey }); + } + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + const errorMessage = error instanceof Error ? error.message : String(error); + debugPortfolioSyncLog('error', { message: errorMessage }); + this.setLastResult({ + errorMessage, + status: 'error', + updatedAt: Date.now(), + }); + } + + private async commitProcessedArtifacts({ + artifacts, + generation, + targetKey, + transferAt, + walletId, + }: { + artifacts: IPortfolioSyncArtifacts; + generation: number; + targetKey: string; + transferAt?: number; + walletId: string; + }) { + // Persist only the latest generation after a successful device upload. + // Compare-and-delete keeps stale cleanup from clearing a newer reservation. + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + await this.portfolioSyncDb.updateTargetState(targetKey, { + lastContentHash: artifacts.contentHash, + ...(transferAt !== undefined ? { lastTransferAt: transferAt } : {}), + lastWalletId: walletId, + }); + if (this.isCurrentSyncGeneration(targetKey, generation)) { + this.lastArtifacts = artifacts; + } + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + } + + private scheduleSyncAfterCooldown({ + deviceConnectId, + eventPayload, + generation, + remainingMs, + targetKey, + }: { + deviceConnectId: string; + eventPayload: IPortfolioSyncSettledPayload; + generation: number; + remainingMs: number; + targetKey: string; + }) { + this.pendingCooldownPayloadByConnectId.set(deviceConnectId, eventPayload); + + const existingTimer = + this.pendingCooldownTimerByConnectId.get(deviceConnectId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.pendingCooldownTimerByConnectId.delete(deviceConnectId); + const pendingPayload = + this.pendingCooldownPayloadByConnectId.get(deviceConnectId); + this.pendingCooldownPayloadByConnectId.delete(deviceConnectId); + if ( + pendingPayload && + this.isCurrentSyncGeneration(targetKey, generation) + ) { + void this.syncSettledPortfolio(pendingPayload, generation); + } + }, remainingMs); + + this.pendingCooldownTimerByConnectId.set(deviceConnectId, timer); + } + + private cancelHardwareBusyRetry(deviceConnectId: string) { + const timer = + this.pendingHardwareRetryTimerByConnectId.get(deviceConnectId); + if (timer) { + clearTimeout(timer); + this.pendingHardwareRetryTimerByConnectId.delete(deviceConnectId); + } + } + + private scheduleHardwareBusyRetry({ + contentHash, + deviceConnectId, + generation, + retry, + targetKey, + }: { + contentHash: string; + deviceConnectId: string; + generation: number; + retry: () => Promise; + targetKey: string; + }) { + this.cancelHardwareBusyRetry(deviceConnectId); + const timer = setTimeout(() => { + this.pendingHardwareRetryTimerByConnectId.delete(deviceConnectId); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash, + generation, + targetKey, + }); + return; + } + void retry().catch((error) => { + this.handleSyncError({ + contentHash, + error, + generation, + targetKey, + }); + }); + }, PORTFOLIO_SYNC_HARDWARE_BUSY_RETRY_MS); + this.pendingHardwareRetryTimerByConnectId.set(deviceConnectId, timer); + } + + private async getHardwareCooldownRemainingMs({ + targetKey, + now, + }: { + targetKey: string; + now: number; + }) { + const state = await this.portfolioSyncDb.getTargetState(targetKey); + return getPortfolioSyncCooldownRemainingMs({ + lastTransferAt: state?.lastTransferAt, + now, + }); + } + + private async getCurrencyMapForBuild() { + let { currencyMap } = await currencyPersistAtom.get(); + const settings = await settingsPersistAtom.get(); + if (!currencyMap[settings.currencyInfo.id]) { + try { + await this.backgroundApi.serviceSetting.fetchCurrencyList(); + currencyMap = (await currencyPersistAtom.get()).currencyMap; + } catch { + // Strict conversion will emit null values if the rate is still absent. + } + } + return { + currencyMap, + displayCurrency: settings.currencyInfo, + }; + } + + private buildResultBase({ + artifacts, + eventPayload, + serverSubmit, + status, + updatedAt, + }: { + artifacts: IPortfolioSyncArtifacts; + eventPayload: IPortfolioSyncSettledPayload; + serverSubmit?: IPortfolioServerSubmitResult; + status: IPortfolioSyncStatus; + updatedAt: number; + }): IPortfolioSyncLastResult { + return { + contentHash: artifacts.contentHash, + deviceConnectId: eventPayload.deviceConnectId, + mockArchiveBytesLength: artifacts.mockArchiveBytes.byteLength, + portfolioJsonBytesLength: artifacts.portfolioJsonBytes.byteLength, + serverSubmit, + status, + tokenCount: artifacts.portfolio.tokens.length, + totalTokenCount: eventPayload.tokens.length, + updatedAt, + walletId: eventPayload.walletId, + }; + } + + private async submitPortfolioJsonToServer({ + artifacts, + }: { + artifacts: IPortfolioSyncArtifacts; + }): Promise<{ + serverPackageBytes: ArrayBuffer; + serverSubmit: IPortfolioServerSubmitResult; + }> { + const { contentHash, portfolio, portfolioJsonBytes } = artifacts; + + debugPortfolioSyncLog('server-submit-ready', { + bytesLength: portfolioJsonBytes.byteLength, + contentHash, + tokenCount: artifacts.portfolio.tokens.length, + totalTokenCount: + artifacts.portfolio.tokenCount + artifacts.portfolio.otherTokens.count, + }); + + // The App only submits portfolio.json. The server validates, normalizes, + // resolves trusted token metadata such as iconName and color, packs and + // signs the production portfolio package, and returns it as base64. + const client = await this.getClient(EServiceEndpointEnum.Wallet); + const resp = await client.post<{ + data: { packageBase64: string }; + }>('/wallet/v1/hardware/portfolio/pack', portfolio); + + const packageBase64 = resp.data?.data?.packageBase64; + if (!packageBase64) { + throw new OneKeyLocalError( + 'Portfolio pack response missing packageBase64', + ); + } + const serverPackageBytes = decodePortfolioPackageBase64(packageBase64); + + debugPortfolioSyncLog('server-submit-packed', { + bytesLength: portfolioJsonBytes.byteLength, + contentHash, + serverPackageBase64Length: packageBase64.length, + serverPackageBytesLength: serverPackageBytes.byteLength, + }); + + return { + serverPackageBytes, + serverSubmit: { + bytesLength: portfolioJsonBytes.byteLength, + contentHash, + serverPackageBase64Length: packageBase64.length, + serverPackageBytesLength: serverPackageBytes.byteLength, + }, + }; + } + + private async uploadPreparedHardwarePortfolio({ + artifacts, + deviceConnectId, + eventPayload, + generation, + serverPackageBytes, + serverSubmit, + targetKey, + updatedAt, + }: { + artifacts: IPortfolioSyncArtifacts; + deviceConnectId: string; + eventPayload: IPortfolioSyncSettledPayload; + generation: number; + serverPackageBytes: ArrayBuffer; + serverSubmit: IPortfolioServerSubmitResult; + targetKey: string; + updatedAt: number; + }) { + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + this.inFlightReservationByTargetKey.set(targetKey, { + contentHash: artifacts.contentHash, + generation, + }); + const activeUpload = this.activeUploadByTargetKey.get(targetKey); + if (activeUpload) { + await activeUpload.catch(() => undefined); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + } + const uploadPromise = + this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + async () => { + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + const hardwareBusy = + await this.backgroundApi.serviceHardwareUI.isHardwareChannelBusy({ + connectId: deviceConnectId, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if (hardwareBusy) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.setLastResult( + this.buildResultBase({ + artifacts, + eventPayload, + serverSubmit, + status: 'hardware-busy', + updatedAt, + }), + ); + debugPortfolioSyncLog('skip-hardware-busy', { + contentHash: artifacts.contentHash, + }); + this.scheduleHardwareBusyRetry({ + contentHash: artifacts.contentHash, + deviceConnectId, + generation, + retry: () => + this.uploadPreparedHardwarePortfolio({ + artifacts, + deviceConnectId, + eventPayload, + generation, + serverPackageBytes, + serverSubmit, + targetKey, + updatedAt: Date.now(), + }), + targetKey, + }); + return; + } + + const upload: { portfolioUpdated: boolean } = + await this.backgroundApi.serviceHardware.uploadPortfolioPackage({ + connectId: deviceConnectId, + packageBytes: serverPackageBytes, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return upload; + } + this.setLastResult({ + ...this.buildResultBase({ + artifacts, + eventPayload, + serverSubmit, + status: 'uploaded', + updatedAt, + }), + upload, + }); + debugPortfolioSyncLog('uploaded', { + bytesLength: serverPackageBytes.byteLength, + contentHash: artifacts.contentHash, + }); + if (!eventPayload.walletId) { + throw new OneKeyLocalError( + 'Authorized portfolio payload is missing walletId', + ); + } + await this.commitProcessedArtifacts({ + artifacts, + generation, + targetKey, + transferAt: Date.now(), + walletId: eventPayload.walletId, + }); + return upload; + }, + { deviceKey: targetKey }, + ); + this.activeUploadByTargetKey.set(targetKey, uploadPromise); + try { + await uploadPromise; + } finally { + if (this.activeUploadByTargetKey.get(targetKey) === uploadPromise) { + this.activeUploadByTargetKey.delete(targetKey); + } + } + } + + private async syncSettledPortfolio( + incomingPayload: IPortfolioSyncSettledPayload, + requestedGeneration?: number, + ) { + const updatedAt = Date.now(); + const eventPayload = + await this.resolveAuthorizedPortfolioPayload(incomingPayload); + if (!eventPayload) { + this.setRejectedPayloadResult(incomingPayload); + return; + } + const targetKey = this.getSyncTargetKey(eventPayload); + const generation = + requestedGeneration ?? this.advanceSyncGeneration(targetKey); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + const pendingDeviceConnectId = eventPayload.deviceConnectId; + let reservedContentHash: string | undefined; + if (pendingDeviceConnectId) { + this.cancelHardwareBusyRetry(pendingDeviceConnectId); + } + try { + const isHardwareWallet = accountUtils.isHwWallet({ + walletId: eventPayload.walletId, + }); + const deviceConnectId = eventPayload.deviceConnectId; + + if (!isHardwareWallet || !deviceConnectId) { + debugPortfolioSyncLog('skip-non-hardware'); + this.setLastResult({ + status: 'disabled', + updatedAt, + walletId: eventPayload.walletId, + }); + return; + } + + // Empty standard-wallet snapshots intentionally continue through the + // signed package flow so the device atomically overwrites stale data. + const cooldownRemainingMs = await this.getHardwareCooldownRemainingMs({ + targetKey, + now: updatedAt, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + if (cooldownRemainingMs > 0) { + this.scheduleSyncAfterCooldown({ + deviceConnectId, + eventPayload, + generation, + remainingMs: cooldownRemainingMs, + targetKey, + }); + debugPortfolioSyncLog('skip-cooldown', { + cooldownRemainingMs, + deviceConnectId, + totalTokenCount: eventPayload.tokens.length, + }); + this.setLastResult({ + cooldownRemainingMs, + deviceConnectId, + status: 'cooldown', + totalTokenCount: eventPayload.tokens.length, + updatedAt, + walletId: eventPayload.walletId, + }); + return; + } + + const { currencyMap, displayCurrency } = + await this.getCurrencyMapForBuild(); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + const artifacts = buildPortfolioSyncArtifacts({ + currencyMap, + displayCurrency, + eventPayload, + timestamp: getPortfolioDisplayTimestamp({ timestamp: updatedAt }), + }); + debugPortfolioSyncLog('portfolio-built', { + contentHash: artifacts.contentHash, + portfolioJsonBytesLength: artifacts.portfolioJsonBytes.byteLength, + tokenCount: artifacts.portfolio.tokens.length, + }); + + // Read the persisted last-synced hash for this target (await) BEFORE the + // synchronous check-and-reserve below. The in-flight read + duplicate + // check + reserve run with NO await between them, so two concurrent + // invocations for the same target either see it already reserved (and are + // deduped) or one reserves first — never both upload the same snapshot. + // The hardware path further down awaits isHardwareChannelBusy, which is + // exactly why the reservation must be taken here, not after that await. + const persistedTargetState = + await this.portfolioSyncDb.getTargetState(targetKey); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + const isDuplicate = + (eventPayload.walletId === persistedTargetState?.lastWalletId && + artifacts.contentHash === persistedTargetState?.lastContentHash) || + artifacts.contentHash === + this.inFlightReservationByTargetKey.get(targetKey)?.contentHash; + if (isDuplicate) { + debugPortfolioSyncLog('skip-duplicate', { + contentHash: artifacts.contentHash, + tokenCount: artifacts.portfolio.tokens.length, + totalTokenCount: eventPayload.tokens.length, + }); + this.setLastResult( + this.buildResultBase({ + artifacts, + eventPayload, + status: 'duplicate', + updatedAt, + }), + ); + return; + } + + this.inFlightReservationByTargetKey.set(targetKey, { + contentHash: artifacts.contentHash, + generation, + }); + reservedContentHash = artifacts.contentHash; + + const hardwareBusy = + await this.backgroundApi.serviceHardwareUI.isHardwareChannelBusy({ + connectId: deviceConnectId, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if (hardwareBusy) { + // Release the reservation and do not persist dedup state: this + // snapshot was never uploaded, so an identical settled event must be + // allowed to retry once the hardware channel frees up. + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + debugPortfolioSyncLog('skip-hardware-busy', { + contentHash: artifacts.contentHash, + }); + this.setLastResult( + this.buildResultBase({ + artifacts, + eventPayload, + status: 'hardware-busy', + updatedAt, + }), + ); + this.scheduleHardwareBusyRetry({ + contentHash: artifacts.contentHash, + deviceConnectId, + generation, + retry: () => this.syncSettledPortfolio(eventPayload, generation), + targetKey, + }); + return; + } + + await this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + () => + this.backgroundApi.serviceHardware.getDeviceState({ + connectId: deviceConnectId, + hardwareCallContext: + EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + params: { scope: 'runtime' }, + silentMode: true, + }), + { deviceKey: targetKey }, + ); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + + const { serverPackageBytes, serverSubmit } = + await this.submitPortfolioJsonToServer({ + artifacts, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + + await this.uploadPreparedHardwarePortfolio({ + artifacts, + deviceConnectId, + eventPayload, + generation, + serverPackageBytes, + serverSubmit, + targetKey, + updatedAt, + }); + } catch (error) { + this.handleSyncError({ + contentHash: reservedContentHash, + error, + generation, + targetKey, + }); + } + } + + @backgroundMethod() + async waitForActivePortfolioSync({ connectId }: { connectId: string }) { + const targetKey = this.targetKeyByConnectId.get(connectId) ?? connectId; + const activeUpload = this.activeUploadByTargetKey.get(targetKey); + if (!activeUpload) { + return false; + } + await activeUpload.catch(() => undefined); + return true; + } +} + +export default ServiceHardwarePortfolioSync; diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts new file mode 100644 index 000000000000..74d539fc84f4 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts @@ -0,0 +1,931 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Jest mock functions do not use this binding. */ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { + EHardwareCallContext, + EHardwareVendor, +} from '@onekeyhq/shared/types/device'; + +import localDb from '../../../dbs/local/localDb'; + +import ServiceHardwarePortfolioSync, { + decodePortfolioPackageBase64, +} from './ServiceHardwarePortfolioSync'; + +import type { IPortfolioSyncSettledPayload } from './serviceHardwarePortfolioSyncUtils'; +import type { IBackgroundApi } from '../../../apis/IBackgroundApi'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + AllNetworksTokenListSettled: 'AllNetworksTokenListSettled', + }, + appEventBus: { on: jest.fn(), off: jest.fn() }, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isDev: false, isJest: true }, +})); + +jest.mock('@onekeyhq/shared/src/utils/accountUtils', () => ({ + __esModule: true, + default: { + isHwWallet: jest.fn(), + shortenAddress: jest.fn(({ address }: { address: string }) => address), + }, +})); + +jest.mock('../../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getAccountSafe: jest.fn(), + getIndexedAccountSafe: jest.fn(), + getWalletDeviceSafe: jest.fn(), + getWalletSafe: jest.fn(), + }, +})); + +jest.mock('../../../states/jotai/atoms', () => ({ + currencyPersistAtom: { get: jest.fn() }, + settingsPersistAtom: { get: jest.fn() }, +})); + +describe('decodePortfolioPackageBase64', () => { + test('returns a standalone buffer for a valid package', () => { + expect( + Array.from(new Uint8Array(decodePortfolioPackageBase64('AQID'))), + ).toEqual([1, 2, 3]); + }); + + test.each(['not-base64', 'AQI', 'AQID\n'])( + 'rejects an invalid package response: %s', + (packageBase64) => { + expect(() => decodePortfolioPackageBase64(packageBase64)).toThrow( + 'response is invalid', + ); + }, + ); + + test('rejects a package larger than the signed envelope limit', () => { + const oversizedPackage = Buffer.alloc(128 * 1024 + 1).toString('base64'); + expect(() => decodePortfolioPackageBase64(oversizedPackage)).toThrow( + 'response is too large', + ); + }); +}); + +describe('ServiceHardwarePortfolioSync.waitForActivePortfolioSync', () => { + test('waits for the active upload through a transport alias', async () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + let resolveUpload: + | ((value: { portfolioUpdated: boolean }) => void) + | undefined; + const uploadPromise = new Promise<{ portfolioUpdated: boolean }>( + (resolve) => { + resolveUpload = resolve; + }, + ); + const activeUploads = new Map([['db-device-1', uploadPromise]]); + ( + service as unknown as { + activeUploadByTargetKey: Map>; + targetKeyByConnectId: Map; + } + ).activeUploadByTargetKey = activeUploads; + ( + service as unknown as { + targetKeyByConnectId: Map; + } + ).targetKeyByConnectId = new Map([ + ['PRO2_USB_ID', 'db-device-1'], + ['PRO2_BLE_ID', 'db-device-1'], + ]); + + let completed = false; + const waiting = service + .waitForActivePortfolioSync({ connectId: 'PRO2_BLE_ID' }) + .then((result) => { + completed = true; + return result; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + + resolveUpload?.({ portfolioUpdated: true }); + await expect(waiting).resolves.toBe(true); + }); + + test('returns immediately when the device has no active upload', async () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + + await expect( + service.waitForActivePortfolioSync({ connectId: 'PRO2_CONNECT_ID' }), + ).resolves.toBe(false); + }); +}); + +describe('ServiceHardwarePortfolioSync settled event debounce', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test('debounces each sync target independently', () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const serviceInternals = service as unknown as { + handleAllNetworksTokenListSettled: ( + eventPayload: IPortfolioSyncSettledPayload, + ) => void; + syncSettledPortfolio: jest.Mock< + Promise, + [IPortfolioSyncSettledPayload, number?] + >; + }; + serviceInternals.syncSettledPortfolio = jest + .fn, [IPortfolioSyncSettledPayload]>() + .mockResolvedValue(undefined); + const buildPayload = ({ + connectId, + totalFiat, + }: { + connectId: string; + totalFiat: string; + }) => + ({ + aggregateTokenMap: {}, + deviceConnectId: connectId, + totalFiat, + totalTokenCount: 0, + tokenMap: {}, + tokens: [], + walletId: `hw-${connectId}`, + walletType: 'hw', + }) as IPortfolioSyncSettledPayload; + + serviceInternals.handleAllNetworksTokenListSettled( + buildPayload({ connectId: 'PRO2_A', totalFiat: '1' }), + ); + serviceInternals.handleAllNetworksTokenListSettled( + buildPayload({ connectId: 'PRO2_B', totalFiat: '2' }), + ); + serviceInternals.handleAllNetworksTokenListSettled( + buildPayload({ connectId: 'PRO2_A', totalFiat: '3' }), + ); + + jest.advanceTimersByTime(1000); + + expect(serviceInternals.syncSettledPortfolio).toHaveBeenCalledTimes(2); + expect(serviceInternals.syncSettledPortfolio).toHaveBeenCalledWith( + expect.objectContaining({ + deviceConnectId: 'PRO2_A', + totalFiat: '3', + }), + expect.any(Number), + ); + expect(serviceInternals.syncSettledPortfolio).toHaveBeenCalledWith( + expect.objectContaining({ + deviceConnectId: 'PRO2_B', + totalFiat: '2', + }), + expect.any(Number), + ); + }); + + test('cancels an older hardware-busy retry as soon as a newer event arrives', async () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const retry = jest.fn().mockResolvedValue(undefined); + const serviceInternals = service as unknown as { + handleAllNetworksTokenListSettled: ( + eventPayload: IPortfolioSyncSettledPayload, + ) => void; + scheduleHardwareBusyRetry: (params: { + deviceConnectId: string; + retry: () => Promise; + }) => void; + syncSettledPortfolio: jest.Mock; + }; + serviceInternals.syncSettledPortfolio = jest + .fn() + .mockResolvedValue(undefined); + serviceInternals.scheduleHardwareBusyRetry({ + deviceConnectId: 'PRO2_A', + retry, + }); + + serviceInternals.handleAllNetworksTokenListSettled({ + aggregateTokenMap: {}, + deviceConnectId: 'PRO2_A', + totalFiat: '2', + totalTokenCount: 0, + tokenMap: {}, + tokens: [], + walletId: 'hw-PRO2_A', + walletType: 'hw', + } as IPortfolioSyncSettledPayload); + + await jest.advanceTimersByTimeAsync(1000); + + expect(retry).not.toHaveBeenCalled(); + expect(serviceInternals.syncSettledPortfolio).toHaveBeenCalledTimes(1); + }); +}); + +describe('ServiceHardwarePortfolioSync.syncSettledPortfolio', () => { + beforeEach(() => { + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + } as never); + jest.mocked(localDb.getWalletDeviceSafe).mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + vendor: EHardwareVendor.onekey, + } as never); + jest.mocked(localDb.getIndexedAccountSafe).mockResolvedValue({ + id: 'indexed-account-1', + index: 0, + name: 'Account #1', + walletId: 'hw-1', + } as never); + jest.mocked(localDb.getAccountSafe).mockResolvedValue({ + id: 'account-1', + address: '0x1234567890abcdef', + indexedAccountId: 'indexed-account-1', + name: 'Ethereum', + } as never); + (accountUtils.isHwWallet as jest.Mock).mockImplementation( + ({ walletId }: { walletId?: string }) => walletId?.startsWith('hw-'), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('drops an older RPC snapshot when its authorization finishes last', async () => { + let resolveOlderWallet: + | ((wallet: { id: string; name: string; type: 'hw' }) => void) + | undefined; + const olderWallet = new Promise<{ + id: string; + name: string; + type: 'hw'; + }>((resolve) => { + resolveOlderWallet = resolve; + }); + jest + .mocked(localDb.getWalletSafe) + .mockImplementationOnce(() => olderWallet as never) + .mockResolvedValueOnce({ + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + } as never); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const handleSettled = jest.fn(); + ( + service as unknown as { + handleAllNetworksTokenListSettled: typeof handleSettled; + } + ).handleAllNetworksTokenListSettled = handleSettled; + + const olderTask = service.notifyAllNetworksTokenListSettled({ + ...buildHardwarePayload(), + totalFiat: '1', + }); + await Promise.resolve(); + const newerTask = service.notifyAllNetworksTokenListSettled({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + await newerTask; + resolveOlderWallet?.({ + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + }); + await olderTask; + + expect(handleSettled).toHaveBeenCalledTimes(1); + expect(handleSettled).toHaveBeenCalledWith( + expect.objectContaining({ totalFiat: '2' }), + ); + }); + + function buildHardwarePayload() { + return { + accountAddress: '0x1234567890abcdef', + accountId: 'account-1', + aggregateTokenMap: {}, + deviceConnectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + indexedAccountId: 'indexed-account-1', + totalFiat: '0.00007276', + totalTokenCount: 1, + tokenMap: { + eth: { + balance: '0.00007276', + balanceParsed: '0.00007276', + currency: 'usd', + fiatValue: '0.1', + price: 1374.38, + }, + }, + tokens: [ + { + $key: 'eth', + address: '', + decimals: 18, + isNative: true, + name: 'Ethereum', + networkId: 'evm--1', + symbol: 'ETH', + }, + ], + walletId: 'hw-1', + walletType: 'hw', + } as unknown as IPortfolioSyncSettledPayload; + } + + function prepareHardwareSync({ + busyResults, + targetState, + }: { + busyResults: boolean[]; + targetState?: { + lastContentHash?: string; + lastTransferAt?: number; + lastWalletId?: string; + }; + }) { + let operationLeaseHeld = false; + const getDeviceState = jest.fn().mockResolvedValue({ protocol: 'V2' }); + const uploadPortfolioPackage = jest.fn( + async (_params: { connectId: string; packageBytes: ArrayBuffer }) => { + expect(operationLeaseHeld).toBe(true); + return { portfolioUpdated: true }; + }, + ); + const updateTargetState = jest.fn().mockResolvedValue(undefined); + const isHardwareChannelBusy = jest.fn(); + for (const busy of busyResults) { + isHardwareChannelBusy.mockResolvedValueOnce(busy); + } + isHardwareChannelBusy.mockResolvedValue(false); + const runExclusiveOneKeyOperation = jest.fn( + async (operation: (lease: object) => Promise) => { + operationLeaseHeld = true; + try { + return await operation({ + deviceKey: 'db-device-1', + owner: Symbol('test'), + }); + } finally { + operationLeaseHeld = false; + } + }, + ); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: { + serviceHardware: { getDeviceState, uploadPortfolioPackage }, + serviceHardwareUI: { + isHardwareChannelBusy, + runExclusiveOneKeyOperation, + }, + simpleDb: { + hardwarePortfolioSync: { + getTargetState: jest.fn().mockResolvedValue(targetState), + updateTargetState, + }, + }, + } as unknown as IBackgroundApi, + }); + const serviceInternals = service as unknown as { + getCurrencyMapForBuild: () => Promise<{ + currencyMap: Record; + displayCurrency: { id: string; symbol: string }; + }>; + getHardwareCooldownRemainingMs: () => Promise; + submitPortfolioJsonToServer: jest.Mock; + syncSettledPortfolio: ( + eventPayload: IPortfolioSyncSettledPayload, + ) => Promise; + }; + serviceInternals.getHardwareCooldownRemainingMs = jest + .fn() + .mockResolvedValue(0); + serviceInternals.getCurrencyMapForBuild = jest.fn().mockResolvedValue({ + currencyMap: {}, + displayCurrency: { id: 'usd', symbol: '$' }, + }); + serviceInternals.submitPortfolioJsonToServer = jest.fn().mockResolvedValue({ + serverPackageBytes: new Uint8Array([1, 2, 3]).buffer, + serverSubmit: { + bytesLength: 3, + contentHash: 'server-content-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 3, + }, + }); + (accountUtils.isHwWallet as jest.Mock).mockReturnValue(true); + return { + getDeviceState, + isHardwareChannelBusy, + runExclusiveOneKeyOperation, + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + }; + } + + test('uploads a signed empty standard-wallet snapshot to overwrite stale device data', async () => { + const { serviceInternals, updateTargetState, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false, false] }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '0', + totalTokenCount: 0, + tokenMap: {}, + tokens: [], + }); + + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ lastWalletId: 'hw-1' }), + ); + }); + + test('does not submit portfolio data when the device is unreachable', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false] }); + getDeviceState.mockRejectedValueOnce(new Error('Device not found')); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(getDeviceState).toHaveBeenCalledWith({ + connectId: 'PRO2_CONNECT_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + params: { scope: 'runtime' }, + silentMode: true, + }); + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + errorMessage: 'Device not found', + status: 'error', + }), + ); + }); + + test('overwrites a legacy target whose matching hash has no wallet binding', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_785_723_200_000); + const first = prepareHardwareSync({ busyResults: [false, false] }); + const emptyPayload = { + ...buildHardwarePayload(), + totalFiat: '0', + totalTokenCount: 0, + tokenMap: {}, + tokens: [], + }; + await first.serviceInternals.syncSettledPortfolio(emptyPayload); + const firstState = first.updateTargetState.mock.calls[0][1] as { + lastContentHash: string; + }; + + const migrated = prepareHardwareSync({ + busyResults: [false, false], + targetState: { lastContentHash: firstState.lastContentHash }, + }); + await migrated.serviceInternals.syncSettledPortfolio(emptyPayload); + + expect(migrated.uploadPortfolioPackage).toHaveBeenCalledTimes(1); + }); + + test('syncs the active hidden wallet to the device-level portfolio target', async () => { + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + id: 'hw-1', + name: 'Hidden Wallet', + passphraseState: 'hidden-state', + type: 'hw', + } as never); + const { serviceInternals, updateTargetState, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false] }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ lastWalletId: 'hw-1' }), + ); + }); + + test('authorizes an All Networks snapshot by indexed account when the virtual account is not persisted', async () => { + jest + .mocked(localDb.getAccountSafe) + .mockClear() + .mockResolvedValue(undefined); + const { serviceInternals, uploadPortfolioPackage } = prepareHardwareSync({ + busyResults: [false], + }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + accountAddress: 'AllNetworkMockAddress', + accountId: 'hw-1--onekeyall--0000/0', + ownerAccountId: 'hw-1--onekeyall--0000/0', + }); + + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + }); + + test('rejects frontend device identifiers that do not match the wallet device', async () => { + const { serviceInternals, uploadPortfolioPackage } = prepareHardwareSync({ + busyResults: [false], + }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + deviceDbId: 'forged-device', + }); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + }); + + test('rejects an indexed account that is not owned by the wallet', async () => { + jest.mocked(localDb.getIndexedAccountSafe).mockResolvedValue({ + id: 'indexed-account-1', + index: 0, + name: 'Account #1', + walletId: 'hw-other', + } as never); + const { serviceInternals, uploadPortfolioPackage } = prepareHardwareSync({ + busyResults: [false], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + }); + + test.each([ + ['non-Pro2', { deviceType: EDeviceType.Pro }], + ['Protocol V1', { connectProtocol: 'V1' }], + ['third-party', { vendor: EHardwareVendor.ledger }], + ['unknown-vendor', { vendor: undefined }], + ])('rejects a %s wallet device', async (_label, deviceOverride) => { + jest.mocked(localDb.getWalletDeviceSafe).mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + vendor: EHardwareVendor.onekey, + ...deviceOverride, + } as never); + const { serviceInternals, uploadPortfolioPackage } = prepareHardwareSync({ + busyResults: [false], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + }); + + test('does not build or submit portfolio data for a software wallet', async () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const serviceInternals = service as unknown as { + getCurrencyMapForBuild: jest.Mock; + submitPortfolioJsonToServer: jest.Mock; + syncSettledPortfolio: ( + eventPayload: IPortfolioSyncSettledPayload, + ) => Promise; + }; + serviceInternals.getCurrencyMapForBuild = jest.fn(); + serviceInternals.submitPortfolioJsonToServer = jest.fn(); + (accountUtils.isHwWallet as jest.Mock).mockReturnValue(false); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + walletId: 'hd-1', + walletType: 'hd', + }); + + expect(serviceInternals.getCurrencyMapForBuild).not.toHaveBeenCalled(); + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'disabled', + walletId: 'hd-1', + }), + ); + }); + + test('uses the real upload result instead of a cached connection flag', async () => { + const uploadPortfolioPackage = jest + .fn() + .mockResolvedValue({ portfolioUpdated: true }); + const updateTargetState = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: { + serviceHardware: { + getDeviceState: jest.fn().mockResolvedValue({ protocol: 'V2' }), + uploadPortfolioPackage, + }, + serviceHardwareUI: { + isHardwareChannelBusy: jest.fn().mockResolvedValue(false), + runExclusiveOneKeyOperation: jest.fn( + async (operation: (lease: object) => Promise) => + operation({ deviceKey: 'db-device-1', owner: Symbol('test') }), + ), + }, + simpleDb: { + hardwarePortfolioSync: { + getTargetState: jest.fn().mockResolvedValue(undefined), + updateTargetState, + }, + }, + } as unknown as IBackgroundApi, + }); + const serviceInternals = service as unknown as { + getCurrencyMapForBuild: () => Promise<{ + currencyMap: Record; + displayCurrency: { id: string; symbol: string }; + }>; + getHardwareCooldownRemainingMs: () => Promise; + submitPortfolioJsonToServer: () => Promise<{ + serverPackageBytes: ArrayBuffer; + serverSubmit: { + bytesLength: number; + contentHash: string; + serverPackageBase64Length: number; + serverPackageBytesLength: number; + }; + }>; + syncSettledPortfolio: ( + eventPayload: IPortfolioSyncSettledPayload, + ) => Promise; + }; + serviceInternals.getHardwareCooldownRemainingMs = jest + .fn() + .mockResolvedValue(0); + serviceInternals.getCurrencyMapForBuild = jest.fn().mockResolvedValue({ + currencyMap: {}, + displayCurrency: { id: 'usd', symbol: '$' }, + }); + serviceInternals.submitPortfolioJsonToServer = jest.fn().mockResolvedValue({ + serverPackageBytes: new Uint8Array([1, 2, 3]).buffer, + serverSubmit: { + bytesLength: 3, + contentHash: 'server-content-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 3, + }, + }); + (accountUtils.isHwWallet as jest.Mock).mockReturnValue(true); + + const payload = buildHardwarePayload(); + + await serviceInternals.syncSettledPortfolio(payload); + + expect(uploadPortfolioPackage).toHaveBeenCalledWith({ + connectId: 'PRO2_CONNECT_ID', + packageBytes: expect.any(ArrayBuffer), + }); + expect(updateTargetState).toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'uploaded', + upload: { portfolioUpdated: true }, + }), + ); + }); + + test('retries the latest snapshot when hardware is busy before server packing', async () => { + jest.useFakeTimers(); + const { serviceInternals, uploadPortfolioPackage } = prepareHardwareSync({ + busyResults: [true, false, false], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1000); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('retries an already-packed snapshot without another server request', async () => { + jest.useFakeTimers(); + const { serviceInternals, updateTargetState, uploadPortfolioPackage } = + prepareHardwareSync({ + busyResults: [false, true, false], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + + await jest.advanceTimersByTimeAsync(1000); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect(updateTargetState).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('uploads only the latest snapshot for the same physical device', async () => { + const { serviceInternals, updateTargetState, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false, false, false] }); + let resolveOlderSubmit: + | ((value: { + serverPackageBytes: ArrayBuffer; + serverSubmit: { + bytesLength: number; + contentHash: string; + serverPackageBase64Length: number; + serverPackageBytesLength: number; + }; + }) => void) + | undefined; + let notifyOlderSubmitStarted: (() => void) | undefined; + const olderSubmitStarted = new Promise((resolve) => { + notifyOlderSubmitStarted = resolve; + }); + const olderSubmit = new Promise<{ + serverPackageBytes: ArrayBuffer; + serverSubmit: { + bytesLength: number; + contentHash: string; + serverPackageBase64Length: number; + serverPackageBytesLength: number; + }; + }>((resolve) => { + resolveOlderSubmit = resolve; + }); + serviceInternals.submitPortfolioJsonToServer + .mockImplementationOnce(() => { + notifyOlderSubmitStarted?.(); + return olderSubmit; + }) + .mockResolvedValueOnce({ + serverPackageBytes: new Uint8Array([2]).buffer, + serverSubmit: { + bytesLength: 1, + contentHash: 'newer-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 1, + }, + }); + const olderPayload = { + ...buildHardwarePayload(), + deviceDbId: 'db-device-1', + totalFiat: '1', + }; + const newerPayload = { + ...buildHardwarePayload(), + deviceDbId: 'db-device-1', + totalFiat: '2', + }; + + const olderTask = serviceInternals.syncSettledPortfolio(olderPayload); + await olderSubmitStarted; + await serviceInternals.syncSettledPortfolio(newerPayload); + resolveOlderSubmit?.({ + serverPackageBytes: new Uint8Array([1]).buffer, + serverSubmit: { + bytesLength: 1, + contentHash: 'older-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 1, + }, + }); + await olderTask; + + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect( + Array.from( + new Uint8Array(uploadPortfolioPackage.mock.calls[0][0].packageBytes), + ), + ).toEqual([2]); + expect(updateTargetState).toHaveBeenCalledTimes(1); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ lastContentHash: expect.any(String) }), + ); + }); + + test('releases a prepared retry reservation when upload fails', async () => { + jest.useFakeTimers(); + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false, true, false] }); + uploadPortfolioPackage.mockRejectedValueOnce(new Error('Device unplugged')); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await jest.advanceTimersByTimeAsync(1000); + + const inFlightReservations = ( + service as unknown as { + inFlightReservationByTargetKey: Map< + string, + { contentHash: string; generation: number } + >; + } + ).inFlightReservationByTargetKey; + expect(inFlightReservations.size).toBe(0); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + errorMessage: 'Device unplugged', + status: 'error', + }), + ); + jest.useRealTimers(); + }); + + test('keeps a newer same-hash reservation when a stale generation finishes', () => { + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const serviceInternals = service as unknown as { + inFlightReservationByTargetKey: Map< + string, + { contentHash: string; generation: number } + >; + releaseInFlightReservation: (params: { + contentHash: string; + generation: number; + targetKey: string; + }) => void; + }; + serviceInternals.inFlightReservationByTargetKey.set('db-device-1', { + contentHash: 'same-hash', + generation: 2, + }); + + serviceInternals.releaseInFlightReservation({ + contentHash: 'same-hash', + generation: 1, + targetKey: 'db-device-1', + }); + + expect( + serviceInternals.inFlightReservationByTargetKey.get('db-device-1'), + ).toEqual({ + contentHash: 'same-hash', + generation: 2, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/index.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/index.ts new file mode 100644 index 000000000000..03f75a872d50 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/index.ts @@ -0,0 +1,3 @@ +import ServiceHardwarePortfolioSync from './ServiceHardwarePortfolioSync'; + +export default ServiceHardwarePortfolioSync; diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts new file mode 100644 index 000000000000..1822a130d634 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts @@ -0,0 +1,340 @@ +/* +yarn test packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts +*/ +import type { + EAppEventBusNames, + IAppEventBusPayload, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import type { ICurrencyItem } from '@onekeyhq/shared/types/currency'; +import type { IAccountToken, ITokenFiat } from '@onekeyhq/shared/types/token'; + +import { + PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS, + buildPortfolioSyncArtifacts, + getPortfolioDisplayTimestamp, + getPortfolioSyncCooldownRemainingMs, +} from './serviceHardwarePortfolioSyncUtils'; + +const currencyMap: Record = { + cny: { + id: 'cny', + name: 'Chinese Yuan', + type: ['fiat'], + unit: '¥', + value: '7', + }, + usd: { + id: 'usd', + name: 'US Dollar', + type: ['fiat'], + unit: '$', + value: '1', + }, +}; + +function buildToken(params: Partial): IAccountToken { + return { + $key: params.$key ?? 'eth', + address: params.address ?? '0xeeee', + decimals: params.decimals ?? 18, + isNative: params.isNative ?? true, + name: params.name ?? 'Ethereum', + symbol: params.symbol ?? 'ETH', + ...params, + }; +} + +function buildFiat(params: Partial): ITokenFiat { + return { + balance: params.balance ?? '1', + balanceParsed: params.balanceParsed ?? '1', + currency: params.currency ?? 'usd', + fiatValue: params.fiatValue ?? '100', + price: params.price ?? 100, + ...params, + }; +} + +describe('serviceHardwarePortfolioSyncUtils', () => { + test('converts a Unix timestamp to the App local display time', () => { + expect( + getPortfolioDisplayTimestamp({ + timestamp: 1_784_592_000_000, + timezoneOffsetMinutes: -540, + }), + ).toBe(1_784_624_400_000); + }); + + test('calculates the 20s hardware transfer cooldown window', () => { + expect( + getPortfolioSyncCooldownRemainingMs({ + lastTransferAt: undefined, + now: 1000, + }), + ).toBe(0); + + expect( + getPortfolioSyncCooldownRemainingMs({ + lastTransferAt: 1000, + now: 6000, + }), + ).toBe(PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS - 5000); + + expect( + getPortfolioSyncCooldownRemainingMs({ + lastTransferAt: 1000, + now: PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS + 1000, + }), + ).toBe(0); + }); + + test('builds portfolio.json without client-generated colors', () => { + const payload: IAppEventBusPayload[EAppEventBusNames.AllNetworksTokenListSettled] = + { + accountAddress: '0x1234567890abcdef', + accountId: 'evm--1', + accountName: 'Account #1', + aggregateTokenMap: {}, + deviceConnectId: 'connect-1', + indexedAccountId: 'hd-1--m/44', + indexedAccountIndex: 0, + indexedAccountName: 'Account #1', + networkId: 'all--networks', + ownerAccountId: 'evm--1', + ownerNetworkId: 'all--networks', + totalFiat: '2500.555', + totalTokenCount: 8, + tokenMap: { + eth: buildFiat({ fiatValue: '100', price: 100 }), + 'fake-usdt': buildFiat({ fiatValue: '99', price: 1 }), + 'real-usdt': buildFiat({ fiatValue: '98', price: 1 }), + }, + tokens: [ + buildToken({ + $key: 'eth', + coingeckoId: 'ethereum', + logoURI: 'https://example.com/eth.png', + networkId: 'evm--1', + }), + buildToken({ + $key: 'fake-usdt', + address: '0x0000000000000000000000000000000000000001', + isNative: false, + logoURI: 'https://example.com/usdt.png', + name: 'Tether USD', + networkId: 'evm--1', + symbol: 'USDT', + }), + buildToken({ + $key: 'real-usdt', + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + isNative: false, + name: 'Tether USD', + networkId: 'evm--1', + symbol: 'USDT', + }), + ], + walletId: 'hw-1', + walletType: 'hw', + }; + + const artifacts = buildPortfolioSyncArtifacts({ + currencyMap, + displayCurrency: { id: 'cny', symbol: '¥' }, + eventPayload: payload, + timestamp: 1_780_900_000, + }); + const portfolioJson = Buffer.from(artifacts.portfolioJsonBytes).toString( + 'utf8', + ); + const portfolio = JSON.parse(portfolioJson) as { + account: { addressMasked: string; label: string }; + otherTokens: { + count: number; + fiat: string; + portfolioPercentage: number; + }; + totalFiat: string; + tokens: { + contractAddress: string; + fiatValue: string; + iconName: string | null; + isAllNetworks: boolean; + isNative: boolean; + logoURI: string; + portfolioPercentage: number; + }[]; + }; + + expect(portfolio).toMatchObject({ + account: { + addressMasked: 'Account #1', + label: 'Account #1', + }, + otherTokens: { + count: 5, + fiat: '¥421.56', + portfolioPercentage: 16.86, + }, + totalFiat: '¥2,500.56', + tokens: [ + { + contractAddress: '', + fiatValue: '¥700.00', + iconName: null, + isAllNetworks: false, + isNative: true, + logoURI: 'https://example.com/eth.png', + portfolioPercentage: 28, + }, + { + contractAddress: '0x0000000000000000000000000000000000000001', + fiatValue: '¥693.00', + iconName: null, + isAllNetworks: false, + isNative: false, + logoURI: 'https://example.com/usdt.png', + portfolioPercentage: 27.71, + }, + { + contractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + fiatValue: '¥686.00', + iconName: null, + isAllNetworks: false, + isNative: false, + logoURI: '', + portfolioPercentage: 27.43, + }, + ], + }); + const mockPortfolioJson = JSON.parse( + Buffer.from(artifacts.mockPortfolioJsonBytes).toString('utf8'), + ) as typeof portfolio; + + expect( + artifacts.mockPortfolio.tokens.map((token) => token.iconName), + ).toEqual(['ETH', null, 'USDT']); + expect(mockPortfolioJson.tokens.map((token) => token.iconName)).toEqual([ + 'ETH', + null, + 'USDT', + ]); + expect(Object.keys(portfolio).toSorted()).toEqual( + [ + 'account', + 'otherTokens', + 'tokenCount', + 'tokens', + 'totalFiat', + 'ts', + 'v', + ].toSorted(), + ); + expect(portfolio.tokens[0]).not.toHaveProperty('price'); + expect(portfolio.tokens[0]).not.toHaveProperty('change24h'); + expect(portfolio.tokens[0]).not.toHaveProperty('color'); + expect(mockPortfolioJson.tokens[0]).not.toHaveProperty('logoURI'); + expect(mockPortfolioJson.tokens[0]).not.toHaveProperty('color'); + expect(artifacts.contentHash).toMatch(/^[\da-f]{64}$/); + + const view = new DataView(artifacts.mockArchiveBytes); + expect(view.getUint32(0, true)).toBe(0x52_41_4b_4f); + expect(view.getUint32(6, true)).toBe(1); + expect(artifacts.mockArchiveBytes.byteLength).toBeGreaterThan( + artifacts.mockPortfolioJsonBytes.byteLength, + ); + }); + + test('builds portfolio account identity from indexed account metadata', () => { + const payload: IAppEventBusPayload[EAppEventBusNames.AllNetworksTokenListSettled] = + { + accountAddress: 'AllNetworkAddress', + accountId: 'allnetwork--account', + accountName: 'AllNetwork Account', + aggregateTokenMap: {}, + deviceConnectId: 'connect-1', + indexedAccountId: 'hd-1--m/44', + indexedAccountIndex: 2, + indexedAccountName: 'Custom Account', + networkId: 'all--networks', + ownerAccountId: 'evm--1', + ownerNetworkId: 'all--networks', + totalFiat: '100', + totalTokenCount: 1, + tokenMap: { + eth: buildFiat({ fiatValue: '100', price: 100 }), + }, + tokens: [ + buildToken({ + $key: 'eth', + coingeckoId: 'ethereum', + networkId: 'evm--1', + }), + ], + walletId: 'hw-1', + walletType: 'hw', + }; + + const artifacts = buildPortfolioSyncArtifacts({ + currencyMap, + displayCurrency: { id: 'usd', symbol: '$' }, + eventPayload: payload, + timestamp: 1_780_900_000, + }); + + expect(artifacts.portfolio.account).toEqual({ + addressMasked: 'Account #3', + label: 'Custom Account', + }); + }); + + test('keeps server logoURI aligned after filtering ineligible tokens', () => { + const payload: IAppEventBusPayload[EAppEventBusNames.AllNetworksTokenListSettled] = + { + accountAddress: '0x1234567890abcdef', + accountId: 'evm--1', + accountName: 'Account #1', + aggregateTokenMap: {}, + deviceConnectId: 'connect-1', + indexedAccountId: 'hd-1--m/44', + indexedAccountIndex: 0, + indexedAccountName: 'Account #1', + networkId: 'all--networks', + ownerAccountId: 'evm--1', + ownerNetworkId: 'all--networks', + totalFiat: '100', + totalTokenCount: 2, + tokenMap: { + advertising: buildFiat({ fiatValue: '50' }), + eth: buildFiat({ fiatValue: '50' }), + }, + tokens: [ + buildToken({ + $key: 'advertising', + logoURI: 'https://example.com/advertising.png', + symbol: 'Telegram @example', + }), + buildToken({ + $key: 'eth', + logoURI: 'https://example.com/eth.png', + symbol: 'ETH', + }), + ], + walletId: 'hw-1', + walletType: 'hw', + }; + + const artifacts = buildPortfolioSyncArtifacts({ + currencyMap, + displayCurrency: { id: 'usd', symbol: '$' }, + eventPayload: payload, + timestamp: 1_780_900_000, + }); + + expect(artifacts.portfolio.tokens).toHaveLength(1); + expect(artifacts.portfolio.tokens[0]).toMatchObject({ + logoURI: 'https://example.com/eth.png', + symbol: 'ETH', + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts new file mode 100644 index 000000000000..310df25002ed --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts @@ -0,0 +1,145 @@ +import type { + EAppEventBusNames, + IAppEventBusPayload, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { packPortfolioArchive } from '@onekeyhq/shared/src/utils/portfolioArchive'; +import { + buildPortfolioPayload, + buildPortfolioPayloadHash, + selectPortfolioPayloadTokens, +} from '@onekeyhq/shared/src/utils/portfolioPayload'; +import type { IPortfolioPayload } from '@onekeyhq/shared/src/utils/portfolioPayload'; +import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; +import type { ICurrencyItem } from '@onekeyhq/shared/types/currency'; + +export type IPortfolioSyncSettledPayload = + IAppEventBusPayload[EAppEventBusNames.AllNetworksTokenListSettled]; + +export type IPortfolioServerSubmitPayload = Omit< + IPortfolioPayload, + 'tokens' +> & { + tokens: Array< + IPortfolioPayload['tokens'][number] & { + logoURI: string; + } + >; +}; + +export type IPortfolioSyncArtifacts = { + contentHash: string; + mockArchiveBytes: ArrayBuffer; + mockPortfolio: IPortfolioPayload; + mockPortfolioJsonBytes: Uint8Array; + mockPortfolioJsonText: string; + portfolio: IPortfolioServerSubmitPayload; + portfolioJsonBytes: Uint8Array; + portfolioJsonText: string; +}; + +export const PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS = 20_000; + +export function getPortfolioDisplayTimestamp({ + timestamp, + timezoneOffsetMinutes = new Date(timestamp).getTimezoneOffset(), +}: { + timestamp: number; + timezoneOffsetMinutes?: number; +}): number { + return timestamp - timezoneOffsetMinutes * 60_000; +} + +export function getPortfolioSyncCooldownRemainingMs({ + cooldownMs = PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS, + lastTransferAt, + now, +}: { + cooldownMs?: number; + lastTransferAt?: number; + now: number; +}): number { + if (!lastTransferAt) { + return 0; + } + return Math.max(lastTransferAt + cooldownMs - now, 0); +} + +function buildPortfolioAccountFromEventPayload( + eventPayload: IPortfolioSyncSettledPayload, +): IPortfolioPayload['account'] { + const accountIdentifier = + typeof eventPayload.indexedAccountIndex === 'number' + ? `Account #${eventPayload.indexedAccountIndex + 1}` + : accountUtils.shortenAddress({ + address: eventPayload.accountAddress, + }); + + return { + addressMasked: accountIdentifier, + label: + eventPayload.indexedAccountName || + eventPayload.accountName || + accountIdentifier, + }; +} + +export function buildPortfolioSyncArtifacts({ + currencyMap, + displayCurrency, + eventPayload, + timestamp, +}: { + currencyMap: Record; + displayCurrency: { + id: string; + symbol: string; + }; + eventPayload: IPortfolioSyncSettledPayload; + timestamp: number; +}): IPortfolioSyncArtifacts { + const portfolioPayloadParams = { + account: buildPortfolioAccountFromEventPayload(eventPayload), + aggregateTokenMap: eventPayload.aggregateTokenMap, + currencyMap, + displayCurrency, + totalFiat: eventPayload.totalFiat, + totalTokenCount: eventPayload.totalTokenCount, + timestamp, + tokenMap: eventPayload.tokenMap, + tokens: eventPayload.tokens, + }; + const selectedSourceTokens = selectPortfolioPayloadTokens( + portfolioPayloadParams, + ); + const mockPortfolio = buildPortfolioPayload(portfolioPayloadParams); + const portfolio: IPortfolioServerSubmitPayload = { + ...mockPortfolio, + tokens: mockPortfolio.tokens.map((token, index) => ({ + ...token, + iconName: null, + logoURI: selectedSourceTokens[index]?.logoURI ?? '', + })), + }; + const portfolioJsonText = stringUtils.stableStringify(portfolio); + const portfolioJsonBytes = Buffer.from(portfolioJsonText, 'utf8'); + const mockPortfolioJsonText = stringUtils.stableStringify(mockPortfolio); + const mockPortfolioJsonBytes = Buffer.from(mockPortfolioJsonText, 'utf8'); + const mockArchiveBytes = packPortfolioArchive([ + { + bytes: mockPortfolioJsonBytes, + name: 'portfolio.json', + }, + ]); + + return { + contentHash: buildPortfolioPayloadHash(portfolio), + mockArchiveBytes, + mockPortfolio, + mockPortfolioJsonBytes, + mockPortfolioJsonText, + portfolio, + portfolioJsonBytes, + portfolioJsonText, + }; +} diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.test.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.test.ts new file mode 100644 index 000000000000..cccb70833302 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.test.ts @@ -0,0 +1,63 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import serviceHardwareUtils from './serviceHardwareUtils'; + +describe('serviceHardwareUtils.getHomeScreenServerDeviceType', () => { + it('maps Pro 2 to Pro for the homescreen service', () => { + expect( + serviceHardwareUtils.getHomeScreenServerDeviceType(EDeviceType.Pro2), + ).toBe(EDeviceType.Pro); + }); + + it('keeps other device types unchanged', () => { + expect( + serviceHardwareUtils.getHomeScreenServerDeviceType(EDeviceType.Touch), + ).toBe(EDeviceType.Touch); + }); +}); + +describe('serviceHardwareUtils.getPro2HomeScreenSizeFallback', () => { + it('returns the Pro 2 wallpaper dimensions when the SDK has no config', () => { + expect( + serviceHardwareUtils.getPro2HomeScreenSizeFallback({ + deviceType: EDeviceType.Pro2, + thumbnail: false, + }), + ).toEqual({ width: 604, height: 1024 }); + }); + + it('does not provide a legacy thumbnail size', () => { + expect( + serviceHardwareUtils.getPro2HomeScreenSizeFallback({ + deviceType: EDeviceType.Pro2, + thumbnail: true, + }), + ).toBeUndefined(); + }); +}); + +describe('serviceHardwareUtils.getPro2NftSizeFallback', () => { + it('returns the Pro 2 NFT image and thumbnail dimensions independently', () => { + expect( + serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: EDeviceType.Pro2, + thumbnail: false, + }), + ).toEqual({ width: 540, height: 540 }); + expect( + serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: EDeviceType.Pro2, + thumbnail: true, + }), + ).toEqual({ width: 263, height: 263 }); + }); + + it('does not apply the Pro 2 fallback to legacy devices', () => { + expect( + serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: EDeviceType.Pro, + thumbnail: false, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts index c4ea5a943fc7..93403466d1c9 100644 --- a/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts @@ -1,7 +1,41 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + function hardwareLog(name: string, ...args: any[]) { console.log(`ServiceHardwareLog@${name}`, ...args); } +function getHomeScreenServerDeviceType(deviceType: EDeviceType): EDeviceType { + // TODO: Remove this compatibility mapping after Dashboard supports + // deviceType=pro2 for wallet homescreen resources. + return deviceType === EDeviceType.Pro2 ? EDeviceType.Pro : deviceType; +} + +function getPro2HomeScreenSizeFallback({ + deviceType, + thumbnail, +}: { + deviceType: EDeviceType; + thumbnail: boolean; +}): { width: number; height: number } | undefined { + if (deviceType !== EDeviceType.Pro2) return undefined; + if (thumbnail) return undefined; + return { width: 604, height: 1024 }; +} + +function getPro2NftSizeFallback({ + deviceType, + thumbnail, +}: { + deviceType: EDeviceType; + thumbnail: boolean; +}): { width: number; height: number } | undefined { + if (deviceType !== EDeviceType.Pro2) return undefined; + return thumbnail ? { width: 263, height: 263 } : { width: 540, height: 540 }; +} + export default { + getHomeScreenServerDeviceType, + getPro2HomeScreenSizeFallback, + getPro2NftSizeFallback, hardwareLog, }; diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts new file mode 100644 index 000000000000..a163ad2955fb --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts @@ -0,0 +1,87 @@ +import { HardwareProcessingManager } from './HardwareProcessingManager'; + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('HardwareProcessingManager OneKey operation lease', () => { + it('allows only one OneKey operation to own SDK UI responses at a time', async () => { + const manager = new HardwareProcessingManager(); + const firstOperation = createDeferred(); + const executionOrder: string[] = []; + + const first = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + operation: async () => { + executionOrder.push('first:start'); + await firstOperation.promise; + executionOrder.push('first:end'); + }, + }); + const second = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-2', + operation: async () => { + executionOrder.push('second:start'); + }, + }); + + await Promise.resolve(); + expect(executionOrder).toEqual(['first:start']); + + firstOperation.resolve(); + await Promise.all([first, second]); + + expect(executionOrder).toEqual([ + 'first:start', + 'first:end', + 'second:start', + ]); + }); + + it('reuses the lease for a nested call without releasing it to competitors', async () => { + const manager = new HardwareProcessingManager(); + const nestedOperation = createDeferred(); + const executionOrder: string[] = []; + + const outer = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + operation: async (lease) => { + executionOrder.push('outer:start'); + await manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + lease, + operation: async () => { + executionOrder.push('nested:start'); + await nestedOperation.promise; + executionOrder.push('nested:end'); + }, + }); + executionOrder.push('outer:end'); + }, + }); + const competitor = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-2', + operation: async () => { + executionOrder.push('competitor:start'); + }, + }); + + await Promise.resolve(); + expect(executionOrder).toEqual(['outer:start', 'nested:start']); + + nestedOperation.resolve(); + await Promise.all([outer, competitor]); + + expect(executionOrder).toEqual([ + 'outer:start', + 'nested:start', + 'nested:end', + 'outer:end', + 'competitor:start', + ]); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts index f50d2469e31f..a06251265c66 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts @@ -1,8 +1,48 @@ +import { Semaphore } from 'async-mutex'; + import { UserCancelFromOutside } from '@onekeyhq/shared/src/errors'; +export type IOneKeyHardwareOperationLease = { + readonly deviceKey: string | undefined; + readonly owner: symbol; +}; + export class HardwareProcessingManager { private cancelCallbacks: Map void> = new Map(); + private oneKeyOperationSemaphore = new Semaphore(1); + + private activeOneKeyOperationLease: IOneKeyHardwareOperationLease | undefined; + + runExclusiveOneKeyOperation({ + deviceKey, + lease, + operation, + }: { + deviceKey?: string; + lease?: IOneKeyHardwareOperationLease; + operation: (lease: IOneKeyHardwareOperationLease) => Promise; + }): Promise { + if (lease && lease === this.activeOneKeyOperationLease) { + return operation(lease); + } + + return this.oneKeyOperationSemaphore.runExclusive(async () => { + const acquiredLease: IOneKeyHardwareOperationLease = Object.freeze({ + deviceKey, + owner: Symbol('onekey-hardware-operation'), + }); + this.activeOneKeyOperationLease = acquiredLease; + try { + return await operation(acquiredLease); + } finally { + if (this.activeOneKeyOperationLease === acquiredLease) { + this.activeOneKeyOperationLease = undefined; + } + } + }); + } + registerCancelCallback(connectId: string, callback: () => void) { this.cancelCallbacks.set(connectId, callback); } diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts new file mode 100644 index 000000000000..d0c13906b3c7 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts @@ -0,0 +1,70 @@ +import ServiceHardwareUI from './ServiceHardwareUI'; + +import type { UiResponseEvent } from '@onekeyfe/hd-core'; + +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => (target: unknown) => target, + backgroundMethod: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + backgroundMethodForDev: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: string, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ + EAppEventBusNames: { + HardwareDeviceStateUpdate: 'HardwareDeviceStateUpdate', + HardwareFeaturesUpdate: 'HardwareFeaturesUpdate', + }, + appEventBus: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + }, +})); + +jest.mock('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getDevice: jest.fn(), + }, +})); + +describe('ServiceHardwareUI.sendUiResponse', () => { + it('Pro2 通过 USB 连接时仍把 Pro BLE 的 Passphrase 回包交给当前 SDK', async () => { + const sendUiResponseToActiveSdk = jest.fn(); + const sdkUiResponse = jest.fn(); + const getSDKInstance = jest.fn().mockResolvedValue({ + uiResponse: sdkUiResponse, + }); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + getSDKInstance, + sendUiResponseToActiveSdk, + }, + }, + }); + const response = { + type: 'ui-receive_passphrase', + payload: { + value: 'hidden wallet', + passphraseOnDevice: false, + attachPinOnDevice: false, + save: false, + }, + interactionId: 'pro-ble-interaction', + deviceId: 'pro-device', + } as UiResponseEvent; + + await service.sendUiResponse(response); + + expect(sendUiResponseToActiveSdk).toHaveBeenCalledWith(response); + expect(getSDKInstance).not.toHaveBeenCalled(); + expect(sdkUiResponse).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts index 58a50f5c9294..b172aef07ef0 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts @@ -13,6 +13,7 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import type { IAppEventBusPayload } from '@onekeyhq/shared/src/eventBus/appEventBus'; import { CoreSDKLoader } from '@onekeyhq/shared/src/hardware/instance'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -31,16 +32,24 @@ import type { import localDb from '../../dbs/local/localDb'; import { EHardwareUiStateAction, + firmwareUpdateWorkflowRunningAtom, hardwareUiStateAtom, thirdPartyAppInstallAtom, thirdPartyHardwareUiStateAtom, } from '../../states/jotai/atoms'; import ServiceBase from '../ServiceBase'; -import { HardwareProcessingManager } from './HardwareProcessingManager'; +import { + HardwareProcessingManager, + type IOneKeyHardwareOperationLease, +} from './HardwareProcessingManager'; +import { buildPassphraseUiResponsePayload } from './passphraseUiResponseUtils'; import type { IDBDevice } from '../../dbs/local/types'; -import type { IHardwareUiPayload } from '../../states/jotai/atoms'; +import type { + IHardwareUiPayload, + IHardwareUiResponseCorrelation, +} from '../../states/jotai/atoms'; import type { UiResponseEvent } from '@onekeyfe/hd-core'; export type IWithHardwareProcessingControlParams = { @@ -54,6 +63,7 @@ export type IWithHardwareProcessingControlParams = { export type IWithHardwareProcessingOptions = { deviceParams: IDeviceSharedCallParams | undefined; debugMethodName?: string; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; onFinally?: () => void; } & IWithHardwareProcessingControlParams; @@ -75,48 +85,64 @@ class ServiceHardwareUI extends ServiceBase { constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); // This service caches `connectId -> IDBDevice` for hardware interaction dialogs. - // When device features (including label) change, invalidate cache to avoid showing stale names. + // Clear cached dialogs after device state changes so labels cannot become stale. + appEventBus.on( + EAppEventBusNames.HardwareDeviceStateUpdate, + this.onHardwareDeviceStateUpdate, + ); + // Third-party hardware remains driven by each SDK's features events. appEventBus.on( EAppEventBusNames.HardwareFeaturesUpdate, - this.onHardwareFeaturesUpdate, + this.onThirdPartyHardwareFeaturesUpdate, ); } hardwareProcessingManager = new HardwareProcessingManager(); - private onHardwareFeaturesUpdate = async ({ - deviceId, - }: { - deviceId: string; - }) => { + private onHardwareDeviceStateUpdate = async ({ + connectId, + state, + }: IAppEventBusPayload[EAppEventBusNames.HardwareDeviceStateUpdate]) => { try { // Delete from cache first to avoid a race where a new interaction immediately reads stale cache. - for (const [connectId, cached] of this.deviceCacheByConnectId.entries()) { - if (cached?.id === deviceId) { - this.deviceCacheByConnectId.delete(connectId); + for (const [ + cachedConnectId, + cached, + ] of this.deviceCacheByConnectId.entries()) { + if ( + cached?.deviceId === state.identity.deviceId || + cached?.uuid === state.identity.serialNo + ) { + this.deviceCacheByConnectId.delete(cachedConnectId); } } + if (connectId) this.deviceCacheByConnectId.delete(connectId); + } catch { + // Best-effort: this event is only for UI consistency. Clear cache on any error. + this.deviceCacheByConnectId.clear(); + } + }; + private onThirdPartyHardwareFeaturesUpdate = async ({ + deviceId, + }: IAppEventBusPayload[EAppEventBusNames.HardwareFeaturesUpdate]) => { + try { const device = await localDb.getDevice(deviceId); if (device?.connectId) { this.deviceCacheByConnectId.delete(device.connectId); } else { - // Conservative fallback: if connectId cannot be resolved, clear all cache to avoid stale UI. this.deviceCacheByConnectId.clear(); } } catch { - // Best-effort: this event is only for UI consistency. Clear cache on any error. this.deviceCacheByConnectId.clear(); } }; @backgroundMethod() async sendUiResponse(response: UiResponseEvent) { - return ( - await this.backgroundApi.serviceHardware.getSDKInstance({ - connectId: undefined, - }) - ).uiResponse(response); + return this.backgroundApi.serviceHardware.sendUiResponseToActiveSdk( + response, + ); } @backgroundMethod() @@ -255,64 +281,79 @@ class ServiceHardwareUI extends ServiceBase { } @backgroundMethod() - async showEnterPassphraseOnDeviceDialog() { + async showEnterPassphraseOnDeviceDialog({ + responseCorrelation, + }: { + responseCorrelation?: IHardwareUiResponseCorrelation; + } = {}) { const { UI_RESPONSE } = await CoreSDKLoader(); await this.sendUiResponse({ type: UI_RESPONSE.RECEIVE_PASSPHRASE, - payload: { - value: '', - passphraseOnDevice: true, - attachPinOnDevice: false, - save: false, - }, + payload: buildPassphraseUiResponsePayload({ mode: 'device' }), + ...responseCorrelation, }); } @backgroundMethod() - async showEnterAttachPinOnDeviceDialog() { + async showEnterAttachPinOnDeviceDialog({ + responseCorrelation, + }: { + responseCorrelation?: IHardwareUiResponseCorrelation; + } = {}) { const { UI_RESPONSE } = await CoreSDKLoader(); await this.sendUiResponse({ type: UI_RESPONSE.RECEIVE_PASSPHRASE, - payload: { - value: '', - passphraseOnDevice: false, - attachPinOnDevice: true, - save: false, - }, + payload: buildPassphraseUiResponsePayload({ mode: 'attach-pin' }), + ...responseCorrelation, }); } @backgroundMethod() - async sendPinToDevice({ pin }: { pin: string }) { + async sendPinToDevice({ + pin, + responseCorrelation, + }: { + pin: string; + responseCorrelation?: IHardwareUiResponseCorrelation; + }) { const { UI_RESPONSE } = await CoreSDKLoader(); await this.sendUiResponse({ type: UI_RESPONSE.RECEIVE_PIN, payload: pin, + ...responseCorrelation, }); } @backgroundMethod() - async sendPassphraseToDevice({ passphrase }: { passphrase: string }) { + async sendPassphraseToDevice({ + passphrase, + responseCorrelation, + }: { + passphrase: string; + responseCorrelation?: IHardwareUiResponseCorrelation; + }) { const { UI_RESPONSE } = await CoreSDKLoader(); await this.sendUiResponse({ type: UI_RESPONSE.RECEIVE_PASSPHRASE, - payload: { - value: passphrase, - passphraseOnDevice: false, - save: false, - }, + payload: buildPassphraseUiResponsePayload({ mode: 'host', passphrase }), + ...responseCorrelation, }); } @backgroundMethod() - async showEnterPinOnDevice() { + async showEnterPinOnDevice({ + responseCorrelation, + }: { + responseCorrelation?: IHardwareUiResponseCorrelation; + } = {}) { const { UI_RESPONSE } = await CoreSDKLoader(); await this.sendUiResponse({ type: UI_RESPONSE.RECEIVE_PIN, payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE', + ...responseCorrelation, }); } @@ -324,7 +365,9 @@ class ServiceHardwareUI extends ServiceBase { connectId: string; payload: IHardwareUiPayload | undefined; }) { - await this.showEnterPinOnDevice(); + await this.showEnterPinOnDevice({ + responseCorrelation: payload?.uiResponseCorrelation, + }); await hardwareUiStateAtom.set({ action: EHardwareUiStateAction.EnterPinOnDevice, @@ -451,7 +494,61 @@ class ServiceHardwareUI extends ServiceBase { return this.processingNestedNum === 1; } + @backgroundMethod() + async isHardwareChannelBusy(_params?: { connectId?: string }) { + const [hardwareUiState, firmwareUpdateWorkflowRunning] = await Promise.all([ + hardwareUiStateAtom.get(), + firmwareUpdateWorkflowRunningAtom.get(), + ]); + return ( + this.processingNestedNum > 0 || + this.backgroundApi.serviceHardware.getFeaturesMutex.isLocked() || + firmwareUpdateWorkflowRunning || + Boolean(hardwareUiState) + ); + } + async withHardwareProcessing( + fn: (lease?: IOneKeyHardwareOperationLease) => Promise, + params: IWithHardwareProcessingOptions, + ): Promise { + const device = params.deviceParams?.dbDevice; + const isThirdPartyVendor = getVendorProfile( + device?.vendor ?? EHardwareVendor.onekey, + ).isThirdParty; + if (isThirdPartyVendor) { + return this.withHardwareProcessingInternal(() => fn(undefined), params); + } + // Keep operation-level serialization during the mixed-SDK rollout and for + // shared lifecycle work outside the correlated PIN/passphrase response path. + return this.runExclusiveOneKeyOperation( + (lease) => this.withHardwareProcessingInternal(() => fn(lease), params), + { + deviceKey: + device?.id || device?.deviceId || device?.uuid || device?.connectId, + lease: params.oneKeyOperationLease, + }, + ); + } + + runExclusiveOneKeyOperation( + operation: (lease: IOneKeyHardwareOperationLease) => Promise, + { + deviceKey, + lease, + }: { + deviceKey?: string; + lease?: IOneKeyHardwareOperationLease; + } = {}, + ) { + return this.hardwareProcessingManager.runExclusiveOneKeyOperation({ + deviceKey, + lease, + operation, + }); + } + + private async withHardwareProcessingInternal( fn: () => Promise, params: IWithHardwareProcessingOptions, ): Promise { @@ -478,7 +575,6 @@ class ServiceHardwareUI extends ServiceBase { const isThirdPartyVendor = getVendorProfile( device?.vendor ?? EHardwareVendor.onekey, ).isThirdParty; - let deviceResetToHome = true; let isBusy = false; try { diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.test.ts b/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.test.ts new file mode 100644 index 000000000000..fbb97c7a1f34 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.test.ts @@ -0,0 +1,38 @@ +import { buildPassphraseUiResponsePayload } from './passphraseUiResponseUtils'; + +describe('buildPassphraseUiResponsePayload', () => { + test.each([ + [ + 'Host Passphrase', + { mode: 'host' as const, passphrase: 'host hidden wallet' }, + { + value: 'host hidden wallet', + passphraseOnDevice: false, + attachPinOnDevice: false, + save: false, + }, + ], + [ + '设备输入', + { mode: 'device' as const }, + { + value: '', + passphraseOnDevice: true, + attachPinOnDevice: false, + save: false, + }, + ], + [ + 'Attach PIN', + { mode: 'attach-pin' as const }, + { + value: '', + passphraseOnDevice: false, + attachPinOnDevice: true, + save: false, + }, + ], + ])('%s 只回传一个钱包选择入口', (_name, input, expected) => { + expect(buildPassphraseUiResponsePayload(input)).toEqual(expected); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.ts b/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.ts new file mode 100644 index 000000000000..29aa1dd97968 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/passphraseUiResponseUtils.ts @@ -0,0 +1,16 @@ +export type IWalletSelectionMode = 'host' | 'device' | 'attach-pin'; + +export function buildPassphraseUiResponsePayload({ + mode, + passphrase = '', +}: { + mode: IWalletSelectionMode; + passphrase?: string; +}) { + return { + value: mode === 'host' ? passphrase : '', + passphraseOnDevice: mode === 'device', + attachPinOnDevice: mode === 'attach-pin', + save: false as const, + }; +} diff --git a/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts b/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts new file mode 100644 index 000000000000..ab38d4911c73 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts @@ -0,0 +1,117 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import ServiceNFT from './ServiceNFT'; + +import type { DeviceUploadResourceParams } from '@onekeyfe/hd-core'; + +const previousBackgroundScope = globalThis.$onekeyIsInBackground; + +beforeAll(() => { + globalThis.$onekeyIsInBackground = true; +}); + +afterAll(() => { + globalThis.$onekeyIsInBackground = previousBackgroundScope; +}); + +const uploadResParams = { + resType: 1, + suffix: 'jpg', + dataHex: 'full-image', + thumbnailDataHex: 'thumbnail-image', + blurDataHex: 'blur-image', + nftMetaData: 'metadata', +} as DeviceUploadResourceParams; + +function buildService(deviceType: EDeviceType) { + const uploadPro2Nft = jest.fn(async () => ({ nftUpdated: true })); + const uploadResource = jest.fn(async () => ({ message: 'Success' })); + const backgroundApi = { + servicePassword: { + promptPasswordVerifyByAccount: jest.fn(async () => ({ + deviceParams: { + dbDevice: { connectId: 'device-connect-id', deviceType }, + }, + })), + }, + serviceHardware: { uploadPro2Nft, uploadResource }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn(async (action: () => Promise) => + action(), + ), + }, + }; + return { + service: new ServiceNFT({ backgroundApi }), + uploadPro2Nft, + uploadResource, + }; +} + +describe('ServiceNFT Pro2 upload routing', () => { + it('routes Pro2 images and metadata to deviceUploadNft', async () => { + const { service, uploadPro2Nft, uploadResource } = buildService( + EDeviceType.Pro2, + ); + + await service.uploadNFTImageToDevice({ + accountId: 'account-id', + pro2UploadParams: { + imageHex: 'full-image', + thumbnailHex: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }, + }); + + expect(uploadPro2Nft).toHaveBeenCalledWith({ + connectId: 'device-connect-id', + imageHex: 'full-image', + thumbnailHex: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }); + expect(uploadResource).not.toHaveBeenCalled(); + }); + + it('keeps legacy ResourceUpload for Pro1 devices', async () => { + const { service, uploadPro2Nft, uploadResource } = buildService( + EDeviceType.Pro, + ); + + await service.uploadNFTImageToDevice({ + accountId: 'account-id', + uploadResParams, + }); + + expect(uploadResource).toHaveBeenCalledWith( + 'device-connect-id', + uploadResParams, + ); + expect(uploadPro2Nft).not.toHaveBeenCalled(); + }); + + it('rejects a Pro2 upload before touching the legacy resource path', async () => { + const { service, uploadPro2Nft, uploadResource } = buildService( + EDeviceType.Pro2, + ); + + await expect( + service.uploadNFTImageToDevice({ accountId: 'account-id' }), + ).rejects.toThrow('Pro2 NFT upload parameters are required'); + expect(uploadPro2Nft).not.toHaveBeenCalled(); + expect(uploadResource).not.toHaveBeenCalled(); + }); + + it('rejects a legacy upload without legacy resource parameters', async () => { + const { service, uploadPro2Nft, uploadResource } = buildService( + EDeviceType.Pro, + ); + + await expect( + service.uploadNFTImageToDevice({ accountId: 'account-id' }), + ).rejects.toThrow('Legacy NFT upload parameters are required'); + expect(uploadPro2Nft).not.toHaveBeenCalled(); + expect(uploadResource).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceNFT.ts b/packages/kit-bg/src/services/ServiceNFT.ts index 864a72cbaf9c..5b543e4c1d5c 100644 --- a/packages/kit-bg/src/services/ServiceNFT.ts +++ b/packages/kit-bg/src/services/ServiceNFT.ts @@ -1,5 +1,6 @@ import qs from 'querystring'; +import { EDeviceType } from '@onekeyfe/hd-shared'; import { debounce, isArray, isNil, isObject, omitBy } from 'lodash'; import { @@ -7,6 +8,7 @@ import { backgroundMethod, } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { memoizee } from '@onekeyhq/shared/src/utils/cacheUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -26,6 +28,14 @@ import ServiceBase from './ServiceBase'; import type { IDBAccount } from '../dbs/local/types'; import type { DeviceUploadResourceParams } from '@onekeyfe/hd-core'; +export type IPro2NftUploadParams = { + imageHex: string; + thumbnailHex: string; + title: string; + subtitle: string; + timestampMs?: number; +}; + @backgroundClass() class ServiceNFT extends ServiceBase { constructor({ backgroundApi }: { backgroundApi: any }) { @@ -39,20 +49,39 @@ class ServiceNFT extends ServiceBase { @backgroundMethod() public async uploadNFTImageToDevice(params: { accountId: string; - uploadResParams: DeviceUploadResourceParams; + uploadResParams?: DeviceUploadResourceParams; + pro2UploadParams?: IPro2NftUploadParams; }) { - const { accountId, uploadResParams } = params; + const { accountId, uploadResParams, pro2UploadParams } = params; const { deviceParams } = await this.backgroundApi.servicePassword.promptPasswordVerifyByAccount({ accountId, reason: EReasonForNeedPassword.Default, }); return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => - this.backgroundApi.serviceHardware.uploadResource( - deviceParams?.dbDevice.connectId ?? '', + async () => { + const device = deviceParams?.dbDevice; + if (device?.deviceType === EDeviceType.Pro2) { + if (!pro2UploadParams) { + throw new OneKeyLocalError( + 'Pro2 NFT upload parameters are required', + ); + } + return this.backgroundApi.serviceHardware.uploadPro2Nft({ + connectId: device.connectId ?? '', + ...pro2UploadParams, + }); + } + if (!uploadResParams) { + throw new OneKeyLocalError( + 'Legacy NFT upload parameters are required', + ); + } + return this.backgroundApi.serviceHardware.uploadResource( + device?.connectId ?? '', uploadResParams, - ), + ); + }, { deviceParams, debugMethodName: 'nft.uploadNFTImageToDevice' }, ); } diff --git a/packages/kit-bg/src/services/ServiceNetwork.exportAccountKeys.test.ts b/packages/kit-bg/src/services/ServiceNetwork.exportAccountKeys.test.ts new file mode 100644 index 000000000000..e46b385f695f --- /dev/null +++ b/packages/kit-bg/src/services/ServiceNetwork.exportAccountKeys.test.ts @@ -0,0 +1,73 @@ +jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ + backgroundClass: () => () => undefined, + backgroundMethod: + () => (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) => + descriptor, + toastIfError: + () => (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) => + descriptor, +})); + +jest.mock('./ServiceBase', () => ({ + __esModule: true, + default: class ServiceBase { + backgroundApi: unknown; + + constructor({ backgroundApi }: { backgroundApi: unknown }) { + this.backgroundApi = backgroundApi; + } + }, +})); + +jest.mock('p-limit', () => () => (fn: () => unknown) => fn()); + +// eslint-disable-next-line import-js/order, import/first +import type { IServerNetwork } from '@onekeyhq/shared/types'; + +// eslint-disable-next-line import-js/order, import/first +import ServiceNetwork from './ServiceNetwork/ServiceNetwork'; + +const btcNetwork = { id: 'btc--0' } as IServerNetwork; +const neuraiNetwork = { id: 'neurai--0' } as IServerNetwork; + +describe('ServiceNetwork export account key networks', () => { + it('filters exportable networks by the current hardware wallet compatibility', async () => { + const service = new ServiceNetwork({ backgroundApi: {} }); + jest + .spyOn(service, 'getSupportExportPublicKeyNetworks') + .mockResolvedValue([{ network: btcNetwork }, { network: neuraiNetwork }]); + const getCompatibleNetworks = jest + .spyOn(service, 'getNetworkIdsCompatibleWithWalletId') + .mockResolvedValue({ + networkIdsCompatible: [btcNetwork.id], + networkIdsIncompatible: [neuraiNetwork.id], + }); + + await expect( + service.getSupportExportAccountKeyNetworks({ + exportType: 'publicKey', + walletId: 'hw-pro2-wallet', + }), + ).resolves.toEqual([{ network: btcNetwork }]); + expect(getCompatibleNetworks).toHaveBeenCalledWith({ + walletId: 'hw-pro2-wallet', + networkIds: [btcNetwork.id, neuraiNetwork.id], + }); + }); + + it('keeps the existing exportable network list when wallet context is absent', async () => { + const service = new ServiceNetwork({ backgroundApi: {} }); + jest + .spyOn(service, 'getSupportExportPublicKeyNetworks') + .mockResolvedValue([{ network: btcNetwork }, { network: neuraiNetwork }]); + const getCompatibleNetworks = jest.spyOn( + service, + 'getNetworkIdsCompatibleWithWalletId', + ); + + await expect( + service.getSupportExportAccountKeyNetworks({ exportType: 'publicKey' }), + ).resolves.toEqual([{ network: btcNetwork }, { network: neuraiNetwork }]); + expect(getCompatibleNetworks).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts b/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts index 933e8f5d500f..a84e1a6eb485 100644 --- a/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts +++ b/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts @@ -1100,20 +1100,37 @@ class ServiceNetwork extends ServiceBase { @backgroundMethod() async getSupportExportAccountKeyNetworks({ exportType, + walletId, }: { exportType: 'privateKey' | 'publicKey' | 'mnemonic'; + walletId?: string; }): Promise< { network: IServerNetwork; }[] > { + let networksInfo: { network: IServerNetwork }[]; if (exportType === 'privateKey') { - return this.getSupportExportPrivateKeyNetworks(); + networksInfo = await this.getSupportExportPrivateKeyNetworks(); + } else if (exportType === 'publicKey') { + networksInfo = await this.getSupportExportPublicKeyNetworks(); + } else { + throw new OneKeyLocalError('Not implemented'); } - if (exportType === 'publicKey') { - return this.getSupportExportPublicKeyNetworks(); + + if (!walletId) { + return networksInfo; } - throw new OneKeyLocalError('Not implemented'); + + const { networkIdsCompatible } = + await this.getNetworkIdsCompatibleWithWalletId({ + walletId, + networkIds: networksInfo.map((item) => item.network.id), + }); + const compatibleNetworkIds = new Set(networkIdsCompatible); + return networksInfo.filter((item) => + compatibleNetworkIds.has(item.network.id), + ); } @backgroundMethod() diff --git a/packages/kit-bg/src/services/ServiceSetting.ts b/packages/kit-bg/src/services/ServiceSetting.ts index 24ca4640878d..49832fabe43d 100644 --- a/packages/kit-bg/src/services/ServiceSetting.ts +++ b/packages/kit-bg/src/services/ServiceSetting.ts @@ -800,9 +800,13 @@ class ServiceSetting extends ServiceBase { public async setHardwareTransportType( hardwareTransportType: EHardwareTransportType, ) { + const nextHardwareTransportType = + deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: hardwareTransportType, + }); await settingsPersistAtom.set((prev) => ({ ...prev, - hardwareTransportType, + hardwareTransportType: nextHardwareTransportType, })); } @@ -810,7 +814,9 @@ class ServiceSetting extends ServiceBase { public async getHardwareTransportType(): Promise { const { hardwareTransportType } = await settingsPersistAtom.get(); if (hardwareTransportType) { - return hardwareTransportType; + return deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: hardwareTransportType, + }); } return deviceUtils.getDefaultHardwareTransportType(); } diff --git a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts index da6f2ba21c41..232aad81cb1b 100644 --- a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts +++ b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts @@ -1,5 +1,6 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { ETabRoutes } from '@onekeyhq/shared/src/routes'; +import type { IPro2FirmwareUpdateTarget } from '@onekeyhq/shared/types/device'; import type { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; import { EAtomNames } from '../atomNames'; @@ -212,6 +213,8 @@ export type IFirmwareUpdateDevSettings = { showDeviceDebugLogs: boolean; showAutoCheckHardwareUpdatesToast: boolean; forceUpdateBtcOnlyUniversalFirmware: boolean; + pro2ForceUpdateTargets: IPro2FirmwareUpdateTarget[]; + pro2ForceUpdateOnceTargets: IPro2FirmwareUpdateTarget[]; }; export type IFirmwareUpdateDevSettingsKeys = keyof IFirmwareUpdateDevSettings; export const { @@ -238,6 +241,8 @@ export const { showDeviceDebugLogs: false, showAutoCheckHardwareUpdatesToast: false, forceUpdateBtcOnlyUniversalFirmware: false, + pro2ForceUpdateTargets: [], + pro2ForceUpdateOnceTargets: [], }, }); diff --git a/packages/kit-bg/src/states/jotai/atoms/hardware.ts b/packages/kit-bg/src/states/jotai/atoms/hardware.ts index 3b5c87c9bb97..df94009a8f2c 100644 --- a/packages/kit-bg/src/states/jotai/atoms/hardware.ts +++ b/packages/kit-bg/src/states/jotai/atoms/hardware.ts @@ -19,6 +19,11 @@ import { globalAtom } from '../utils'; import type { IDeviceType } from '@onekeyfe/hd-core'; export { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; +export type IHardwareUiResponseCorrelation = { + interactionId: string; + deviceId: string; +}; + export type IHardwareUiPayload = { uiRequestType: string; // EHardwareUiStateAction eventType: string; @@ -29,8 +34,13 @@ export type IHardwareUiPayload = { deviceMode: EOneKeyDeviceMode; isBootloaderMode?: boolean; // request passphrase - passphraseState?: string; // use passphrase, REQUEST_PASSPHRASE_ON_DEVICE only - existsAttachPinUser?: boolean; // use attach pin, REQUEST_PASSPHRASE_ON_DEVICE only + passphraseState?: string; // Wallet identity used to verify a passphrase recovery request. + existsAttachPinUser?: boolean; // Show the existing Attach PIN entry during wallet selection. + deviceOnly?: boolean; + source?: 'wallet-session-coordinator'; + reason?: 'open-wallet' | 'session-recovery'; + expectedPassphraseState?: string; + uiResponseCorrelation?: IHardwareUiResponseCorrelation; // firmware update tip firmwareTipData?: { message: EFirmwareUpdateTipMessages | string; @@ -38,6 +48,14 @@ export type IHardwareUiPayload = { // firmware update progress firmwareProgress?: number; firmwareProgressType?: 'transferData' | 'installingFirmware'; + // generic device data transfer progress + deviceProgress?: { + progress?: number; + transferredBytes?: number; + totalBytes?: number; + rateBytesPerSecond?: number; + elapsedMs?: number; + }; rawPayload: any; // request pin type requestPinType?: 'PinEntry' | 'AttachPin'; diff --git a/packages/kit-bg/src/states/jotai/atoms/settings.hardwareTransport.test.ts b/packages/kit-bg/src/states/jotai/atoms/settings.hardwareTransport.test.ts new file mode 100644 index 000000000000..d23a88c232ad --- /dev/null +++ b/packages/kit-bg/src/states/jotai/atoms/settings.hardwareTransport.test.ts @@ -0,0 +1,24 @@ +import { EHardwareTransportType } from '@onekeyhq/shared/types'; + +import { settingsAtomInitialValue } from './settings'; + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isDesktopLinux: true, + isNative: false, + isSupportWebUSB: true, + }, +})); + +jest.mock('../utils', () => ({ + globalAtom: jest.fn(() => ({ target: {}, use: jest.fn() })), +})); + +describe('settings default hardware transport', () => { + it('uses WebUSB on Linux desktop', () => { + expect(settingsAtomInitialValue.hardwareTransportType).toBe( + EHardwareTransportType.WEBUSB, + ); + }); +}); diff --git a/packages/kit-bg/src/vaults/base/KeyringHardwareBase.ts b/packages/kit-bg/src/vaults/base/KeyringHardwareBase.ts index 14bf7b49d47c..a6bdb155d2a3 100644 --- a/packages/kit-bg/src/vaults/base/KeyringHardwareBase.ts +++ b/packages/kit-bg/src/vaults/base/KeyringHardwareBase.ts @@ -103,7 +103,7 @@ export abstract class KeyringHardwareBase extends KeyringBase { return false; }; - const result = await convertDeviceResponse(async () => + const result = (await convertDeviceResponse(async () => sdkGetDataFn({ connectId, deviceId, @@ -114,7 +114,7 @@ export abstract class KeyringHardwareBase extends KeyringBase { receiveAddressPath, showOnOnekeyFn, }), - ); + )) as T[] | undefined; if (!result || result.length !== usedIndexes.length) { throw new OneKeyInternalError(errorMessage); diff --git a/packages/kit-bg/src/vaults/impls/ada/settings.test.ts b/packages/kit-bg/src/vaults/impls/ada/settings.test.ts new file mode 100644 index 000000000000..266a9d303a0a --- /dev/null +++ b/packages/kit-bg/src/vaults/impls/ada/settings.test.ts @@ -0,0 +1,9 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import settings from './settings'; + +describe('ADA hardware settings', () => { + it('supports OneKey Pro 2 hardware wallets', () => { + expect(settings.supportedDeviceTypes).toContain(EDeviceType.Pro2); + }); +}); diff --git a/packages/kit-bg/src/vaults/impls/ada/settings.ts b/packages/kit-bg/src/vaults/impls/ada/settings.ts index 3e6bef1ecbb8..db49576a4405 100644 --- a/packages/kit-bg/src/vaults/impls/ada/settings.ts +++ b/packages/kit-bg/src/vaults/impls/ada/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types'; import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; import { EMPTY_NATIVE_TOKEN_ADDRESS } from '@onekeyhq/shared/src/consts/addresses'; @@ -35,6 +37,16 @@ const settings: IVaultSettings = { supportExportedSecretKeys: [ECoreApiExportedSecretKeyType.xprvt], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + EDeviceType.Pro2, + ], + dappInteractionEnabled: true, minTransferAmount: '1', diff --git a/packages/kit-bg/src/vaults/impls/btc/KeyringHardware.ts b/packages/kit-bg/src/vaults/impls/btc/KeyringHardware.ts index 623dc2859c68..a4713dd3714f 100644 --- a/packages/kit-bg/src/vaults/impls/btc/KeyringHardware.ts +++ b/packages/kit-bg/src/vaults/impls/btc/KeyringHardware.ts @@ -47,7 +47,6 @@ import type { AllNetworkAddressParams, RefTransaction, } from '@onekeyfe/hd-core'; -import type { Messages } from '@onekeyfe/hd-transport'; export class KeyringHardware extends KeyringHardwareBtcBase { override coreApi = coreChainApi.btc.hd; diff --git a/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareBtcBase.ts b/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareBtcBase.ts index 1b394d33b6f5..f9469a12ee30 100644 --- a/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareBtcBase.ts +++ b/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareBtcBase.ts @@ -46,8 +46,7 @@ import type { ISignMessageParams, ISignTransactionParams, } from '../../types'; -import type { RefTransaction } from '@onekeyfe/hd-core'; -import type { HDNodeType, Messages } from '@onekeyfe/hd-transport'; +import type { HDNodeType, PROTO, RefTransaction } from '@onekeyfe/hd-core'; export abstract class KeyringHardwareBtcBase extends KeyringHardwareBase { abstract override coreApi: CoreChainSoftwareBtc | undefined; @@ -145,7 +144,7 @@ export abstract class KeyringHardwareBtcBase extends KeyringHardwareBase { private buildHardwareInput = async ( input: IBtcInput, path: string, - ): Promise => { + ): Promise => { const { getHDPath, getScriptType } = await CoreSDKLoader(); const addressN = getHDPath(path); const scriptType = getScriptType(addressN); @@ -162,7 +161,7 @@ export abstract class KeyringHardwareBtcBase extends KeyringHardwareBase { private buildHardwareOutput = async ( output: IBtcOutput, - ): Promise => { + ): Promise => { const { isChange, bip44Path, opReturn } = output.payload || {}; if (opReturn && typeof opReturn === 'string' && opReturn.length > 0) { diff --git a/packages/kit-bg/src/vaults/impls/ckb/settings.ts b/packages/kit-bg/src/vaults/impls/ckb/settings.ts index e2d87585bc85..84d807c0d630 100644 --- a/packages/kit-bg/src/vaults/impls/ckb/settings.ts +++ b/packages/kit-bg/src/vaults/impls/ckb/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { COINTYPE_CKB, IMPL_CKB, @@ -33,6 +35,15 @@ const settings: IVaultSettings = { // ECoreApiExportedSecretKeyType.publicKey, ], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + defaultFeePresetIndex: 0, isUtxo: false, diff --git a/packages/kit-bg/src/vaults/impls/dot/settings.ts b/packages/kit-bg/src/vaults/impls/dot/settings.ts index 1268c9a86e4b..67bed8aa5888 100644 --- a/packages/kit-bg/src/vaults/impls/dot/settings.ts +++ b/packages/kit-bg/src/vaults/impls/dot/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types'; import { COINTYPE_DOT, @@ -34,6 +36,16 @@ const settings: IVaultSettings = { // ECoreApiExportedSecretKeyType.publicKey, ], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + EDeviceType.Pro2, + ], + dappInteractionEnabled: true, // dApp not edit fee preCheckDappTxFeeInfoRequired: true, diff --git a/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts b/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts index b1d0b303bf02..327222f34f49 100644 --- a/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts +++ b/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { sha256 } from '@noble/hashes/sha256'; +import { EDeviceType } from '@onekeyfe/hd-shared'; import stringify from 'fast-json-stable-stringify'; import { getBtcForkNetwork } from '@onekeyhq/core/src/chains/btc/sdkBtc'; @@ -18,6 +19,7 @@ import { OneKeyInternalError, OneKeyLocalError, } from '@onekeyhq/shared/src/errors'; +import * as deviceErrors from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; import { convertDeviceError, convertDeviceResponse, @@ -38,6 +40,7 @@ import type LightningVault from './Vault'; import type { IDBAccount } from '../../../dbs/local/types'; import type { IBuildHwAllNetworkPrepareAccountsParams, + IDeviceSharedCallParams, IHwSdkNetwork, IPrepareHardwareAccountsParams, ISignMessageParams, @@ -50,6 +53,22 @@ export class KeyringHardware extends KeyringHardwareBase { override hwSdkNetwork: IHwSdkNetwork = 'btc'; + private assertPro2LightningSupported( + deviceParams: IDeviceSharedCallParams | undefined, + ) { + if (deviceParams?.dbDevice?.deviceType !== EDeviceType.Pro2) { + return; + } + + throw new deviceErrors.UnknownMethod({ + payload: { + error: 'Device not support this method', + connectId: deviceParams.dbDevice.connectId, + deviceId: deviceParams.dbDevice.deviceId, + }, + }); + } + override async buildHwAllNetworkPrepareAccountsParams( params: IBuildHwAllNetworkPrepareAccountsParams, ): Promise { @@ -86,6 +105,8 @@ export class KeyringHardware extends KeyringHardwareBase { override async prepareAccounts( params: IPrepareHardwareAccountsParams, ): Promise { + this.assertPro2LightningSupported(params.deviceParams); + const { addressEncoding } = params.deriveInfo; const networkInfo = await this.getCoreApiNetworkInfo(); const isTestnet = networkInfo.networkImpl === IMPL_LIGHTNING_TESTNET; @@ -249,6 +270,8 @@ export class KeyringHardware extends KeyringHardwareBase { override async signTransaction( params: ISignTransactionParams, ): Promise { + this.assertPro2LightningSupported(params.deviceParams); + const { unsignedTx } = params; const deviceParams = checkIsDefined(params.deviceParams); const { connectId, deviceId } = deviceParams.dbDevice; @@ -317,6 +340,8 @@ export class KeyringHardware extends KeyringHardwareBase { override async signMessage( params: ISignMessageParams, ): Promise { + this.assertPro2LightningSupported(params.deviceParams); + if (process.env.NODE_ENV !== 'production') { console.log('LightningNetwork signMessage: ', params); } @@ -349,6 +374,8 @@ export class KeyringHardware extends KeyringHardwareBase { } async lnurlAuth(params: ILnurlAuthParams) { + this.assertPro2LightningSupported(params.deviceParams); + const { lnurlDetail } = params; if (lnurlDetail.tag !== 'login') { throw new OneKeyLocalError('lnurl-auth: invalid tag'); diff --git a/packages/kit-bg/src/vaults/impls/lightning/settings-testnet.ts b/packages/kit-bg/src/vaults/impls/lightning/settings-testnet.ts index 9f6bf2713a1f..a62bf8ee61f2 100644 --- a/packages/kit-bg/src/vaults/impls/lightning/settings-testnet.ts +++ b/packages/kit-bg/src/vaults/impls/lightning/settings-testnet.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { COINTYPE_LIGHTNING_TESTNET, IMPL_LIGHTNING_TESTNET, @@ -28,6 +30,15 @@ const settings: IVaultSettings = { addressBookDisabled: true, + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + defaultFeePresetIndex: 0, isUtxo: false, diff --git a/packages/kit-bg/src/vaults/impls/lightning/settings.ts b/packages/kit-bg/src/vaults/impls/lightning/settings.ts index ba1d87adb003..a8d606fd9ecc 100644 --- a/packages/kit-bg/src/vaults/impls/lightning/settings.ts +++ b/packages/kit-bg/src/vaults/impls/lightning/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { COINTYPE_LIGHTNING, IMPL_LIGHTNING, @@ -28,6 +30,15 @@ const settings: IVaultSettings = { addressBookDisabled: true, copyAddressDisabled: true, + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + dappInteractionEnabled: true, defaultFeePresetIndex: 0, diff --git a/packages/kit-bg/src/vaults/impls/neurai/settings.ts b/packages/kit-bg/src/vaults/impls/neurai/settings.ts index 3daf60e9e78f..972fc53c0333 100644 --- a/packages/kit-bg/src/vaults/impls/neurai/settings.ts +++ b/packages/kit-bg/src/vaults/impls/neurai/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { EAddressEncodings } from '@onekeyhq/core/src/types'; import { COINNAME_NEURAI, @@ -27,6 +29,15 @@ const settings: IVaultSettings = { // Clear inherited [ledger] — Ledger does not support Neurai. supportedThirdPartyVendors: undefined, + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + importedAccountEnabled: true, hardwareAccountEnabled: true, externalAccountEnabled: false, diff --git a/packages/kit-bg/src/vaults/impls/nexa/settings.ts b/packages/kit-bg/src/vaults/impls/nexa/settings.ts index c0ef698eb789..6cc4dbf770a4 100644 --- a/packages/kit-bg/src/vaults/impls/nexa/settings.ts +++ b/packages/kit-bg/src/vaults/impls/nexa/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types'; import { COINTYPE_NEXA, @@ -31,6 +33,15 @@ const settings: IVaultSettings = { supportExportedSecretKeys: [ECoreApiExportedSecretKeyType.xprvt], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + isUtxo: true, // isSingleToken: true, NFTEnabled: false, diff --git a/packages/kit-bg/src/vaults/impls/stellar/settings.ts b/packages/kit-bg/src/vaults/impls/stellar/settings.ts index fe1c4c74e027..865d5becf0e7 100644 --- a/packages/kit-bg/src/vaults/impls/stellar/settings.ts +++ b/packages/kit-bg/src/vaults/impls/stellar/settings.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types'; import { COINTYPE_STELLAR, @@ -31,6 +33,15 @@ const settings: IVaultSettings = { supportExportedSecretKeys: [ECoreApiExportedSecretKeyType.privateKey], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + ], + defaultFeePresetIndex: 0, isUtxo: false, diff --git a/packages/kit-bg/src/vaults/impls/ton/settings.ts b/packages/kit-bg/src/vaults/impls/ton/settings.ts index a594d4fdef5f..7683eb69f1d8 100644 --- a/packages/kit-bg/src/vaults/impls/ton/settings.ts +++ b/packages/kit-bg/src/vaults/impls/ton/settings.ts @@ -64,6 +64,7 @@ const settings: IVaultSettings = { supportedDeviceTypes: [ EDeviceType.Touch, EDeviceType.Pro, + EDeviceType.Pro2, EDeviceType.Classic1s, EDeviceType.ClassicPure, ], diff --git a/packages/kit-bg/src/vaults/types.ts b/packages/kit-bg/src/vaults/types.ts index 4355252ab905..65371563ec7c 100644 --- a/packages/kit-bg/src/vaults/types.ts +++ b/packages/kit-bg/src/vaults/types.ts @@ -91,6 +91,7 @@ export enum EVaultKeyringTypes { } export { EUtxoSelectionStrategy } from '@onekeyhq/shared/types/send'; +export type { IDeviceSharedCallParams } from '@onekeyhq/shared/types/device'; // AccountNameInfo export type IAccountDeriveInfoItems = { diff --git a/packages/kit/src/components/Hardware/Hardware.tsx b/packages/kit/src/components/Hardware/Hardware.tsx index 6ff95eed3bab..76fcaae5a008 100644 --- a/packages/kit/src/components/Hardware/Hardware.tsx +++ b/packages/kit/src/components/Hardware/Hardware.tsx @@ -453,6 +453,7 @@ export function ConfirmOnDeviceToastContent({ return import('@onekeyhq/kit/assets/animations/confirm-on-mini.json'); case EDeviceType.Touch: return import('@onekeyhq/kit/assets/animations/confirm-on-touch.json'); + case EDeviceType.Pro2: case EDeviceType.Pro: return import('@onekeyhq/kit/assets/animations/confirm-on-pro-dark.json'); default: @@ -572,6 +573,7 @@ export function EnterPinOnDevice({ case EDeviceType.Touch: return import('@onekeyhq/kit/assets/animations/enter-pin-on-touch.json'); case EDeviceType.Pro: + case EDeviceType.Pro2: return import('@onekeyhq/kit/assets/animations/enter-pin-on-pro-dark.json'); default: // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-case-declarations @@ -832,6 +834,7 @@ export function EnterPassphraseOnDevice({ case EDeviceType.Touch: return import('@onekeyhq/kit/assets/animations/enter-passphrase-on-touch.json'); case EDeviceType.Pro: + case EDeviceType.Pro2: return import('@onekeyhq/kit/assets/animations/enter-passphrase-on-pro-dark.json'); default: // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-case-declarations diff --git a/packages/kit/src/components/Hardware/HardwareDialog.tsx b/packages/kit/src/components/Hardware/HardwareDialog.tsx index 70191ca8492d..eaf39c5de4e7 100644 --- a/packages/kit/src/components/Hardware/HardwareDialog.tsx +++ b/packages/kit/src/components/Hardware/HardwareDialog.tsx @@ -12,6 +12,7 @@ import { } from '@onekeyhq/shared/src/hardware/blePermissions'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import backgroundApiProxy from '../../background/instance/backgroundApiProxy'; import { usePromiseResult } from '../../hooks/usePromiseResult'; @@ -178,9 +179,11 @@ function WebDeviceAccessDialogContent({ await backgroundApiProxy.serviceHardware.getDeviceByConnectId({ connectId, }); - return ( - device?.featuresInfo?.ble_name || `OneKey ${device?.deviceType || ''}` - ); + return device?.featuresInfo + ? deviceUtils.buildDeviceBleName({ + features: device.featuresInfo, + }) || `OneKey ${device?.deviceType || ''}` + : ''; } catch (error) { console.log('======>: error: ', error); return ''; diff --git a/packages/kit/src/components/Hardware/HardwareEnterPhase.test.ts b/packages/kit/src/components/Hardware/HardwareEnterPhase.test.ts new file mode 100644 index 000000000000..abdb6df7124b --- /dev/null +++ b/packages/kit/src/components/Hardware/HardwareEnterPhase.test.ts @@ -0,0 +1,45 @@ +import { resolvePassphraseEntryUi } from './HardwareEnterPhase.utils'; + +describe('resolvePassphraseEntryUi', () => { + it('uses device entry without rendering the host input in device-only mode', () => { + expect( + resolvePassphraseEntryUi({ + deviceOnly: true, + isVerifyMode: false, + passphrase: '', + }), + ).toEqual({ + showHostInput: false, + primaryAction: 'device', + primaryDisabled: false, + }); + }); + + it('shows Host input when Pro2 explicitly sets deviceOnly=false', () => { + expect( + resolvePassphraseEntryUi({ + deviceOnly: false, + isVerifyMode: false, + passphrase: '', + }), + ).toEqual({ + showHostInput: true, + primaryAction: 'host', + primaryDisabled: true, + }); + }); + + it('does not submit an empty Host passphrase while recovering a hidden wallet', () => { + expect( + resolvePassphraseEntryUi({ + deviceOnly: false, + isVerifyMode: true, + passphrase: '', + }), + ).toEqual({ + showHostInput: true, + primaryAction: 'host', + primaryDisabled: true, + }); + }); +}); diff --git a/packages/kit/src/components/Hardware/HardwareEnterPhase.tsx b/packages/kit/src/components/Hardware/HardwareEnterPhase.tsx index 785323c9add4..30de274d0801 100644 --- a/packages/kit/src/components/Hardware/HardwareEnterPhase.tsx +++ b/packages/kit/src/components/Hardware/HardwareEnterPhase.tsx @@ -25,7 +25,12 @@ import { import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; -import { isPassphraseValid } from '../../utils/passphraseUtils'; +import { + isPassphraseValid, + normalizeProtocolV2Passphrase, +} from '../../utils/passphraseUtils'; + +import { resolvePassphraseEntryUi } from './HardwareEnterPhase.utils'; interface IEnterPhaseFormValues { passphrase: string; @@ -36,6 +41,8 @@ interface IEnterPhaseFormValues { export type IEnterPhaseProps = { isVerifyMode?: boolean; allowUseAttachPin?: boolean; + deviceOnly?: boolean; + allowProtocolV2Utf8?: boolean; onConfirm: (p: { passphrase: string; save: boolean; @@ -52,6 +59,8 @@ export type IEnterPhaseProps = { export function EnterPhase({ isVerifyMode, allowUseAttachPin, + deviceOnly, + allowProtocolV2Utf8, onConfirm, switchOnDevice, switchOnDeviceAttachPin, @@ -70,7 +79,9 @@ export function EnterPhase({ }, onSubmit: async (form: UseFormReturn) => { const values = form.getValues(); - const passphrase = values.passphrase || ''; + const passphrase = allowProtocolV2Utf8 + ? normalizeProtocolV2Passphrase(values.passphrase || '') + : values.passphrase || ''; onConfirm({ passphrase, save: true, @@ -78,7 +89,7 @@ export function EnterPhase({ }); }, }), - [onConfirm, settings.hiddenWalletImmediately], + [allowProtocolV2Utf8, onConfirm, settings.hiddenWalletImmediately], ); const form = useForm(formOption); @@ -97,9 +108,12 @@ export function EnterPhase({ // Watch passphrase input to control button state const passphraseValue = form.watch('passphrase'); - const isButtonDisabled = isVerifyMode - ? false - : !passphraseValue || passphraseValue === ''; + const { showHostInput, primaryAction, primaryDisabled } = + resolvePassphraseEntryUi({ + deviceOnly: deviceOnly === true, + isVerifyMode: isVerifyMode === true, + passphrase: passphraseValue, + }); return ( @@ -112,112 +126,126 @@ export function EnterPhase({ />
- - + {showHostInput ? ( + + + {intl.formatMessage({ + id: ETranslations.passphrase_character_limit, + })} + + + } + renderContent={() => ( + + + {intl.formatMessage({ + id: ETranslations.passphrase_allowed_characters_desc, + })} + + + )} + /> + + ) + } + labelAddon={ + + } + rules={{ + maxLength: { + value: 50, + message: intl.formatMessage( + { + id: ETranslations.hardware_passphrase_enter_too_long, + }, + { + 0: 50, + }, + ), + }, + validate: (text) => { + const valid = isPassphraseValid(text, { + allowProtocolV2Utf8, + }); + if (valid) { + return undefined; } - renderContent={() => ( - - - {intl.formatMessage({ - id: ETranslations.passphrase_allowed_characters_desc, - })} - - - )} - /> - - } - labelAddon={ - {allowUseAttachPin ? ( + + 事件:Confirm =》Confirm =》Pin =》Pin =》Confirm =》Confirm =》Pin diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx index de70f9dfaa2a..79752e094393 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx @@ -154,9 +154,9 @@ function DeviceBasicInfo({ - {deviceMetaStatic.deviceName ? ( + {deviceMetaStatic.serialNo ? ( - {deviceMetaStatic.deviceName} + {deviceMetaStatic.serialNo} ) : null} {isQrWallet || !showFirmwareVersion ? null : ( diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.test.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.test.tsx new file mode 100644 index 000000000000..72b723a658c4 --- /dev/null +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.test.tsx @@ -0,0 +1,118 @@ +/** + * @jest-environment jsdom + */ + +import { StrictMode } from 'react'; + +import { act, renderHook } from '@testing-library/react'; + +import { + normalizeDeviceBrightness, + useDeviceBrightnessSlider, +} from './DeviceBrightnessSlider'; + +describe('DeviceBrightnessSlider', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const tick = async (ms: number) => { + await act(async () => { + jest.advanceTimersByTime(ms); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + it('clamps and rounds brightness to the supported range', () => { + expect(normalizeDeviceBrightness(9.6)).toBe(10); + expect(normalizeDeviceBrightness(67.6)).toBe(68); + expect(normalizeDeviceBrightness(100.4)).toBe(100); + }); + + it('updates immediately without committing while sliding', async () => { + const onCommit = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useDeviceBrightnessSlider({ value: 50, onCommit }), + ); + + act(() => { + result.current.handleChange(61.2); + result.current.handleChange(72.8); + }); + + expect(result.current.displayValue).toBe(73); + expect(onCommit).not.toHaveBeenCalled(); + + await tick(1000); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('flushes the final value when sliding completes', async () => { + const onCommit = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => + useDeviceBrightnessSlider({ value: 40, onCommit }), + ); + + act(() => { + result.current.handleChange(84.6); + result.current.handleSlideComplete(); + }); + await tick(0); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(85); + }); + + it('cancels a pending write when unmounted', async () => { + const onCommit = jest.fn().mockResolvedValue(undefined); + const { result, unmount } = renderHook(() => + useDeviceBrightnessSlider({ value: 50, onCommit }), + ); + + act(() => { + result.current.handleChange(70); + }); + unmount(); + await tick(300); + + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('rolls back to the last device value when the latest write fails', async () => { + const onCommit = jest.fn().mockRejectedValue(new Error('write failed')); + const { result } = renderHook(() => + useDeviceBrightnessSlider({ value: 40, onCommit }), + ); + + act(() => { + result.current.handleChange(80); + result.current.handleSlideComplete(); + }); + await tick(0); + + expect(result.current.displayValue).toBe(40); + }); + + it('keeps failure rollback active under React StrictMode', async () => { + const onCommit = jest.fn().mockRejectedValue(new Error('write failed')); + const { result } = renderHook( + () => useDeviceBrightnessSlider({ value: 30, onCommit }), + { + wrapper: ({ children }) => {children}, + }, + ); + + act(() => { + result.current.handleChange(90); + result.current.handleSlideComplete(); + }); + await tick(0); + + expect(result.current.displayValue).toBe(30); + }); +}); diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.tsx new file mode 100644 index 000000000000..167e9aa670bf --- /dev/null +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useIntl } from 'react-intl'; + +import { + SegmentSlider, + SizableText, + XStack, + YStack, +} from '@onekeyhq/components'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; + +import { DeviceManagementTestIDs } from '../../testIDs'; + +const MIN_BRIGHTNESS = 10; +const MAX_BRIGHTNESS = 100; + +export function normalizeDeviceBrightness(value: number) { + return Math.min(MAX_BRIGHTNESS, Math.max(MIN_BRIGHTNESS, Math.round(value))); +} + +export function useDeviceBrightnessSlider({ + value, + onCommit, +}: { + value: number; + onCommit: (value: number) => Promise; +}) { + const normalizedValue = normalizeDeviceBrightness(value); + const [displayValue, setDisplayValue] = useState(normalizedValue); + const displayValueRef = useRef(normalizedValue); + const confirmedValueRef = useRef(normalizedValue); + const onCommitRef = useRef(onCommit); + const scheduledRef = useRef<{ revision: number; value: number } | undefined>( + undefined, + ); + const latestRevisionRef = useRef(0); + const settledRevisionRef = useRef(0); + const mountedRef = useRef(true); + + useEffect(() => { + onCommitRef.current = onCommit; + }, [onCommit]); + + useEffect(() => { + confirmedValueRef.current = normalizedValue; + if (latestRevisionRef.current === settledRevisionRef.current) { + displayValueRef.current = normalizedValue; + setDisplayValue(normalizedValue); + } + }, [normalizedValue]); + + const commit = useCallback( + async ({ revision, value: next }: { revision: number; value: number }) => { + try { + await onCommitRef.current(next); + if (revision === latestRevisionRef.current) { + settledRevisionRef.current = revision; + } + } catch { + if (revision === latestRevisionRef.current && mountedRef.current) { + settledRevisionRef.current = revision; + displayValueRef.current = confirmedValueRef.current; + setDisplayValue(confirmedValueRef.current); + } + } + }, + [], + ); + + const handleChange = useCallback((nextValue: number) => { + const next = normalizeDeviceBrightness(nextValue); + if (next === displayValueRef.current) return; + + displayValueRef.current = next; + setDisplayValue(next); + latestRevisionRef.current += 1; + scheduledRef.current = { + revision: latestRevisionRef.current, + value: next, + }; + }, []); + + const handleSlideComplete = useCallback(() => { + const scheduled = scheduledRef.current; + if (!scheduled) return; + + scheduledRef.current = undefined; + void commit(scheduled); + }, [commit]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + scheduledRef.current = undefined; + }; + }, []); + + return { + displayValue, + handleChange, + handleSlideComplete, + }; +} + +export function DeviceBrightnessSlider({ + value, + disabled, + onCommit, +}: { + value: number; + disabled?: boolean; + onCommit: (value: number) => Promise; +}) { + const intl = useIntl(); + const { displayValue, handleChange, handleSlideComplete } = + useDeviceBrightnessSlider({ value, onCommit }); + + return ( + + + + {intl.formatMessage({ id: ETranslations.global_brightness })} + + + {displayValue}% + + + + + ); +} diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx index eb136a63f530..5e3bb4718dfd 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx @@ -58,8 +58,9 @@ function DeviceSectionAdvancePassphrase() { })} titleProps={{ size: '$bodyMdMedium', color: '$text' }} justifyContent="center" - value={passphraseEnabled} + value={passphraseEnabled ?? false} onAction={onPressPassphrase} + disabled={passphraseEnabled === undefined} > {({ value, disabled, onChange }) => ( {({ value, disabled, onChange }) => ( { - if (!isAllowChangeFirmwareType) { + if (firmwareTypeChangeAvailability === 'hidden') { return null; } return ( @@ -92,15 +97,34 @@ function DeviceSectionDangerZone({ }, )} titleProps={{ size: '$bodyMdMedium', color: '$text' }} - drillIn - onPress={onPressFirmwareTypeChange} + disabled={isFirmwareTypeChangeComingSoon} + drillIn={!isFirmwareTypeChangeComingSoon} + onPress={ + isFirmwareTypeChangeComingSoon ? undefined : onPressFirmwareTypeChange + } testID={DeviceManagementTestIDs.switchFirmwareTypeItem} - /> + > + {isFirmwareTypeChangeComingSoon ? ( + + + + ) : null} + ); }, [ - isAllowChangeFirmwareType, + firmwareTypeChangeAvailability, deviceMetaStatic.firmwareType, intl, + isFirmwareTypeChangeComingSoon, onPressFirmwareTypeChange, ]); diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx index 6fad98357c9f..c1873b370951 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react'; +import { EDeviceType } from '@onekeyfe/hd-shared'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -9,13 +10,17 @@ import useAppNavigation from '@onekeyhq/kit/src/hooks/useAppNavigation'; import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; import { useStatefulAction } from '@onekeyhq/kit/src/hooks/useStatefulAction'; import { + canEditPro2DeviceWideSettings, + resolveDeviceWithCurrentType, useDeviceAtom, useDeviceAutoLockDelayMsAtom, useDeviceAutoShutDownDelayMsAtom, + useDeviceBrightnessAtom, useDeviceDetailsActions, useDeviceHapticFeedbackAtom, useDeviceLanguageAtom, useDeviceMetaStaticAtom, + useDeviceSettingsAccessibleAtom, useDeviceTypeAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/deviceDetails'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -32,11 +37,22 @@ import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { DeviceManagementTestIDs } from '../../testIDs'; import { ListItemGroup } from '../ListItemGroup'; +import { DeviceBrightnessSlider } from './DeviceBrightnessSlider'; import { TREZOR_AUTO_LOCK_OPTIONS } from './utils'; -const NEVER_LOCK_VALUE = 268_435_456; const LOCKED_VALUE = 0; +type IDeviceLanguageOption = { + label: string; + code: string; +}; + +type IDeviceDelayOption = { + isNever: boolean; + label: string; + valueMs: number; +}; + function getDurationLabel({ intl, option, @@ -62,6 +78,30 @@ function getDurationLabel({ ); } +function getDeviceDurationLabel({ + intl, + isNever, + valueMs, +}: { + intl: ReturnType; + isNever: boolean; + valueMs: number; +}) { + if (isNever) { + return intl.formatMessage({ id: ETranslations.global_never }); + } + if (valueMs < 60_000) { + return intl.formatMessage( + { id: ETranslations.earn_number_seconds }, + { number: valueMs / 1000 }, + ); + } + return intl.formatMessage( + { id: ETranslations.earn_number_minutes }, + { number: valueMs / 60_000 }, + ); +} + function isNumberFeature(features: Record, field: string) { return typeof features[field] === 'number'; } @@ -72,16 +112,26 @@ function isBooleanFeature(features: Record, field: string) { export function LanguageListItem({ languageOptions, + disabled, }: { languageOptions: Array<{ label: string; value: string }>; + disabled?: boolean; }) { const intl = useIntl(); const actions = useDeviceDetailsActions(); const [language] = useDeviceLanguageAtom(); + const languageCode = useMemo( + () => + deviceUtils.resolveDeviceLanguageCode({ + language, + supportedCodes: languageOptions.map((option) => option.value), + }), + [language, languageOptions], + ); const stateful = useStatefulAction({ - value: language || 'en', + value: languageCode || language || 'en', onAction: actions.updateLanguage, }); @@ -102,7 +152,7 @@ export function LanguageListItem({ title={intl.formatMessage({ id: ETranslations.global_language, })} - disabled={stateful.loading} + disabled={disabled || stateful.loading} testID={DeviceManagementTestIDs.languageSelect} renderTrigger={() => ( ; + autoLockOptions: Array<{ + isNever?: boolean; + label: string; + value: number; + }>; + disabled?: boolean; }) { const intl = useIntl(); const actions = useDeviceDetailsActions(); @@ -149,8 +205,11 @@ export function AutoLockListItem({ }); const { displayLabel } = useMemo(() => { - const locked = stateful.value === LOCKED_VALUE; - const never = stateful.value === NEVER_LOCK_VALUE; + const selectedOption = autoLockOptions.find( + (option) => option.value === stateful.value, + ); + const never = Boolean(selectedOption?.isNever); + const locked = stateful.value === LOCKED_VALUE && !never; let label = ''; if (locked) { @@ -176,7 +235,7 @@ export function AutoLockListItem({ title={intl.formatMessage({ id: ETranslations.global_auto_lock, })} - disabled={stateful.loading} + disabled={disabled || stateful.loading} testID={DeviceManagementTestIDs.autoLockSelect} renderTrigger={() => ( ; + autoShutDownOptions: Array<{ + isNever?: boolean; + label: string; + value: number; + }>; + disabled?: boolean; }) { const intl = useIntl(); const actions = useDeviceDetailsActions(); @@ -223,8 +288,11 @@ export function AutoShutDownListItem({ }); const { displayLabel } = useMemo(() => { - const locked = stateful.value === LOCKED_VALUE; - const never = stateful.value === NEVER_LOCK_VALUE; + const selectedOption = autoShutDownOptions.find( + (option) => option.value === stateful.value, + ); + const never = Boolean(selectedOption?.isNever); + const locked = stateful.value === LOCKED_VALUE && !never; let label = ''; if (locked) { @@ -252,7 +320,7 @@ export function AutoShutDownListItem({ title={intl.formatMessage({ id: ETranslations.global_auto_shutdown, })} - disabled={stateful.loading} + disabled={disabled || stateful.loading} testID={DeviceManagementTestIDs.autoShutDownSelect} renderTrigger={() => ( - {({ value, disabled, onChange }) => ( + {({ value, disabled: actionDisabled, onChange }) => ( )} @@ -320,6 +394,19 @@ export function HapticFeedbackListItem() { ); } +function Pro2BrightnessListItem({ disabled }: { disabled?: boolean }) { + const actions = useDeviceDetailsActions(); + const [brightness] = useDeviceBrightnessAtom(); + + return ( + + ); +} + function DeviceSectionGeneral() { const intl = useIntl(); const actions = useDeviceDetailsActions(); @@ -327,8 +414,26 @@ function DeviceSectionGeneral() { const [deviceMeta] = useDeviceMetaStaticAtom(); const [deviceType] = useDeviceTypeAtom(); + const [deviceSettingsAccessible] = useDeviceSettingsAccessibleAtom(); const [device] = useDeviceAtom(); const isTrezor = device?.vendor === EHardwareVendor.trezor; + const settingsProtocol = useMemo(() => { + const stateProtocol = device?.deviceStateInfo?.protocol; + if (stateProtocol === 'V1' || stateProtocol === 'V2') { + return stateProtocol; + } + const connectProtocol = device?.connectProtocol; + if (connectProtocol === 'V1' || connectProtocol === 'V2') { + return connectProtocol; + } + return undefined; + }, [device?.connectProtocol, device?.deviceStateInfo?.protocol]); + const generalSettingsDisabled = + deviceType === EDeviceType.Pro2 + ? !canEditPro2DeviceWideSettings({ + unlocked: Boolean(deviceSettingsAccessible), + }) + : !deviceSettingsAccessible; const trezorFeatures = useMemo( () => (device?.featuresInfo ?? {}) as Record, [device?.featuresInfo], @@ -339,7 +444,9 @@ function DeviceSectionGeneral() { async () => { if (isTrezor) return []; if (!deviceType) return []; - const options = await deviceUtils.getLanguageConfig({ deviceType }); + const options = (await deviceUtils.getLanguageConfig({ + deviceType, + })) as IDeviceLanguageOption[]; return options.map((option) => ({ label: option.label, value: option.code, @@ -360,35 +467,22 @@ function DeviceSectionGeneral() { value: timerUtils.getTimeDurationMs(option), })); } - if (!deviceType) return []; - const options = await deviceUtils.getAutoLockOptions({ deviceType }); - return options.map((option) => { - const value = timerUtils.getTimeDurationMs(option); - if ( - option.seconds === 0 && - option.minute === 0 && - option.hour === 0 && - option.day === 0 - ) { - return { - label: intl.formatMessage({ id: ETranslations.global_never }), - value: NEVER_LOCK_VALUE, - }; - } - - const label = option.seconds - ? intl.formatMessage( - { id: ETranslations.earn_number_seconds }, - { number: option.seconds }, - ) - : intl.formatMessage( - { id: ETranslations.earn_number_minutes }, - { number: option.minute }, - ); - return { label, value }; - }); + if (!deviceType || !settingsProtocol) return []; + const options = (await deviceUtils.getAutoLockOptions({ + deviceType, + protocol: settingsProtocol, + })) as IDeviceDelayOption[]; + return options.map((option) => ({ + isNever: option.isNever, + label: getDeviceDurationLabel({ + intl, + isNever: option.isNever, + valueMs: option.valueMs, + }), + value: option.valueMs, + })); }, - [deviceType, intl, isTrezor], + [deviceType, intl, isTrezor, settingsProtocol], { initResult: [], }, @@ -398,34 +492,22 @@ function DeviceSectionGeneral() { const { result: autoShutDownOptions } = usePromiseResult( async () => { if (isTrezor) return []; - if (!deviceType) return []; - const options = await deviceUtils.getAutoShutDownOptions({ deviceType }); - return options.map((option) => { - const value = timerUtils.getTimeDurationMs(option); - if ( - option.seconds === 0 && - option.minute === 0 && - option.hour === 0 && - option.day === 0 - ) { - return { - label: intl.formatMessage({ id: ETranslations.global_never }), - value: NEVER_LOCK_VALUE, - }; - } - const label = option.seconds - ? intl.formatMessage( - { id: ETranslations.earn_number_seconds }, - { number: option.seconds }, - ) - : intl.formatMessage( - { id: ETranslations.earn_number_minutes }, - { number: option.minute }, - ); - return { label, value }; - }); + if (!deviceType || !settingsProtocol) return []; + const options = (await deviceUtils.getAutoShutDownOptions({ + deviceType, + protocol: settingsProtocol, + })) as IDeviceDelayOption[]; + return options.map((option) => ({ + isNever: option.isNever, + label: getDeviceDurationLabel({ + intl, + isNever: option.isNever, + valueMs: option.valueMs, + }), + value: option.valueMs, + })); }, - [deviceType, intl, isTrezor], + [deviceType, intl, isTrezor, settingsProtocol], { initResult: [], }, @@ -519,10 +601,10 @@ function DeviceSectionGeneral() { navigation.pushModal(EModalRoutes.AccountManagerStacks, { screen: EAccountManagerStacksRoutes.HardwareHomeScreenModal, params: { - device: deviceData.device, + device: resolveDeviceWithCurrentType(deviceData.device, deviceType), }, }); - }, [navigation, actions]); + }, [navigation, actions, deviceType]); const onPressBrightness = useCallback(async () => { await actions.updateBrightness(); @@ -542,6 +624,22 @@ function DeviceSectionGeneral() { return null; } + const brightnessItem = + deviceType === EDeviceType.Pro2 ? ( + + ) : ( + + ); + return ( {showLanguage ? ( - + ) : null} {showWallpaper ? ( ) : null} - {showBrightness ? ( - - ) : null} + {showBrightness ? brightnessItem : null} {showAutoLock ? ( - + ) : null} {showAutoShutDown ? ( - + + ) : null} + {showHapticFeedback ? ( + ) : null} - {showHapticFeedback ? : null} ); } diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionSupport.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionSupport.tsx index 2c8a2e460339..7449614977db 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionSupport.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionSupport.tsx @@ -70,7 +70,7 @@ function DeviceSectionSupport({ return ( { - if (!device || !device.featuresInfo) { + if (!device) { return defaultDeviceInfo; } - const profile = getVendorProfile(device.vendor ?? EHardwareVendor.onekey); - const versions = profile.isThirdParty - ? thirdPartyDeviceUtils.getDeviceVersion({ - device, - features: device.featuresInfo, - }) - : await deviceUtils.getDeviceVersion({ - device, - features: device.featuresInfo, - }); + const vendorProfile = getVendorProfile( + device.vendor ?? EHardwareVendor.onekey, + ); + const state = vendorProfile.isThirdParty + ? undefined + : await backgroundApiProxy.serviceHardware + .getDeviceState({ + connectId: device.connectId, + params: { + scope: 'firmware', + }, + silentMode: true, + }) + .catch(() => device.deviceStateInfo); + + let versions; + if (vendorProfile.isThirdParty) { + versions = thirdPartyDeviceUtils.getDeviceVersion({ + device, + features: device.featuresInfo ?? ({} as never), + }); + } else if (state) { + versions = deviceUtils.getDeviceVersionsFromState({ state }); + } else { + versions = await deviceUtils.getDeviceVersion({ + device, + features: device.featuresInfo, + }); + } const features = device.featuresInfo as typeof device.featuresInfo & { internal_model?: string; model?: string; }; - const model = profile.isThirdParty - ? thirdPartyDeviceUtils.getDeviceModelName({ - device, - features, - defaultDeviceName: profile.defaultDeviceName, - }) - : await deviceUtils.buildDeviceLabel({ - features: device.featuresInfo, - buildModelName: true, - }); - - const firmwareTypeLabel = profile.isThirdParty - ? deviceUtils.getFirmwareTypeLabelByFirmwareType({ - firmwareType: thirdPartyDeviceUtils.getFirmwareType({ - features: device?.featuresInfo, - }), - displayFormat: 'withSpace', - }) - : await deviceUtils.getFirmwareTypeLabel({ + let model: string | undefined; + if (vendorProfile.isThirdParty && device.featuresInfo) { + model = thirdPartyDeviceUtils.getDeviceModelName({ + device, + features, + defaultDeviceName: vendorProfile.defaultDeviceName, + }); + } else if (state) { + model = deviceUtils.getDefaultDeviceLabel(state.identity.deviceType); + } else if (device.featuresInfo) { + model = await deviceUtils.buildDeviceLabel({ + features: device.featuresInfo, + buildModelName: true, + }); + } + + let firmwareTypeLabel; + if (vendorProfile.isThirdParty) { + firmwareTypeLabel = deviceUtils.getFirmwareTypeLabelByFirmwareType({ + firmwareType: thirdPartyDeviceUtils.getFirmwareType({ features: device?.featuresInfo, - displayFormat: 'withSpace', - }); + }), + displayFormat: 'withSpace', + }); + } else if (state) { + firmwareTypeLabel = deviceUtils.getFirmwareTypeLabelByFirmwareType({ + firmwareType: state.identity.firmwareType, + displayFormat: 'withSpace', + }); + } else { + firmwareTypeLabel = await deviceUtils.getFirmwareTypeLabel({ + features: device?.featuresInfo, + displayFormat: 'withSpace', + }); + } const firmwareVersion = `${firmwareTypeLabel}${getDisplayVersion( versions?.firmwareVersion, )}`; + const deviceType = state?.identity.deviceType ?? device.deviceType; return { model: model ?? VERSION_PLACEHOLDER, - bleName: device.featuresInfo.ble_name ?? VERSION_PLACEHOLDER, + bleName: + state?.identity.bleName ?? + deviceUtils.buildDeviceBleName({ + features: device.featuresInfo, + }) ?? + VERSION_PLACEHOLDER, bleVersion: getDisplayVersion(versions?.bleVersion), bootloaderVersion: getDisplayVersion(versions?.bootloaderVersion), firmwareVersion, serialNumber: - (profile.isThirdParty + (vendorProfile.isThirdParty && device.featuresInfo ? thirdPartyDeviceUtils.getSerialNo(device.featuresInfo) - : deviceUtils.getDeviceSerialNoFromFeatures(device.featuresInfo)) ?? + : state?.identity.serialNo || + deviceUtils.getDeviceSerialNoFromFeatures(device.featuresInfo)) ?? VERSION_PLACEHOLDER, certifications: [ EDeviceType.Pro, + EDeviceType.Pro2, EDeviceType.Classic1s, EDeviceType.ClassicPure, - ].includes(device.deviceType) + ].includes(deviceType) ? 'EAL 6+' : null, }; diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogFirmwareChange.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogFirmwareChange.tsx index 6fbf7cf4aa4a..2025669814d1 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogFirmwareChange.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogFirmwareChange.tsx @@ -172,7 +172,9 @@ function FirmwareChangeDialogContentBase({ // get device version information const versions = await deviceUtils.getDeviceVersion({ device, - features: checkAllResultInfo?.features, + features: checkAllResultInfo?.features as + | import('@onekeyhq/shared/types/device').IOneKeyDeviceFeatures + | undefined, }); // check bootloader version diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/index.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/index.tsx index a7bf89f2d30a..9ff98a8bea3d 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/index.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect } from 'react'; +import { useFocusEffect } from '@react-navigation/core'; import { useIntl } from 'react-intl'; import { Page, XStack, YStack, useMedia } from '@onekeyhq/components'; @@ -9,6 +10,7 @@ import { ProviderJotaiContextDeviceDetails, useDeviceAtom, useDeviceDetailsActions, + useDeviceMetaStateAtom, } from '@onekeyhq/kit/src/states/jotai/contexts/deviceDetails'; import { useFirmwareUpdateActions } from '@onekeyhq/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions'; import { useDevSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms/devSettings'; @@ -16,6 +18,7 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import type { IAppEventBusPayload } from '@onekeyhq/shared/src/eventBus/appEventBus'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import type { EModalDeviceManagementRoutes, @@ -42,7 +45,11 @@ import DeviceSectionSecurity from './DeviceSectionSecurity'; import DeviceSectionSupport from './DeviceSectionSupport'; import DeviceSectionTrezorDebug from './DeviceSectionTrezorDebug'; import { DeviceUpdateAlert } from './DeviceUpdateAlert'; -import { buildDeviceDetailsVisibility } from './utils'; +import { + buildDeviceDetailsVisibility, + shouldShowDeviceInteractiveSections, + syncRelevantDeviceStateEvent, +} from './utils'; import type { AllFirmwareRelease } from '@onekeyfe/hd-core'; import type { EFirmwareType } from '@onekeyfe/hd-shared'; @@ -70,17 +77,23 @@ function DeviceDetailsModalV2Cmp({ initialDeviceVendor?: EHardwareVendor; }) { const intl = useIntl(); - const { refresh } = useDeviceDetailsActions(); + const localActions = useDeviceDetailsActions(); + const { applyDeviceStateEvent, refresh } = localActions; const { handleBackPress } = useDeviceBackNavigation(); const isQrWallet = accountUtils.isQrWallet({ walletId }); const [device] = useDeviceAtom(); + const [deviceMetaState] = useDeviceMetaStateAtom(); const [devSettings] = useDevSettingsPersistAtom(); const deviceVendor = device?.vendor ?? initialDeviceVendor; // DEV-ONLY Trezor THP debug tools, shown only in developer mode. const showTrezorDebug = devSettings.enabled && deviceVendor === EHardwareVendor.trezor; const hasLoadedDevice = isQrWallet || Boolean(device); + const showInteractiveSections = shouldShowDeviceInteractiveSections( + device?.deviceType, + deviceMetaState.isReady, + ); const { vendorProfile, showFirmwareActions, @@ -94,28 +107,80 @@ function DeviceDetailsModalV2Cmp({ hasLoadedDevice, }); - useEffect(() => { + const refreshCurrentDevice = useCallback(async () => { if (!walletId) return; - const fn = async () => { - const data = await refresh(walletId); - if (!data) { - void handleBackPress?.(); - } + const data = await refresh(walletId); + if (!data) { + void handleBackPress?.(); + } + }, [refresh, walletId, handleBackPress]); + + const refreshConfirmedState = useCallback( + async ( + event: IAppEventBusPayload[EAppEventBusNames.HardwareDeviceStateUpdate], + ) => { + if (!walletId) return; + await syncRelevantDeviceStateEvent({ + event, + applyEvent: applyDeviceStateEvent, + refresh: () => refresh(walletId, { skipDeviceStateSnapshot: true }), + }); + }, + [applyDeviceStateEvent, refresh, walletId], + ); + + const refreshLegacyFeatures = useCallback(async () => { + if (!walletId) return; + await refresh(walletId, { skipDeviceStateSnapshot: true }); + }, [refresh, walletId]); + + useFocusEffect( + useCallback(() => { + void refreshCurrentDevice(); + }, [refreshCurrentDevice]), + ); + + useEffect(() => { + const refreshAfterFirmwareUpdate = async () => { + await refresh(walletId, { refreshFirmwareInfo: true }); }; - void fn(); - appEventBus.on(EAppEventBusNames.WalletUpdate, fn); - appEventBus.on(EAppEventBusNames.HardwareFeaturesUpdate, fn); - appEventBus.on(EAppEventBusNames.FinishFirmwareUpdate, fn); + appEventBus.on(EAppEventBusNames.WalletUpdate, refreshCurrentDevice); + appEventBus.on( + EAppEventBusNames.HardwareDeviceStateUpdate, + refreshConfirmedState, + ); + appEventBus.on( + EAppEventBusNames.HardwareFeaturesUpdate, + refreshLegacyFeatures, + ); + appEventBus.on( + EAppEventBusNames.FinishFirmwareUpdate, + refreshAfterFirmwareUpdate, + ); return () => { - appEventBus.off(EAppEventBusNames.WalletUpdate, fn); - appEventBus.off(EAppEventBusNames.HardwareFeaturesUpdate, fn); - appEventBus.off(EAppEventBusNames.FinishFirmwareUpdate, fn); + appEventBus.off(EAppEventBusNames.WalletUpdate, refreshCurrentDevice); + appEventBus.off( + EAppEventBusNames.HardwareDeviceStateUpdate, + refreshConfirmedState, + ); + appEventBus.off( + EAppEventBusNames.HardwareFeaturesUpdate, + refreshLegacyFeatures, + ); + appEventBus.off( + EAppEventBusNames.FinishFirmwareUpdate, + refreshAfterFirmwareUpdate, + ); }; - }, [refresh, walletId, handleBackPress]); + }, [ + refresh, + refreshConfirmedState, + refreshCurrentDevice, + refreshLegacyFeatures, + walletId, + ]); const actions = useFirmwareUpdateActions(); - const localActions = useDeviceDetailsActions(); - const onPressCheckForUpdates = useCallback( async ( firmwareType?: EFirmwareType, @@ -171,7 +236,7 @@ function DeviceDetailsModalV2Cmp({ )} /> ) : null} - {showDeviceSettings ? ( + {showDeviceSettings && showInteractiveSections ? ( <> @@ -184,11 +249,15 @@ function DeviceDetailsModalV2Cmp({ )} ) : null} - {showPassphraseSettings ? : null} + {showPassphraseSettings && showInteractiveSections ? ( + + ) : null} {showDeviceConnection ? : null} {showTrezorDebug ? : null} - + diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts index 4ebb5d62cda7..2498aaac415c 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { @@ -5,7 +7,10 @@ import { canOpenDeviceManagementDetails, canShowTrezorBleBinding, getTrezorAutoLockOptionsMs, + shouldShowDeviceInteractiveSections, + syncRelevantDeviceStateEvent, } from './utils'; +import * as deviceDetailsUtils from './utils'; describe('DeviceDetailsModal utils', () => { it('allows Ledger rows to open device details', () => { @@ -46,6 +51,69 @@ describe('DeviceDetailsModal utils', () => { }); }); + it('shows Passphrase settings for OneKey devices, including Pro2', () => { + expect( + buildDeviceDetailsVisibility({ + vendor: EHardwareVendor.onekey, + isQrWallet: false, + hasLoadedDevice: true, + }), + ).toMatchObject({ + showDeviceSettings: true, + showPassphraseSettings: true, + }); + }); + + it('shows the Pro2 firmware type switch as coming soon', () => { + const getAvailability = ( + deviceDetailsUtils as typeof deviceDetailsUtils & { + getFirmwareTypeChangeAvailability?: (deviceType: EDeviceType) => string; + } + ).getFirmwareTypeChangeAvailability; + + expect(getAvailability?.(EDeviceType.Pro)).toBe('enabled'); + expect(getAvailability?.(EDeviceType.Pro2)).toBe('comingSoon'); + expect(getAvailability?.(EDeviceType.Touch)).toBe('hidden'); + }); + + it('shows Pro2 interactive settings consistently with Pro devices', () => { + expect(shouldShowDeviceInteractiveSections(EDeviceType.Pro2, false)).toBe( + true, + ); + expect(shouldShowDeviceInteractiveSections(EDeviceType.Pro2, true)).toBe( + true, + ); + expect( + shouldShowDeviceInteractiveSections(EDeviceType.Classic1s, false), + ).toBe(true); + }); + + it('does not refresh details for an unrelated device state event', async () => { + const refresh = jest.fn(); + + await expect( + syncRelevantDeviceStateEvent({ + event: { connectId: 'OTHER' }, + applyEvent: jest.fn().mockResolvedValue(false), + refresh, + }), + ).resolves.toBe(false); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('refreshes details after applying a relevant device state event', async () => { + const refresh = jest.fn().mockResolvedValue(undefined); + + await expect( + syncRelevantDeviceStateEvent({ + event: { connectId: 'CURRENT' }, + applyEvent: jest.fn().mockResolvedValue(true), + refresh, + }), + ).resolves.toBe(true); + expect(refresh).toHaveBeenCalledTimes(1); + }); + it('shows Trezor BLE binding on BLE capable models, including re-binding when already bound', () => { expect( canShowTrezorBleBinding( diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts index 8db2edbc88b2..f3d64abecc5e 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts @@ -1,5 +1,8 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import thirdPartyDeviceUtils from '@onekeyhq/shared/src/utils/thirdPartyDeviceUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; @@ -63,6 +66,46 @@ export function buildDeviceDetailsVisibility({ }; } +export function shouldShowDeviceInteractiveSections( + deviceType: EDeviceType | undefined, + deviceStateReady: boolean, +) { + return Boolean(deviceType) || deviceStateReady; +} + +export type IFirmwareTypeChangeAvailability = + | 'enabled' + | 'comingSoon' + | 'hidden'; + +export function getFirmwareTypeChangeAvailability( + deviceType: EDeviceType | undefined, +): IFirmwareTypeChangeAvailability { + if (deviceType === EDeviceType.Pro2) { + return 'comingSoon'; + } + if (deviceType && deviceUtils.checkAllowChangeFirmwareType(deviceType)) { + return 'enabled'; + } + return 'hidden'; +} + +export async function syncRelevantDeviceStateEvent({ + event, + applyEvent, + refresh, +}: { + event: T; + applyEvent: (event: T) => Promise; + refresh: () => Promise; +}) { + const applied = await applyEvent(event); + if (applied) { + await refresh(); + } + return applied; +} + export function canShowTrezorBleBinding( device: IDeviceConnectionInfo | undefined, platform: { diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceGuideModal/index.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceGuideModal/index.tsx index 82981df4caa9..f0fe9be4654f 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceGuideModal/index.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceGuideModal/index.tsx @@ -27,13 +27,15 @@ import type { IAllWalletAvatarImageNames } from '@onekeyhq/shared/src/utils/avat import { useBuyOneKeyHeaderRightButton } from '../../hooks/useBuyOneKeyHeaderRightButton'; +import type { IDeviceType } from '@onekeyfe/hd-core'; + function DeviceItem({ img, name, bg, ...rest }: IXStackProps & { - img: IAllWalletAvatarImageNames; + img: IAllWalletAvatarImageNames | IDeviceType; name: string; bg?: IImageProps['source']; }) { diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx index 43d957d795f8..a98484f45450 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx @@ -233,6 +233,11 @@ function DeviceListItem({ w: 56, h: 56, }} + testID={ + isConnected + ? DeviceManagementTestIDs.deviceStatusConnected + : DeviceManagementTestIDs.deviceStatusDisconnected + } > 0 ? toVersion : unknownMessage; + + return ( + + + {fromVersionText} + + + + {toVersionText} + + + ); +} + function ChangeLogMarkdown({ changelog, }: { @@ -73,9 +116,11 @@ function ChangeLogSection({ title, updateInfo, accordionValue, + versionOnly, }: { title: string; accordionValue: string; + versionOnly?: boolean; updateInfo: | IFirmwareUpdateInfo | IBleFirmwareUpdateInfo @@ -125,14 +170,22 @@ function ChangeLogSection({ > {title} - + {versionOnly ? ( + + ) : ( + + )} { - if (result?.updateInfos?.firmware?.hasUpgrade) return 'firmware'; + if (result?.updateInfos?.firmware?.hasUpgrade || isPro2SafeOSUpdate) { + return 'firmware'; + } if (result?.updateInfos?.bootloader?.hasUpgrade) return 'bootloader'; if (result?.updateInfos?.ble?.hasUpgrade) return 'ble'; return undefined; - }, [result?.updateInfos]); + }, [isPro2SafeOSUpdate, result?.updateInfos]); return ( @@ -190,21 +246,26 @@ export function FirmwareChangeLogContentView({ defaultValue={defaultExpandedSections} collapsible > - {result?.updateInfos?.firmware?.hasUpgrade ? ( + {result?.updateInfos?.firmware?.hasUpgrade || isPro2SafeOSUpdate ? ( ) : null} - {result?.updateInfos?.bootloader?.hasUpgrade ? ( + {result?.updateInfos?.bootloader?.hasUpgrade && !isPro2SafeOSUpdate ? ( ) : null} - {result?.updateInfos?.ble?.hasUpgrade ? ( + {result?.updateInfos?.ble?.hasUpgrade && !isPro2SafeOSUpdate ? ( { - let newProgress = - startAt + (currentProgress ?? 0) * ((maxAt - startAt) / 100); - if (newProgress >= maxAt) { - newProgress = maxAt; - } - return newProgress; -}; - function FirmwareUpdateVersionItem({ title, fromVersion, @@ -236,6 +231,8 @@ export function FirmwareUpdateProgressBarV2({ const intl = useIntl(); const [stepInfo, setStepInfo] = useFirmwareUpdateStepInfoAtom(); const [state] = useHardwareUiStateAtom(); + const [completedState] = useHardwareUiStateCompletedAtom(); + const [retryInfo] = useFirmwareUpdateRetryAtom(); const [progress, setProgress] = useState(1); const [isDoneInternal, setIsDoneInternal] = useState(!!isDone); @@ -250,8 +247,18 @@ export function FirmwareUpdateProgressBarV2({ ); const [desc, setDesc] = useState(defaultDesc()); - const firmwareProgress = state?.payload?.firmwareProgress; - const firmwareProgressType = state?.payload?.firmwareProgressType; + // The active state may be cleared when the confirmation dialog closes. + // Use the latest completed event so the firmware page can still consume it. + let progressState; + if (state?.action === EHardwareUiStateAction.FIRMWARE_PROGRESS) { + progressState = state; + } else if ( + completedState?.action === EHardwareUiStateAction.FIRMWARE_PROGRESS + ) { + progressState = completedState; + } + const firmwareProgress = progressState?.payload?.firmwareProgress; + const firmwareProgressType = progressState?.payload?.firmwareProgressType; const firmwareTipMessage = state?.payload?.firmwareTipData?.message; const firmwareProgressRef = useRef(firmwareProgress); @@ -298,14 +305,17 @@ export function FirmwareUpdateProgressBarV2({ { type: [EFirmwareUpdateTipMessages.SwitchFirmwareReconnectDevice], progress: () => progressRef.current, - progressMax: () => 99, + progressMax: () => PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, desc: () => intl.formatMessage({ - id: ETranslations.firmware_update_switch_firmware_reconnect_device, + id: isPro2SafeOSFirmwareUpdate(result) + ? ETranslations.update_keep_usb_connected_and_app_active + : ETranslations.firmware_update_switch_firmware_reconnect_device, }), }, { type: [EFirmwareUpdateTipMessages.StartTransferData], + progressMax: () => 50, progress: () => calculateProgressInRange({ startAt: 12, @@ -318,13 +328,23 @@ export function FirmwareUpdateProgressBarV2({ }), }, { - type: ['installing'], + type: [EFirmwareUpdateTipMessages.ConfirmOnDevice], + progress: () => progressRef.current, + progressMax: () => progressRef.current, + desc: () => + intl.formatMessage({ + id: ETranslations.global_confirm_on_device, + }), + }, + { + type: [EFirmwareUpdateTipMessages.FirmwareUpdating, 'installing'], progress: () => calculateProgressInRange({ startAt: 50, maxAt: 90, currentProgress: firmwareProgressRef.current, }), + progressMax: () => PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, desc: () => { return intl.formatMessage({ id: ETranslations.update_installing, @@ -377,7 +397,7 @@ export function FirmwareUpdateProgressBarV2({ } } }, - [intl], + [intl, result], ); const updateProgressRef = useRef(updateProgress); @@ -422,6 +442,36 @@ export function FirmwareUpdateProgressBarV2({ } }, [firmwareProgress, firmwareProgressType]); + const shouldEstimatePro2Progress = + result?.deviceType === EDeviceType.Pro2 && + stepInfo.step === EFirmwareUpdateSteps.installing && + firmwareProgressType === 'installingFirmware' && + lastFirmwareTipMessage !== + EFirmwareUpdateTipMessages.FirmwareUpdateCompleted && + !retryInfo && + !isDone; + + useEffect(() => { + if (!shouldEstimatePro2Progress) { + return undefined; + } + + const timer = setInterval(() => { + setProgress((currentProgress) => { + const nextProgress = getNextEstimatedFirmwareProgress({ + currentProgress, + maxProgress: progressMaxRef.current, + }); + progressRef.current = nextProgress; + return nextProgress; + }); + }, 2000); + + return () => { + clearInterval(timer); + }; + }, [shouldEstimatePro2Progress]); + useEffect(() => { console.log('FirmwareUpdateProgressBar: =>>>> result: ', result); }, [result]); @@ -442,22 +492,26 @@ export function FirmwareUpdateProgressBarV2({ if (!result?.updateInfos) return []; const versions: IFirmwareUpdateVersions[] = []; + const isPro2SafeOSUpdate = isPro2SafeOSFirmwareUpdate(result); + const firmwareInfo = result.updateInfos.firmware; - if (result.updateInfos.firmware?.hasUpgrade) { + if (firmwareInfo?.hasUpgrade || isPro2SafeOSUpdate) { versions.push({ - type: 'Firmware', + type: isPro2SafeOSUpdate ? 'SafeOS' : 'Firmware', info: { - title: intl.formatMessage({ id: ETranslations.global_firmware }), - fromVersion: result.updateInfos.firmware.fromVersion ?? '', - toVersion: result.updateInfos.firmware.toVersion ?? '', + title: isPro2SafeOSUpdate + ? 'SafeOS' + : intl.formatMessage({ id: ETranslations.global_firmware }), + fromVersion: firmwareInfo?.fromVersion ?? '', + toVersion: firmwareInfo?.toVersion ?? '', verifyVersion: resultVerifyVersions?.finalFirmwareVersion, hasUpgrade: true, - githubReleaseUrl: result.updateInfos.firmware.githubReleaseUrl, + githubReleaseUrl: firmwareInfo?.githubReleaseUrl, }, }); } - if (result.updateInfos.bootloader?.hasUpgrade) { + if (result.updateInfos.bootloader?.hasUpgrade && !isPro2SafeOSUpdate) { versions.push({ type: 'Bootloader', info: { @@ -471,7 +525,7 @@ export function FirmwareUpdateProgressBarV2({ }); } - if (result.updateInfos.ble?.hasUpgrade) { + if (result.updateInfos.ble?.hasUpgrade && !isPro2SafeOSUpdate) { versions.push({ type: 'Bluetooth', info: { diff --git a/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.test.ts b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.test.ts new file mode 100644 index 000000000000..34cd1b5a4832 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.test.ts @@ -0,0 +1,63 @@ +import { + PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, + PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, + calculateProgressInRange, + getNextEstimatedFirmwareProgress, +} from './firmwareUpdateProgressUtils'; + +describe('firmwareUpdateProgressUtils', () => { + test('将 SDK 阶段进度映射到 UI 区间并限制上界', () => { + expect( + calculateProgressInRange({ + startAt: 50, + maxAt: 90, + currentProgress: undefined, + }), + ).toBe(50); + expect( + calculateProgressInRange({ + startAt: 50, + maxAt: 90, + currentProgress: 50, + }), + ).toBe(70); + expect( + calculateProgressInRange({ + startAt: 50, + maxAt: 90, + currentProgress: 150, + }), + ).toBe(90); + }); + + test('Pro2 估算进度渐近阶段上限但不会提前触顶', () => { + let progress = 50; + for (let index = 0; index < 240; index += 1) { + progress = getNextEstimatedFirmwareProgress({ + currentProgress: progress, + maxProgress: PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, + }); + } + + expect(progress).toBeGreaterThan(88.9); + expect(progress).toBeLessThan(PRO2_INSTALL_ESTIMATED_PROGRESS_MAX); + }); + + test('重连估算进度不回退真实进度,也不越过验证阶段', () => { + expect( + getNextEstimatedFirmwareProgress({ + currentProgress: 90, + maxProgress: PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, + }), + ).toBe(90); + + const reconnectProgress = getNextEstimatedFirmwareProgress({ + currentProgress: 90, + maxProgress: PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, + }); + expect(reconnectProgress).toBeGreaterThan(90); + expect(reconnectProgress).toBeLessThan( + PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, + ); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.ts b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.ts new file mode 100644 index 000000000000..00de2e3360f4 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.ts @@ -0,0 +1,34 @@ +const ESTIMATED_PROGRESS_STEP_RATIO = 0.03; + +export const PRO2_INSTALL_ESTIMATED_PROGRESS_MAX = 89; +export const PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX = 98; + +export function calculateProgressInRange({ + startAt, + maxAt, + currentProgress, +}: { + startAt: number; + maxAt: number; + currentProgress: number | null | undefined; +}) { + const progress = startAt + (currentProgress ?? 0) * ((maxAt - startAt) / 100); + return Math.min(progress, maxAt); +} + +export function getNextEstimatedFirmwareProgress({ + currentProgress, + maxProgress, +}: { + currentProgress: number; + maxProgress: number; +}) { + if (currentProgress >= maxProgress) { + return currentProgress; + } + + return ( + currentProgress + + (maxProgress - currentProgress) * ESTIMATED_PROGRESS_STEP_RATIO + ); +} diff --git a/packages/kit/src/views/FirmwareUpdate/utils.test.ts b/packages/kit/src/views/FirmwareUpdate/utils.test.ts new file mode 100644 index 000000000000..a978f95e3e37 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/utils.test.ts @@ -0,0 +1,62 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; + +import { isPro2SafeOSFirmwareUpdate } from './utils'; + +function buildResult({ + deviceType = EDeviceType.Pro2, + firmwareHasUpgrade = false, + targets = [], +}: { + deviceType?: EDeviceType; + firmwareHasUpgrade?: boolean; + targets?: ICheckAllFirmwareReleaseResult['pro2TargetsToUpdate']; +}) { + return { + deviceType, + pro2TargetsToUpdate: targets, + updateInfos: { + firmware: { + hasUpgrade: firmwareHasUpgrade, + }, + }, + } as ICheckAllFirmwareReleaseResult; +} + +describe('isPro2SafeOSFirmwareUpdate', () => { + it('uses the legacy firmware update flag', () => { + expect( + isPro2SafeOSFirmwareUpdate(buildResult({ firmwareHasUpgrade: true })), + ).toBe(true); + }); + + it.each(['app_v1', 'app_v2'] as const)( + 'treats the Pro2 %s target as a SafeOS update', + (target) => { + expect( + isPro2SafeOSFirmwareUpdate(buildResult({ targets: [target] })), + ).toBe(true); + }, + ); + + it('does not label a Pro2 resource-only update as SafeOS', () => { + expect( + isPro2SafeOSFirmwareUpdate( + buildResult({ targets: ['resource', 'se01'] }), + ), + ).toBe(false); + }); + + it('does not label another device as SafeOS', () => { + expect( + isPro2SafeOSFirmwareUpdate( + buildResult({ + deviceType: EDeviceType.Touch, + firmwareHasUpgrade: true, + targets: ['app_v1'], + }), + ), + ).toBe(false); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/utils.ts b/packages/kit/src/views/FirmwareUpdate/utils.ts index 1a78f08d682f..cbb0ee36a9d4 100644 --- a/packages/kit/src/views/FirmwareUpdate/utils.ts +++ b/packages/kit/src/views/FirmwareUpdate/utils.ts @@ -1,9 +1,22 @@ -import { EFirmwareType } from '@onekeyfe/hd-shared'; +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; import type { IntlShape } from 'react-intl'; +export function isPro2SafeOSFirmwareUpdate( + result: ICheckAllFirmwareReleaseResult | undefined, +) { + return ( + result?.deviceType === EDeviceType.Pro2 && + (result.updateInfos?.firmware?.hasUpgrade === true || + result.pro2TargetsToUpdate?.some( + (target) => target === 'app_v1' || target === 'app_v2', + ) === true) + ); +} + export function getTargetFirmwareTypeLabel({ firmwareType, intl, diff --git a/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx b/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx index bcce5a9ddada..749023529d79 100644 --- a/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx +++ b/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx @@ -29,6 +29,7 @@ export function HomeSupportedWallet({ [EDeviceType.Mini]: 'Mini', [EDeviceType.Touch]: 'Touch', [EDeviceType.Pro]: 'Pro', + [EDeviceType.Pro2]: 'Pro 2', [EDeviceType.Unknown]: '', 'watching': intl.formatMessage({ id: ETranslations.faq_watched_account, diff --git a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts new file mode 100644 index 000000000000..441b2e938a40 --- /dev/null +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +describe('TokenListBlock portfolio sync producer', () => { + it('checks the Pro2 device type before building the cross-runtime payload', () => { + const source = readFileSync(join(__dirname, 'TokenListBlock.tsx'), 'utf8'); + const gateIndex = source.indexOf( + 'device?.deviceType === EDeviceType.Pro2 &&', + ); + const buildIndex = source.indexOf( + 'const flattenedAggregateTokenMap = flattenAggregateTokensMap', + ); + const sendToBackgroundIndex = source.indexOf( + 'backgroundApiProxy.serviceHardwarePortfolioSync.notifyAllNetworksTokenListSettled', + ); + + expect(source).not.toContain('useDevSettingsPersistAtom'); + expect(source).toContain( + 'deviceDbId: device?.id ?? wallet.associatedDeviceInfo?.id', + ); + expect(source).not.toContain('isPro2DebugModuleEnabled'); + expect(source).toContain( + 'accountUtils.isHwWallet({ walletId: wallet.id })', + ); + expect(gateIndex).toBeGreaterThan(0); + expect(gateIndex).toBeLessThan(buildIndex); + expect(buildIndex).toBeLessThan(sendToBackgroundIndex); + expect(source).not.toContain( + 'appEventBus.emit(EAppEventBusNames.AllNetworksTokenListSettled', + ); + }); +}); diff --git a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx index 36b8ed40e713..14bd45364bed 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react'; +import { EDeviceType } from '@onekeyfe/hd-shared'; import { CanceledError } from 'axios'; import BigNumber from 'bignumber.js'; import { isEmpty, isNil, uniqBy } from 'lodash'; @@ -111,6 +112,7 @@ import { import { buildAggregateTokenListData, calculateAccountTokensValue, + flattenAggregateTokensMap, getEmptyTokenData, getMergedDeriveTokenData, getMergedTokenData, @@ -243,6 +245,7 @@ function TokenListBlock({ accountName, network, wallet, + device, indexedAccount, isOthersWallet, deriveInfo, @@ -1838,6 +1841,56 @@ function TokenListBlock({ worth: snapshot.accountsWorth, createAtNetworkWorth: snapshot.createAtNetworkWorth, }); + + if ( + device?.deviceType === EDeviceType.Pro2 && + wallet && + accountUtils.isHwWallet({ walletId: wallet.id }) + ) { + const flattenedAggregateTokenMap = flattenAggregateTokensMap( + snapshot.aggregateTokenMap, + ); + const portfolioTokenMap = { + ...snapshot.mergeTokenListMap, + ...flattenedAggregateTokenMap, + }; + const portfolioTokens = [ + ...snapshot.orderedTokens, + ...snapshot.smallBalanceTokens, + ].filter((token) => + new BigNumber( + portfolioTokenMap[token.$key]?.balanceParsed ?? 0, + ).isGreaterThan(0), + ); + + void backgroundApiProxy.serviceHardwarePortfolioSync.notifyAllNetworksTokenListSettled( + { + accountAddress: account?.address, + accountId: account?.id, + accountName, + aggregateTokenMap: flattenedAggregateTokenMap, + deviceConnectId: + device?.connectId ?? wallet.associatedDeviceInfo?.connectId, + deviceDbId: device?.id ?? wallet.associatedDeviceInfo?.id, + indexedAccountId: indexedAccount?.id, + indexedAccountIndex: indexedAccount?.index, + indexedAccountName: indexedAccount?.name, + networkId: network?.id, + ownerAccountId: allNetworksResult[0].ownerAccountId, + ownerNetworkId: allNetworksResult[0].ownerNetworkId, + totalFiat: snapshot.createAtNetworkWorth, + totalTokenCount: portfolioTokens.length, + tokenMap: { + ...snapshot.mergeTokenListMap, + ...snapshot.riskyTokenListMap, + ...flattenedAggregateTokenMap, + }, + tokens: portfolioTokens, + walletId: wallet.id, + walletType: wallet.type, + }, + ); + } } // Authoritative ingest (facade, design §2): ingest the FULL merged @@ -1865,9 +1918,16 @@ function TokenListBlock({ isRefreshing: false, }); }, [ + account?.address, account?.id, account?.indexedAccountId, + accountName, + device?.connectId, + device?.deviceType, + device?.id, indexedAccount?.id, + indexedAccount?.index, + indexedAccount?.name, mergeDeriveAddressData, allNetworkAccounts, allNetworksResult, @@ -1876,6 +1936,7 @@ function TokenListBlock({ commitAuthoritativeIngest, updateAccountWorth, updateTokenListState, + wallet, ]); // The legacy per-owner `renderedTokenListCache` pre-paint hydrator was REMOVED @@ -2763,7 +2824,7 @@ function TokenListBlock({ showLpTokensOnly ? false : !!network?.isAllNetworks } deferTokenManagement={!!network?.isAllNetworks} - manageTokenEnabled={manageTokenEnabled && !showLpTokensOnly} + manageTokenEnabled={Boolean(manageTokenEnabled && !showLpTokensOnly)} onManageToken={handleOnManageToken} onPressToken={handleOnPressToken} isAllNetworks={network?.isAllNetworks} diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx index 7ba174ec30ef..64c7dc3b1cdf 100644 --- a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; import { type RouteProp, useRoute } from '@react-navigation/core'; -import { get, isString } from 'lodash'; +import { get } from 'lodash'; import natsort from 'natsort'; import { useIntl } from 'react-intl'; import { StyleSheet } from 'react-native'; @@ -73,6 +73,7 @@ import { convertDeviceError } from '@onekeyhq/shared/src/errors/utils/deviceErro import errorToastUtils from '@onekeyhq/shared/src/errors/utils/errorToastUtils'; import bleManagerInstance from '@onekeyhq/shared/src/hardware/bleManager'; import { checkBLEPermissions } from '@onekeyhq/shared/src/hardware/blePermissions'; +import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; @@ -92,6 +93,7 @@ import { EConnectDeviceChannel } from '@onekeyhq/shared/types/connectDevice'; import { EOneKeyDeviceMode, type IOneKeyDeviceFeatures, + type IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import { useBuyOneKeyHeaderRightButton } from '../../../DeviceManagement/hooks/useBuyOneKeyHeaderRightButton'; @@ -99,6 +101,12 @@ import { useFirmwareUpdateActions } from '../../../FirmwareUpdate/hooks/useFirmw import { useFirmwareVerifyDialog } from './FirmwareVerifyDialog'; import { useSelectAddWalletTypeDialog } from './SelectAddWalletTypeDialog'; +import { + EHardwareWalletCreationMode, + getWalletCreationDeviceState, + resolveAutomaticWalletCreationMode, + shouldCheckExistingStandardWallet, +} from './walletCreationMode'; import type { Features, IDeviceType, SearchDevice } from '@onekeyfe/hd-core'; import type { ImageSourcePropType } from 'react-native'; @@ -146,10 +154,9 @@ async function getForceTransportType( if (platformEnv.isNative) return EHardwareTransportType.BLE; if (platformEnv.isDesktop) { const dev = await backgroundApiProxy.serviceDevSetting.getDevSetting(); - const usbCommunicationMode = dev?.settings?.usbCommunicationMode; - if (usbCommunicationMode === 'bridge') - return EHardwareTransportType.Bridge; - return EHardwareTransportType.WEBUSB; + return deviceUtils.getDesktopUsbTransportType({ + usbCommunicationMode: dev?.settings?.usbCommunicationMode, + }); } // For web/extension, use system setting transport type const currentTransportType = @@ -530,18 +537,11 @@ function useDeviceConnection({ // Only set search results if tabValue hasn't changed if (currentTabValueRef.current === tabValue) { - if (tabValue === EConnectDeviceChannel.bluetooth) { - const isUsbData = sortedDevices.some((device) => - // @ts-expect-error - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - isString(device.features?.device_id), - ); - if (isUsbData) { - setSearchedDevices([]); - return; - } - } - setSearchedDevices(sortedDevices); + setSearchedDevices( + tabValue === EConnectDeviceChannel.bluetooth + ? sortedDevices.filter(deviceUtils.isBluetoothSearchDevice) + : sortedDevices, + ); } else { console.log('🔍 Ignoring search results - tab changed during search'); } @@ -1326,6 +1326,7 @@ export function ConnectYourDevicePage() { try { return await backgroundApiProxy.serviceHardware.connect({ device, + forceProtocolDetection: true, }); } catch (error: any) { if (error instanceof OneKeyHardwareError) { @@ -1346,16 +1347,6 @@ export function ConnectYourDevicePage() { } }, []); - const extractDeviceState = useCallback( - (features: IOneKeyDeviceFeatures) => ({ - unlockedAttachPin: features.unlocked_attach_pin, - unlocked: features.unlocked, - passphraseEnabled: Boolean(features.passphrase_protection), - deviceId: features.device_id, - }), - [], - ); - const closeDialogAndReturn = useCallback( async (device: SearchDevice, options: { skipDelayClose?: boolean }) => { setIsChecking(false); @@ -1368,62 +1359,35 @@ export function ConnectYourDevicePage() { [], ); - type IWalletCreationStrategy = { - createHiddenWalletOnly: boolean; - createStandardWalletOnly: boolean; - }; - const determineWalletCreationStrategy = useCallback( async ( - deviceState: ReturnType, + deviceState: IOneKeyDeviceState, device: SearchDevice, - ): Promise => { - if (!deviceState.unlocked) { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; - } - - if (deviceState.unlockedAttachPin) { - return { - createHiddenWalletOnly: deviceState.passphraseEnabled, - createStandardWalletOnly: !deviceState.passphraseEnabled, - }; - } - - const existsStandardWallet = - await backgroundApiProxy.serviceAccount.existsHwStandardWallet({ - connectId: device.connectId ?? '', - deviceId: deviceState.deviceId ?? '', - }); - - if (existsStandardWallet) { - return { - createHiddenWalletOnly: deviceState.passphraseEnabled, - createStandardWalletOnly: !deviceState.passphraseEnabled, - }; - } - - if (!deviceState.passphraseEnabled) { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; + ): Promise => { + const existsStandardWallet = shouldCheckExistingStandardWallet( + deviceState, + ) + ? await backgroundApiProxy.serviceAccount.existsHwStandardWallet({ + connectId: device.connectId ?? '', + deviceId: + deviceState.identity.deviceId ?? + deviceUtils.getRawDeviceId({ device }), + }) + : false; + const automaticMode = resolveAutomaticWalletCreationMode({ + state: deviceState, + existsStandardWallet, + }); + if (automaticMode) { + return automaticMode; } const walletType = await showSelectAddWalletTypeDialog(); if (walletType === 'Standard') { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; + return EHardwareWalletCreationMode.Standard; } if (walletType === 'Hidden') { - return { - createHiddenWalletOnly: true, - createStandardWalletOnly: false, - }; + return EHardwareWalletCreationMode.Hidden; } return null; @@ -1434,10 +1398,10 @@ export function ConnectYourDevicePage() { const createHwWallet = useCallback( async ( device: SearchDevice, - strategy: IWalletCreationStrategy, + walletMode: EHardwareWalletCreationMode, features: IOneKeyDeviceFeatures, isFirmwareVerified?: boolean, - deviceState?: ReturnType, + deviceState?: IOneKeyDeviceState, ) => { try { navigation.push(EOnboardingPages.FinalizeWalletSetup); @@ -1446,11 +1410,12 @@ export function ConnectYourDevicePage() { device, hideCheckingDeviceLoading: true, features, + deviceState, isFirmwareVerified, defaultIsTemp: true, - isAttachPinMode: deviceState?.unlockedAttachPin, + isAttachPinMode: deviceState?.status.unlockedAttachPin ?? undefined, }; - if (strategy.createStandardWalletOnly) { + if (walletMode === EHardwareWalletCreationMode.Standard) { await actions.current.createHWWalletWithoutHidden(params); } else { await actions.current.createHWWalletWithHidden(params); @@ -1466,7 +1431,7 @@ export function ConnectYourDevicePage() { await actions.current.updateHwWalletsDeprecatedStatus({ connectId: device.connectId ?? '', - deviceId: features.device_id || device.deviceId || '', + deviceId: deviceUtils.getRawDeviceId({ device, features }), }); } catch (error) { errorToastUtils.toastIfError(error); @@ -1495,6 +1460,7 @@ export function ConnectYourDevicePage() { const selectAddWalletType = useCallback( async ({ device, + features: connectedFeatures, isFirmwareVerified, }: { device: SearchDevice; @@ -1508,24 +1474,30 @@ export function ConnectYourDevicePage() { }); let features: IOneKeyDeviceFeatures | undefined; + let deviceState: IOneKeyDeviceState; try { - features = - await backgroundApiProxy.serviceHardware.getFeaturesWithUnlock({ - connectId: device.connectId ?? '', - }); + const connectProtocol = + connectedFeatures.protocol === 'V1' || + connectedFeatures.protocol === 'V2' + ? connectedFeatures.protocol + : undefined; + deviceState = await getWalletCreationDeviceState({ + serviceHardware: backgroundApiProxy.serviceHardware, + connectId: device.connectId ?? '', + connectProtocol, + }); + features = projectLegacyDeviceFeaturesFromState(deviceState); } catch (_error) { await closeDialogAndReturn(device, { skipDelayClose: true }); return; } - const deviceState = extractDeviceState(features); const strategy = await determineWalletCreationStrategy( deviceState, device, ); - console.log('Current hardware wallet State', deviceState, strategy); if (!strategy) { await closeDialogAndReturn(device, { skipDelayClose: true }); return; @@ -1539,12 +1511,7 @@ export function ConnectYourDevicePage() { deviceState, ); }, - [ - extractDeviceState, - determineWalletCreationStrategy, - createHwWallet, - closeDialogAndReturn, - ], + [determineWalletCreationStrategy, createHwWallet, closeDialogAndReturn], ); // Shared device connection handler @@ -1600,13 +1567,8 @@ export function ConnectYourDevicePage() { return; } - // Set global transport type based on selected channel before connecting - let forceTransportType: EHardwareTransportType | undefined; - if (tabValue === EConnectDeviceChannel.bluetooth) { - forceTransportType = EHardwareTransportType.DesktopWebBle; - } else { - forceTransportType = await getForceTransportType(tabValue); - } + // Select transport for the current platform; native Bluetooth requires BLE. + const forceTransportType = await getForceTransportType(tabValue); if (forceTransportType) { await backgroundApiProxy.serviceHardware.setForceTransportType({ forceTransportType, @@ -1665,7 +1627,7 @@ export function ConnectYourDevicePage() { await backgroundApiProxy.serviceHardware.shouldAuthenticateFirmware({ device: { ...device, - deviceId: device.deviceId || features.device_id, + deviceId: deviceUtils.getRawDeviceId({ device, features }), }, }); diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog.tsx b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog.tsx index 3dc36162b0fa..99277b75ea05 100644 --- a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog.tsx +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog.tsx @@ -36,6 +36,7 @@ import { import { ETranslations } from '@onekeyhq/shared/src/locale'; import { showIntercom } from '@onekeyhq/shared/src/modules3rdParty/intercom'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import type { IDeviceVerifyVersionCompareResult, IOneKeyDeviceFeatures, @@ -151,10 +152,12 @@ function useFirmwareVerifyBase({ if (useNewProcess) { // verify firmware hash const latestFeatures = - await backgroundApiProxy.serviceHardware.getOneKeyFeatures({ - connectId: device?.connectId ?? '', - deviceType: device.deviceType, - }); + await backgroundApiProxy.serviceHardware.getFirmwareVerificationFeatures( + { + connectId: device?.connectId ?? '', + deviceType: device.deviceType, + }, + ); const verifyResult = await backgroundApiProxy.serviceHardware.verifyFirmwareHash({ deviceType: device.deviceType, @@ -1051,6 +1054,11 @@ export function useFirmwareVerifyDialog() { onVerified?: (params: { checked: boolean }) => Promise | void; onDevSkipVerificationPress?: () => void; }) => { + if (!deviceUtils.isFirmwareVerifySupported(device.deviceType)) { + await onContinue({ checked: false }); + return; + } + const onCloseFn = async () => { await onClose?.(); setIsLoading(false); diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.test.ts b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.test.ts new file mode 100644 index 000000000000..31e92d0cd0ad --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.test.ts @@ -0,0 +1,39 @@ +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import { resolveHardwarePassphraseEnabled } from './passphraseStateUtils'; + +describe('resolveHardwarePassphraseEnabled', () => { + it('兼容老设备的 passphrase_protection 字段', () => { + expect( + resolveHardwarePassphraseEnabled({ + features: { + passphrase_protection: true, + unlocked: true, + } as IOneKeyDeviceFeatures, + }), + ).toBe(true); + }); + + it('旧字段缺失时兼容 DeviceState 投影的 passphraseProtection 字段', () => { + expect( + resolveHardwarePassphraseEnabled({ + features: { + passphraseProtection: true, + unlocked: true, + } as IOneKeyDeviceFeatures, + }), + ).toBe(true); + }); + + it('设备状态关闭 Passphrase 时使用标准钱包', () => { + expect( + resolveHardwarePassphraseEnabled({ + features: { + passphrase_protection: false, + passphraseProtection: false, + unlocked: true, + } as IOneKeyDeviceFeatures, + }), + ).toBe(false); + }); +}); diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.ts b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.ts new file mode 100644 index 000000000000..2fab74e87ebc --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/passphraseStateUtils.ts @@ -0,0 +1 @@ +export { resolveHardwarePassphraseEnabled } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.test.ts b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.test.ts new file mode 100644 index 000000000000..1c78065968b8 --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.test.ts @@ -0,0 +1,134 @@ +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; + +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +import { + EHardwareWalletCreationMode, + getWalletCreationDeviceState, + resolveAutomaticWalletCreationMode, + shouldCheckExistingStandardWallet, +} from './walletCreationMode'; + +function buildState( + status: Partial, +): IOneKeyDeviceState { + return { + status: { + unlocked: true, + unlockedAttachPin: false, + passphraseProtection: true, + ...status, + }, + } as IOneKeyDeviceState; +} + +describe('walletCreationMode', () => { + it('Protocol V2 创建钱包前使用 Any 解锁并读取解锁后的状态', async () => { + const state = buildState({ unlocked: false }); + const getDeviceState = jest.fn().mockResolvedValue(state); + const getDeviceStateWithUnlock = jest.fn().mockResolvedValue(state); + + await expect( + getWalletCreationDeviceState({ + serviceHardware: { getDeviceState, getDeviceStateWithUnlock }, + connectId: 'pro2-connect', + connectProtocol: 'V2', + }), + ).resolves.toBe(state); + + expect(getDeviceStateWithUnlock).toHaveBeenCalledWith({ + connectId: 'pro2-connect', + pinType: DeviceSessionPinType.Any, + params: { connectProtocol: 'V2', scope: 'runtime' }, + }); + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('Protocol V1 保留创建钱包前的原有解锁流程', async () => { + const state = buildState({ unlocked: true }); + const getDeviceState = jest.fn(); + const getDeviceStateWithUnlock = jest.fn().mockResolvedValue(state); + + await expect( + getWalletCreationDeviceState({ + serviceHardware: { getDeviceState, getDeviceStateWithUnlock }, + connectId: 'classic-connect', + connectProtocol: 'V1', + }), + ).resolves.toBe(state); + + expect(getDeviceStateWithUnlock).toHaveBeenCalledWith({ + connectId: 'classic-connect', + params: { connectProtocol: 'V1', scope: 'runtime' }, + }); + expect(getDeviceState).not.toHaveBeenCalled(); + }); + + it('锁定状态不提前选择钱包模式', () => { + const state = buildState({ unlocked: false }); + + expect(shouldCheckExistingStandardWallet(state)).toBe(false); + expect( + resolveAutomaticWalletCreationMode({ + state, + existsStandardWallet: false, + }), + ).toBeUndefined(); + }); + + it('attach PIN 隐藏钱包直接进入隐藏钱包流程', () => { + const state = buildState({ unlockedAttachPin: true }); + + expect(shouldCheckExistingStandardWallet(state)).toBe(false); + expect( + resolveAutomaticWalletCreationMode({ + state, + existsStandardWallet: false, + }), + ).toBe(EHardwareWalletCreationMode.Hidden); + }); + + it('attach PIN 解锁结果优先于缓存中的 passphrase 开关状态', () => { + const state = buildState({ + unlockedAttachPin: true, + passphraseProtection: false, + }); + + expect( + resolveAutomaticWalletCreationMode({ + state, + existsStandardWallet: false, + }), + ).toBe(EHardwareWalletCreationMode.Hidden); + }); + + it('已有标准钱包且启用 passphrase 时直接创建隐藏钱包', () => { + const state = buildState({}); + + expect(shouldCheckExistingStandardWallet(state)).toBe(true); + expect( + resolveAutomaticWalletCreationMode({ + state, + existsStandardWallet: true, + }), + ).toBe(EHardwareWalletCreationMode.Hidden); + }); + + it('首次连接且启用 passphrase 时交给用户明确选择', () => { + expect( + resolveAutomaticWalletCreationMode({ + state: buildState({}), + existsStandardWallet: false, + }), + ).toBeUndefined(); + }); + + it('未启用 passphrase 时只创建标准钱包', () => { + expect( + resolveAutomaticWalletCreationMode({ + state: buildState({ passphraseProtection: false }), + existsStandardWallet: false, + }), + ).toBe(EHardwareWalletCreationMode.Standard); + }); +}); diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.ts b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.ts new file mode 100644 index 000000000000..9f85afb05ca1 --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.ts @@ -0,0 +1,86 @@ +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; + +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared'; + +export enum EHardwareWalletCreationMode { + Standard = 'standard', + Hidden = 'hidden', +} + +type IWalletCreationHardwareService = { + getDeviceState: (params: { + connectId: string; + params: { + connectProtocol?: HardwareConnectProtocol; + scope: 'runtime'; + }; + }) => Promise; + getDeviceStateWithUnlock: (params: { + connectId: string; + pinType?: DeviceSessionPinType; + params: { + connectProtocol?: HardwareConnectProtocol; + scope: 'runtime'; + }; + }) => Promise; +}; + +export async function getWalletCreationDeviceState({ + serviceHardware, + connectId, + connectProtocol, +}: { + serviceHardware: IWalletCreationHardwareService; + connectId: string; + connectProtocol?: HardwareConnectProtocol; +}): Promise { + const params = { + connectProtocol, + scope: 'runtime' as const, + }; + return serviceHardware.getDeviceStateWithUnlock({ + connectId, + ...(connectProtocol === 'V2' ? { pinType: DeviceSessionPinType.Any } : {}), + params, + }); +} + +export function shouldCheckExistingStandardWallet( + state: IOneKeyDeviceState, +): boolean { + return ( + state.status.unlocked === true && state.status.unlockedAttachPin !== true + ); +} + +export function resolveAutomaticWalletCreationMode({ + state, + existsStandardWallet, +}: { + state: IOneKeyDeviceState; + existsStandardWallet: boolean; +}): EHardwareWalletCreationMode | undefined { + const { passphraseProtection, unlocked, unlockedAttachPin } = state.status; + + if (unlocked !== true) { + return undefined; + } + + if (unlockedAttachPin === true) { + return EHardwareWalletCreationMode.Hidden; + } + + if (existsStandardWallet) { + return passphraseProtection === true + ? EHardwareWalletCreationMode.Hidden + : EHardwareWalletCreationMode.Standard; + } + + if (passphraseProtection !== true) { + return EHardwareWalletCreationMode.Standard; + } + + return undefined; +} diff --git a/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx b/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx index a575277f344d..c0dfa22981c5 100644 --- a/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx +++ b/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { HardwareErrorCode } from '@onekeyfe/hd-shared'; +import { + type HardwareConnectProtocol, + HardwareErrorCode, +} from '@onekeyfe/hd-shared'; import { useIsFocused } from '@react-navigation/core'; import { get, noop, throttle } from 'lodash'; import { useIntl } from 'react-intl'; @@ -21,17 +24,19 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { showIntercom } from '@onekeyhq/shared/src/modules3rdParty/intercom'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EOnboardingPages } from '@onekeyhq/shared/src/routes/onboarding'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; -import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EConnectDeviceChannel } from '@onekeyhq/shared/types/connectDevice'; import type { IFirmwareVerifyResult, IOneKeyDeviceFeatures, + IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import { EHardwareCallContext, @@ -47,6 +52,12 @@ import { useAccountSelectorActions } from '../../../states/jotai/contexts/accoun import { useFirmwareUpdateActions } from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; import { useFirmwareVerifyDialog } from '../../Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog'; import { useSelectAddWalletTypeDialog } from '../../Onboarding/pages/ConnectHardwareWallet/SelectAddWalletTypeDialog'; +import { + EHardwareWalletCreationMode, + getWalletCreationDeviceState, + resolveAutomaticWalletCreationMode, + shouldCheckExistingStandardWallet, +} from '../../Onboarding/pages/ConnectHardwareWallet/walletCreationMode'; import { getForceTransportType, getHardwareCommunicationTypeString, @@ -107,9 +118,9 @@ async function createLedgerHwWallet({ device, hideCheckingDeviceLoading: true, features: { - device_id: device.deviceId || '', + deviceId: device.deviceId || '', vendor, - } as IOneKeyDeviceFeatures, + } as unknown as IOneKeyDeviceFeatures, isFirmwareVerified: true, defaultIsTemp: true, vendor, @@ -200,15 +211,30 @@ export function useDeviceConnect({ async ( device: SearchDevice, hardwareCallContext?: EHardwareCallContext, + connectProtocol?: HardwareConnectProtocol, + forceProtocolDetection?: boolean, ) => { await ensureStopScan(); try { const features = await backgroundApiProxy.serviceHardware.connect({ + connectProtocol, device, + forceProtocolDetection, hardwareCallContext, }); - activeDeviceRef.current = { ...device }; + const confirmedConnectProtocol = + features?.protocol === 'V1' || features?.protocol === 'V2' + ? features.protocol + : undefined; + const connectedDevice: SearchDevice = { + ...device, + ...(confirmedConnectProtocol + ? { connectProtocol: confirmedConnectProtocol } + : {}), + }; + activeDeviceRef.current = connectedDevice; activeFeaturesRef.current = features ?? null; + setCurrentDevice?.(connectedDevice); return features; } catch (error: any) { if (error instanceof OneKeyHardwareError) { @@ -234,11 +260,18 @@ export function useDeviceConnect({ throw error; } }, - [ensureStopScan], + [ensureStopScan, setCurrentDevice], ); const ensureActiveConnection = useCallback( - async (device: SearchDevice, options?: { forceReconnect?: boolean }) => { + async ( + device: SearchDevice, + options?: { + connectProtocol?: HardwareConnectProtocol; + forceProtocolDetection?: boolean; + forceReconnect?: boolean; + }, + ) => { // If device was in bootloader mode, force reconnect to get fresh features const shouldForceReconnect = options?.forceReconnect || wasInBootloaderModeRef.current; @@ -264,9 +297,14 @@ export function useDeviceConnect({ isBootMode = true; } - const features = await connectDevice(device, hardwareCallContext); + const features = await connectDevice( + device, + hardwareCallContext, + options?.connectProtocol, + options?.forceProtocolDetection, + ); // If device was in bootloader mode and connectId is empty, search for the updated device - if (device.connectId === '' && isBootMode && !features?.bootloader_mode) { + if (device.connectId === '' && isBootMode && !features?.bootloaderMode) { const searchedDevices = await backgroundApiProxy.serviceHardware.searchDevices(); if (searchedDevices.success && searchedDevices.payload.length === 1) { @@ -458,6 +496,7 @@ export function useDeviceConnect({ let connectionFailureTracked = false; let forceTransportType: EHardwareTransportType | undefined; + const confirmedConnectProtocol = device.connectProtocol; try { void backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ connectId: device.connectId ?? '', @@ -525,19 +564,22 @@ export function useDeviceConnect({ } } - // Set global transport type based on selected channel before connecting - if (tabValue === EConnectDeviceChannel.bluetooth) { - forceTransportType = EHardwareTransportType.DesktopWebBle; - } else { - forceTransportType = await getForceTransportType(tabValue); - } + // Select transport for the current platform; native Bluetooth requires BLE. + forceTransportType = await getForceTransportType(tabValue, { + connectProtocol: confirmedConnectProtocol, + }); if (forceTransportType) { await backgroundApiProxy.serviceHardware.setForceTransportType({ forceTransportType, }); } - const features = await ensureActiveConnection(device); + const features = await ensureActiveConnection( + device, + confirmedConnectProtocol + ? { connectProtocol: confirmedConnectProtocol } + : { forceProtocolDetection: true }, + ); // Get the latest device reference after connection (it may have been updated) const latestDevice = getActiveDevice() ?? device; @@ -593,7 +635,10 @@ export function useDeviceConnect({ await backgroundApiProxy.serviceHardware.shouldAuthenticateFirmware({ device: { ...latestDevice, - deviceId: latestDevice.deviceId || features.device_id, + deviceId: deviceUtils.getRawDeviceId({ + device: latestDevice, + features, + }), }, }); @@ -677,7 +722,8 @@ export function useDeviceConnect({ // } return { - verified: true, + verified: false, + skipVerification: true, device: latestDevice, payload: { deviceType: latestDevice.deviceType, @@ -720,16 +766,6 @@ export function useDeviceConnect({ ], ); - const extractDeviceState = useCallback( - (features: IOneKeyDeviceFeatures) => ({ - unlockedAttachPin: features.unlocked_attach_pin, - unlocked: features.unlocked, - passphraseEnabled: Boolean(features.passphrase_protection), - deviceId: features.device_id, - }), - [], - ); - const closeDialogAndReturn = useCallback( async (device: SearchDevice, options: { skipDelayClose?: boolean }) => { void backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ @@ -741,62 +777,35 @@ export function useDeviceConnect({ [], ); - type IWalletCreationStrategy = { - createHiddenWalletOnly: boolean; - createStandardWalletOnly: boolean; - }; - const determineWalletCreationStrategy = useCallback( async ( - deviceState: ReturnType, + deviceState: IOneKeyDeviceState, device: SearchDevice, - ): Promise => { - if (!deviceState.unlocked) { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; - } - - if (deviceState.unlockedAttachPin) { - return { - createHiddenWalletOnly: deviceState.passphraseEnabled, - createStandardWalletOnly: !deviceState.passphraseEnabled, - }; - } - - const existsStandardWallet = - await backgroundApiProxy.serviceAccount.existsHwStandardWallet({ - connectId: device.connectId ?? '', - deviceId: deviceState.deviceId ?? '', - }); - - if (existsStandardWallet) { - return { - createHiddenWalletOnly: deviceState.passphraseEnabled, - createStandardWalletOnly: !deviceState.passphraseEnabled, - }; - } - - if (!deviceState.passphraseEnabled) { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; + ): Promise => { + const existsStandardWallet = shouldCheckExistingStandardWallet( + deviceState, + ) + ? await backgroundApiProxy.serviceAccount.existsHwStandardWallet({ + connectId: device.connectId ?? '', + deviceId: + deviceState.identity.deviceId ?? + deviceUtils.getRawDeviceId({ device }), + }) + : false; + const automaticMode = resolveAutomaticWalletCreationMode({ + state: deviceState, + existsStandardWallet, + }); + if (automaticMode) { + return automaticMode; } const walletType = await showSelectAddWalletTypeDialog(); if (walletType === 'Standard') { - return { - createHiddenWalletOnly: false, - createStandardWalletOnly: true, - }; + return EHardwareWalletCreationMode.Standard; } if (walletType === 'Hidden') { - return { - createHiddenWalletOnly: true, - createStandardWalletOnly: false, - }; + return EHardwareWalletCreationMode.Hidden; } return null; @@ -807,10 +816,11 @@ export function useDeviceConnect({ const createHwWallet = useCallback( async ( device: SearchDevice, - strategy: IWalletCreationStrategy, + walletMode: EHardwareWalletCreationMode, features: IOneKeyDeviceFeatures, isFirmwareVerified?: boolean, - deviceState?: ReturnType, + deviceState?: IOneKeyDeviceState, + connectProtocol?: HardwareConnectProtocol, ) => { try { navigation.push(EOnboardingPages.FinalizeWalletSetup); @@ -819,11 +829,13 @@ export function useDeviceConnect({ device, hideCheckingDeviceLoading: true, features, + deviceState, + connectProtocol, isFirmwareVerified, defaultIsTemp: true, - isAttachPinMode: deviceState?.unlockedAttachPin, + isAttachPinMode: deviceState?.status.unlockedAttachPin ?? undefined, }; - if (strategy.createStandardWalletOnly) { + if (walletMode === EHardwareWalletCreationMode.Standard) { await actions.current.createHWWalletWithoutHidden(params); } else { await actions.current.createHWWalletWithHidden(params); @@ -839,7 +851,7 @@ export function useDeviceConnect({ await actions.current.updateHwWalletsDeprecatedStatus({ connectId: device.connectId ?? '', - deviceId: features.device_id || device.deviceId || '', + deviceId: deviceUtils.getRawDeviceId({ device, features }), }); } catch (error) { errorToastUtils.toastIfError(error); @@ -870,10 +882,12 @@ export function useDeviceConnect({ device, isFirmwareVerified, vendor, + connectProtocol, }: { device: SearchDevice; isFirmwareVerified?: boolean; vendor?: EHardwareVendor; + connectProtocol?: HardwareConnectProtocol; }) => { // For third-party vendor devices (Ledger), skip OneKey SDK // connection/features flow and create wallet directly. @@ -888,31 +902,39 @@ export function useDeviceConnect({ }); } - await ensureActiveConnection(device); + const cachedProtocol = getActiveDeviceFeatures()?.protocol; + const resolvedConnectProtocol = + cachedProtocol === 'V1' || cachedProtocol === 'V2' + ? cachedProtocol + : connectProtocol; + await ensureActiveConnection(device, { + connectProtocol: resolvedConnectProtocol, + }); const currentDevice = getActiveDevice() ?? device; void backgroundApiProxy.serviceHardwareUI.showDeviceProcessLoadingDialog({ connectId: currentDevice.connectId ?? '', }); let features: IOneKeyDeviceFeatures | undefined; + let deviceState: IOneKeyDeviceState; try { - features = - await backgroundApiProxy.serviceHardware.getFeaturesWithUnlock({ - connectId: currentDevice.connectId ?? '', - }); + deviceState = await getWalletCreationDeviceState({ + serviceHardware: backgroundApiProxy.serviceHardware, + connectId: currentDevice.connectId ?? '', + connectProtocol: resolvedConnectProtocol, + }); + features = projectLegacyDeviceFeaturesFromState(deviceState); } catch (error) { await closeDialogAndReturn(device, { skipDelayClose: true }); throw error; } - const deviceState = extractDeviceState(features); const strategy = await determineWalletCreationStrategy( deviceState, currentDevice, ); - console.log('Current hardware wallet State', deviceState, strategy); if (!strategy) { await closeDialogAndReturn(device, { skipDelayClose: true }); throw new OneKeyLocalError({ @@ -928,12 +950,13 @@ export function useDeviceConnect({ features, isFirmwareVerified, deviceState, + resolvedConnectProtocol, ); }, [ ensureActiveConnection, getActiveDevice, - extractDeviceState, + getActiveDeviceFeatures, determineWalletCreationStrategy, createHwWallet, closeDialogAndReturn, @@ -971,35 +994,41 @@ export function useDeviceConnect({ export const useConnectDeviceError = ( onError: (errorMessageId: ETranslations) => void, ) => { - const uiRequestCallback = throttle( - ({ uiRequestType }: { uiRequestType: EHardwareUiStateAction }) => { - if (uiRequestType === EHardwareUiStateAction.BLUETOOTH_PERMISSION) { - onError(ETranslations.onboarding_enable_bluetooth); - } else if ( - uiRequestType === - EHardwareUiStateAction.BLUETOOTH_CHARACTERISTIC_NOTIFY_CHANGE_FAILURE - ) { - onError( - platformEnv.isNativeIOS - ? ETranslations.feedback_try_toggling_bluetooth - : ETranslations.feedback_try_repairing_device_in_settings, - ); - } else if ( - uiRequestType === - EHardwareUiStateAction.WEB_DEVICE_PROMPT_ACCESS_PERMISSION - ) { - onError(ETranslations.device_not_connected); - } - }, - 2500, - ); - appEventBus.on(EAppEventBusNames.RequestHardwareUIDialog, uiRequestCallback); - return () => { - appEventBus.off( + useEffect(() => { + const uiRequestCallback = throttle( + ({ uiRequestType }: { uiRequestType: EHardwareUiStateAction }) => { + if (uiRequestType === EHardwareUiStateAction.BLUETOOTH_PERMISSION) { + onError(ETranslations.onboarding_enable_bluetooth); + } else if ( + uiRequestType === + EHardwareUiStateAction.BLUETOOTH_CHARACTERISTIC_NOTIFY_CHANGE_FAILURE + ) { + onError( + platformEnv.isNativeIOS + ? ETranslations.feedback_try_toggling_bluetooth + : ETranslations.feedback_try_repairing_device_in_settings, + ); + } else if ( + uiRequestType === + EHardwareUiStateAction.WEB_DEVICE_PROMPT_ACCESS_PERMISSION + ) { + onError(ETranslations.device_not_connected); + } + }, + 2500, + ); + appEventBus.on( EAppEventBusNames.RequestHardwareUIDialog, uiRequestCallback, ); - }; + return () => { + uiRequestCallback.cancel(); + appEventBus.off( + EAppEventBusNames.RequestHardwareUIDialog, + uiRequestCallback, + ); + }; + }, [onError]); }; export enum EBluetoothStatus { diff --git a/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx b/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx index 4b3b65e31d7c..3cc795eee270 100644 --- a/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; +import { EDeviceType } from '@onekeyfe/hd-shared'; import { isNil } from 'lodash'; import { useIntl } from 'react-intl'; @@ -7,7 +8,7 @@ import { Dialog } from '@onekeyhq/components'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; -import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; @@ -35,9 +36,28 @@ export function usePrepareUSBConnectForFirmwareUpdate() { device: SearchDevice; features: IOneKeyDeviceFeatures | undefined; }): Promise => { + const connectProtocol = + device.deviceType === EDeviceType.Pro2 ? 'V2' : undefined; + let connectIdToUse = device.connectId; + if (platformEnv.isDesktop && features) { + try { + const usbConnectId = await deviceUtils.buildDeviceUSBConnectId({ + features, + }); + if (!isNil(usbConnectId)) { + connectIdToUse = usbConnectId; + } + } catch (error) { + console.error('Failed to build USB connectId:', error); + } + } + // Step 1: Check if USB device is available const isUSBDeviceAvailable = - await backgroundApiProxy.serviceHardware.detectUSBDeviceAvailability(); + await backgroundApiProxy.serviceHardware.detectUSBDeviceAvailability({ + connectId: connectIdToUse ?? undefined, + connectProtocol, + }); if (!isUSBDeviceAvailable) { Dialog.show({ @@ -59,7 +79,7 @@ export function usePrepareUSBConnectForFirmwareUpdate() { // Step 2: For Desktop, switch to USB transport type if (platformEnv.isDesktop) { const desktopForceUSBTransportType = - await getDesktopForceUSBTransportType(); + await getDesktopForceUSBTransportType({ connectProtocol }); if (desktopForceUSBTransportType) { globalOriginalTransport = await backgroundApiProxy.serviceHardware.getCurrentForceTransportType(); @@ -69,25 +89,6 @@ export function usePrepareUSBConnectForFirmwareUpdate() { } } - // Step 3: Build USB connectId from BLE connection if needed - let connectIdToUse = device.connectId; - if ( - platformEnv.isDesktop && - globalOriginalTransport === EHardwareTransportType.DesktopWebBle && - features - ) { - try { - const usbConnectId = await deviceUtils.buildDeviceUSBConnectId({ - features, - }); - if (!isNil(usbConnectId)) { - connectIdToUse = usbConnectId; - } - } catch (error) { - console.error('Failed to build USB connectId:', error); - } - } - if (isNil(connectIdToUse)) { return null; } diff --git a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx index f87abd65050c..dfd6b988b85f 100644 --- a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx @@ -48,6 +48,8 @@ import { usePrepareUSBConnectForFirmwareUpdate } from '../hooks/usePrepareUSBCon import { OnboardingTestIDs } from '../testIDs'; import { getForceTransportType } from '../utils'; +import { createFirmwareRecheckTimer } from './firmwareRecheckUtils'; + import type { Features, KnownDevice, SearchDevice } from '@onekeyfe/hd-core'; enum ECheckAndUpdateStepState { @@ -88,8 +90,7 @@ function CheckAndUpdatePage({ EOnboardingPagesV2.CheckAndUpdate >) { const intl = useIntl(); - const { deviceData, tabValue } = routeParams?.params || {}; - console.log('deviceData', deviceData); + const { connectProtocol, deviceData, tabValue } = routeParams?.params || {}; const navigation = useAppNavigation(); const reactNavigation = useNavigation(); const isFirmwareVerifiedRef = useRef(undefined); @@ -104,11 +105,19 @@ function CheckAndUpdatePage({ // update: consumed when the scheduled recheck fires (or cleared by an // explicit user Skip). Only drives scheduling/delay math. const firmwareUpdateFinishTimeRef = useRef(null); + const firmwareRecheckCancelRef = useRef<(() => void) | null>(null); // Carries the "device may still be rebooting" fact separately: set on // FinishFirmwareUpdate, cleared only when a check COMPLETES successfully. // While set, every round — including a manual Retry — upgrades to the // patient reconnect path and the longer watchdog budget. const pendingPostUpdateReconnectRef = useRef(false); + const [isFirmwareRecheckPending, setIsFirmwareRecheckPending] = + useState(false); + + const cancelFirmwareRecheck = useCallback(() => { + firmwareRecheckCancelRef.current?.(); + firmwareRecheckCancelRef.current = null; + }, []); const [currentDevice, setCurrentDevice] = useState( deviceData.device as SearchDevice | undefined, @@ -129,13 +138,14 @@ function CheckAndUpdatePage({ if (!deviceType) { return deviceLabel; } - return deviceUtils.getDeviceModelNameByType(deviceType) || deviceLabel; + return deviceUtils.getDefaultDeviceLabel(deviceType) || deviceLabel; }, [currentDevice, deviceLabel]); const { verifyHardware, ensureActiveConnection, getActiveDevice, + getActiveDeviceFeatures, ensureStopScan, } = useDeviceConnect({ setCurrentDevice, @@ -146,13 +156,28 @@ function CheckAndUpdatePage({ if (!tabValue) { return; } - const forceTransportType = await getForceTransportType(tabValue); + const activeFeaturesProtocol = getActiveDeviceFeatures()?.protocol; + const confirmedConnectProtocol = + activeFeaturesProtocol === 'V1' || activeFeaturesProtocol === 'V2' + ? activeFeaturesProtocol + : (getActiveDevice()?.connectProtocol ?? + currentDevice?.connectProtocol ?? + connectProtocol); + const forceTransportType = await getForceTransportType(tabValue, { + connectProtocol: confirmedConnectProtocol, + }); if (forceTransportType) { await backgroundApiProxy.serviceHardware.setForceTransportType({ forceTransportType, }); } - }, [tabValue]); + }, [ + connectProtocol, + currentDevice?.connectProtocol, + getActiveDevice, + getActiveDeviceFeatures, + tabValue, + ]); const [steps, setSteps] = useState< { @@ -282,17 +307,35 @@ function CheckAndUpdatePage({ // (its connectId may have changed after a firmware update) so DeviceSetup // and FinalizeWalletSetup talk to the right device. const toDeviceSetup = useCallback(() => { + const activeDevice = (getActiveDevice() ?? + currentDevice ?? + deviceData.device) as SearchDevice; + const activeFeaturesProtocol = getActiveDeviceFeatures()?.protocol; + const confirmedConnectProtocol = + activeFeaturesProtocol === 'V1' || activeFeaturesProtocol === 'V2' + ? activeFeaturesProtocol + : (activeDevice.connectProtocol ?? connectProtocol); navigation.push(EOnboardingPagesV2.DeviceSetup, { + connectProtocol: confirmedConnectProtocol, deviceData: { ...deviceData, - device: (getActiveDevice() ?? - currentDevice ?? - deviceData.device) as SearchDevice, + device: { + ...activeDevice, + connectProtocol: confirmedConnectProtocol, + }, }, tabValue, isFirmwareVerified: isFirmwareVerifiedRef.current, }); - }, [navigation, deviceData, getActiveDevice, currentDevice, tabValue]); + }, [ + connectProtocol, + navigation, + deviceData, + getActiveDevice, + getActiveDeviceFeatures, + currentDevice, + tabValue, + ]); // Retry connecting to device after firmware update const retryDeviceConnectionAfterUpdate = useCallback( @@ -405,6 +448,7 @@ function CheckAndUpdatePage({ // manual Retry — to the patient path with its longer watchdog budget. const checkAfterUpdate = params?.checkAfterUpdate || pendingPostUpdateReconnectRef.current; + setIsFirmwareRecheckPending(false); const cancelTimeout = createStepTimeout( isStale, () => watchdogConnectId, @@ -444,6 +488,7 @@ function CheckAndUpdatePage({ { connectId: compatibleConnectId, skipCancel: true, + checkFirmwareHash: checkAfterUpdate, firmwareType: undefined, }, ); @@ -575,33 +620,44 @@ function CheckAndUpdatePage({ return; } - const elapsed = Date.now() - finishTime; - const remainingDelay = Math.max(0, FIRMWARE_RECHECK_DELAY - elapsed); - - setSteps((prev) => { - const newSteps = [...prev]; - newSteps[1] = { - ...newSteps[1], - state: ECheckAndUpdateStepState.InProgress, - }; - return newSteps; - }); - // Wait for remaining delay (0 if already >= 10s), then recheck firmware. - const timeoutId = setTimeout(() => { - // One-shot: consume the timestamp when the recheck actually fires. - // The patient-path upgrade for later rounds is carried by - // pendingPostUpdateReconnectRef instead. - firmwareUpdateFinishTimeRef.current = null; - void checkFirmwareUpdate({ - checkAfterUpdate: true, - }); - }, remainingDelay); + // Keep the previous state during this cancellable window so a blur can + // safely reschedule the check on the next focus. The check owns the + // InProgress transition when it actually starts. + cancelFirmwareRecheck(); + setIsFirmwareRecheckPending(true); + const cancel = createFirmwareRecheckTimer({ + finishTime, + delayMs: FIRMWARE_RECHECK_DELAY, + onFire: () => { + firmwareRecheckCancelRef.current = null; + setIsFirmwareRecheckPending(false); + // A user decision may be made between scheduling and firing. + if ( + firmwareStepStateRef.current === ECheckAndUpdateStepState.Skipped || + firmwareStepStateRef.current === ECheckAndUpdateStepState.Success + ) { + firmwareUpdateFinishTimeRef.current = null; + return; + } + // One-shot: consume the timestamp when the recheck actually fires. + // The patient-path upgrade for later rounds is carried by + // pendingPostUpdateReconnectRef instead. + firmwareUpdateFinishTimeRef.current = null; + void checkFirmwareUpdate({ + checkAfterUpdate: true, + }); + }, + }); + firmwareRecheckCancelRef.current = cancel; return () => { - clearTimeout(timeoutId); + cancel(); + if (firmwareRecheckCancelRef.current === cancel) { + firmwareRecheckCancelRef.current = null; + } }; - }, [checkFirmwareUpdate, restoreOriginalTransport]), + }, [cancelFirmwareRecheck, checkFirmwareUpdate, restoreOriginalTransport]), ); useEffect(() => { @@ -650,10 +706,10 @@ function CheckAndUpdatePage({ // unavailable) is not a verification pass — record it as Skipped so the // step doesn't claim the device is genuine. let genuineState = ECheckAndUpdateStepState.Error; - if (result.verified) { - genuineState = ECheckAndUpdateStepState.Success; - } else if (result.skipVerification) { + if (result.skipVerification) { genuineState = ECheckAndUpdateStepState.Skipped; + } else if (result.verified) { + genuineState = ECheckAndUpdateStepState.Success; } const shouldContinueToFirmwareCheck = genuineState !== ECheckAndUpdateStepState.Error; @@ -747,6 +803,8 @@ function CheckAndUpdatePage({ // Declining the optional update is recorded honestly as // Skipped — the flow continues, but without success visuals. // Skipping also cancels any pending focus-effect auto-recheck. + cancelFirmwareRecheck(); + setIsFirmwareRecheckPending(false); firmwareUpdateFinishTimeRef.current = null; setSteps((prev) => { const newSteps = [...prev]; @@ -762,7 +820,7 @@ function CheckAndUpdatePage({ ), }); - }, [intl]); + }, [cancelFirmwareRecheck, intl]); useConnectDeviceError( useCallback( @@ -787,6 +845,8 @@ function CheckAndUpdatePage({ const handleSkipCurrentStep = useCallback(() => { let currentStepId: ECheckAndUpdateStepId | undefined; // Skipping also cancels any pending focus-effect auto-recheck. + cancelFirmwareRecheck(); + setIsFirmwareRecheckPending(false); firmwareUpdateFinishTimeRef.current = null; setSteps((prev) => { const index = prev.findIndex( @@ -813,7 +873,7 @@ function CheckAndUpdatePage({ void checkFirmwareUpdate(); } }, 150); - }, [checkFirmwareUpdate]); + }, [cancelFirmwareRecheck, checkFirmwareUpdate]); // Primary CTA at the foot of the flow. The two states are mutually exclusive // (all-idle → verify the device; ready → continue to setup), so the single @@ -883,15 +943,21 @@ function CheckAndUpdatePage({ displayTitle = step.id === ECheckAndUpdateStepId.GenuineCheck ? intl.formatMessage({ - id: ETranslations.genuine_check_skipped_title, + id: ETranslations.global_skip, }) : intl.formatMessage({ - id: ETranslations.firmware_check_skipped_title, + id: ETranslations.global_skip, }); } - const displayDescription = isStepCollapsed - ? undefined - : step.description; + const isFirmwareRecheckWaiting = + step.id === ECheckAndUpdateStepId.FirmwareCheck && + isFirmwareRecheckPending; + let displayDescription = isStepCollapsed ? undefined : step.description; + if (isFirmwareRecheckWaiting) { + displayDescription = intl.formatMessage({ + id: ETranslations.update_checking_device_if_no_restart, + }); + } return ( {/* highlight background */} @@ -970,7 +1036,10 @@ function CheckAndUpdatePage({ : 'firmware' } tone={illustrationTone} - beaming={step.state === ECheckAndUpdateStepState.InProgress} + beaming={ + step.state === ECheckAndUpdateStepState.InProgress || + isFirmwareRecheckWaiting + } /> {displayTitle} @@ -984,7 +1053,8 @@ function CheckAndUpdatePage({ {/* update */} {step.id === ECheckAndUpdateStepId.FirmwareCheck && - step.state === ECheckAndUpdateStepState.Warning ? ( + step.state === ECheckAndUpdateStepState.Warning && + !isFirmwareRecheckPending ? ( import('./ConnectionFlowLedger')); @@ -208,6 +209,11 @@ function useDeviceConnection({ forceTransportType, }); } + const transportType = + forceTransportType === EHardwareTransportType.BLE || + forceTransportType === EHardwareTransportType.DesktopWebBle + ? 'ble' + : 'usb'; isSearchingRef.current = true; deviceScanner.startDeviceScan( @@ -292,18 +298,11 @@ function useDeviceConnection({ // Only set search results if tabValue hasn't changed if (currentTabValueRef.current === tabValue) { - if (tabValue === EConnectDeviceChannel.bluetooth) { - const isUsbData = sortedDevices.some((device) => - // @ts-expect-error - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - isString(device.features?.device_id), - ); - if (isUsbData) { - setSearchedDevices([]); - return; - } - } - setSearchedDevices(sortedDevices); + setSearchedDevices( + tabValue === EConnectDeviceChannel.bluetooth + ? sortedDevices.filter(deviceUtils.isBluetoothSearchDevice) + : sortedDevices, + ); } else { console.log('🔍 Ignoring search results - tab changed during search'); } @@ -315,6 +314,7 @@ function useDeviceConnection({ undefined, // pollInterval undefined, // maxTryCount vendor, + { transportType }, ); }, [deviceScanner, intl, tabValue, vendor]); @@ -543,7 +543,18 @@ function BluetoothCard({ ); } -function DeviceVideo({ deviceTypeItems }: { deviceTypeItems: EDeviceType[] }) { +function DeviceVideo({ + deviceTypeItems, + themeVariant, +}: { + deviceTypeItems: EDeviceType[]; + themeVariant: 'light' | 'dark'; +}) { + const isPro2 = useMemo( + () => deviceTypeItems.includes(EDeviceType.Pro2), + [deviceTypeItems], + ); + const isTouch = useMemo(() => { return deviceTypeItems.find( (deviceType) => deviceType === EDeviceType.Touch, @@ -568,6 +579,9 @@ function DeviceVideo({ deviceTypeItems }: { deviceTypeItems: EDeviceType[] }) { // The onboarding flow is force-dark, so every device uses its dark (-D) asset // and no theme branching is needed. const videoSource = useMemo(() => { + if (isPro2) { + return require('@onekeyhq/kit/assets/onboarding/ProW-D.mp4') as ReactVideoSource; + } if (isMini) { return require('@onekeyhq/kit/assets/onboarding/Mini-D.mp4') as ReactVideoSource; } @@ -578,7 +592,7 @@ function DeviceVideo({ deviceTypeItems }: { deviceTypeItems: EDeviceType[] }) { return require('@onekeyhq/kit/assets/onboarding/Touch-D.mp4') as ReactVideoSource; } return require('@onekeyhq/kit/assets/onboarding/ProW-D.mp4') as ReactVideoSource; - }, [isClassic, isMini, isTouch]); + }, [isClassic, isMini, isPro2, isTouch, themeVariant]); return ( + ) : null} + + + )} ); } diff --git a/packages/kit/src/views/Onboardingv2/pages/FinalizeWalletSetup.tsx b/packages/kit/src/views/Onboardingv2/pages/FinalizeWalletSetup.tsx index 496371365104..929c0aef2e75 100644 --- a/packages/kit/src/views/Onboardingv2/pages/FinalizeWalletSetup.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/FinalizeWalletSetup.tsx @@ -268,6 +268,7 @@ function FinalizeWalletSetupPage({ const mnemonic = route?.params?.mnemonic; const mnemonicType = route?.params?.mnemonicType; const deviceData = route?.params?.deviceData; + const connectProtocol = route?.params?.connectProtocol; const ledgerTabValue = route?.params?.tabValue; const isFirmwareVerified = route?.params?.isFirmwareVerified; const isWalletBackedUp = route?.params?.isWalletBackedUp; @@ -596,7 +597,7 @@ function FinalizeWalletSetupPage({ let featuresForCreate = { device_id: thirdPartyDevice?.deviceId || '', vendor: deviceData.vendor, - } as IOneKeyDeviceFeatures; + } as unknown as IOneKeyDeviceFeatures; if ( deviceData.vendor === EHardwareVendor.trezor && thirdPartyDevice.connectId @@ -628,11 +629,16 @@ function FinalizeWalletSetupPage({ const connectedFeatures = connected.success ? connected.payload.features : undefined; + const legacyConnectedFeatures = connectedFeatures as + | { + device_id?: string; + } + | undefined; const connectedDeviceId = connected.success && (connected.payload.deviceId || - (typeof connectedFeatures?.device_id === 'string' - ? connectedFeatures.device_id + (typeof legacyConnectedFeatures?.device_id === 'string' + ? legacyConnectedFeatures.device_id : '')); if (!connected.success) { throw getTrezorConnectFailureError( @@ -679,7 +685,7 @@ function FinalizeWalletSetupPage({ featuresForCreate = { ...connectedFeatures, device_id: connectedDeviceId, - } as IOneKeyDeviceFeatures; + } as unknown as IOneKeyDeviceFeatures; const rawThirdPartyDevice = ( thirdPartyDevice as SearchDevice & { raw?: Record; @@ -742,6 +748,7 @@ function FinalizeWalletSetupPage({ goNextStep(EFinalizeWalletSetupSteps.ConnectingDevice); await connectDevice(deviceData.device as SearchDevice); await createHWWallet({ + connectProtocol, device: deviceData.device as SearchDevice, isFirmwareVerified, }); @@ -792,6 +799,7 @@ function FinalizeWalletSetupPage({ shouldAutoResetKeylessPinAfterRestore, connectDevice, createHWWallet, + connectProtocol, setPendingKeylessAutoConnectWalletId, goNextStep, hardwareTransportType, diff --git a/packages/kit/src/views/Onboardingv2/pages/deviceSetupPro2.tsx b/packages/kit/src/views/Onboardingv2/pages/deviceSetupPro2.tsx new file mode 100644 index 000000000000..426ab49147af --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/deviceSetupPro2.tsx @@ -0,0 +1,468 @@ +import type { ReactNode } from 'react'; + +import { EDeviceType } from '@onekeyfe/hd-shared'; +import { type IntlShape, defineMessages, useIntl } from 'react-intl'; + +import { Anchor, Image, SizableText, YStack } from '@onekeyhq/components'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { HwWalletAvatarImages } from '@onekeyhq/shared/src/utils/avatarUtils'; + +import { SeedCardIllustration } from '../components/SeedCardIllustration'; +import { SetupCardBody } from '../components/SetupCard'; +import { SetupCardBackground } from '../components/SetupCardBackground'; +import { SetupStepItem } from '../components/SetupStepItem'; + +import { + EPro2OnboardingStep, + mapPro2OnboardingStatus, +} from './pro2OnboardingStatus'; + +import type { IPro2OnboardingStatus } from './pro2OnboardingStatus'; +import type { IDeviceType } from '@onekeyfe/hd-core'; + +const pro2Avatar = HwWalletAvatarImages[EDeviceType.Pro]; + +const pro2Messages = defineMessages({ + createPhraseDesc: { + id: ETranslations.device_setup_create_phrase_desc, + defaultMessage: + 'Write down each word in order and keep it offline. Anyone with this phrase can access your assets. OneKey cannot recover it for you.', + }, + createSeedCardDesc: { + id: ETranslations.device_setup_create_seedcard_desc, + defaultMessage: + 'Create a wallet backup on a PIN-protected SeedCard with a built-in Secure Element. Treat the card and PIN as sensitive backup materials.', + }, + createSeedCardLink: { + id: ETranslations.device_setup_create_seedcard_link, + defaultMessage: 'Learn about SeedCard', + }, + followStepsInstruction: { + id: ETranslations.device_setup_follow_steps_instruction, + defaultMessage: 'Follow the steps on your device', + }, + personalizeDesc: { + id: ETranslations.device_setup_personalize_desc, + defaultMessage: 'Choose a language and name your device.', + }, + personalizeInstruction: { + id: ETranslations.device_setup_personalize_instruction, + defaultMessage: 'Continue on your device', + }, + personalizeTitle: { + id: ETranslations.device_setup_personalize_title, + defaultMessage: 'Personalize your device', + }, + pinDesc: { + id: ETranslations.device_setup_pin_desc, + defaultMessage: + 'Your PIN unlocks your device and helps prevent unauthorized access. Keep it private.', + }, + pinInstruction: { + id: ETranslations.device_setup_pin_instruction, + defaultMessage: 'Create a PIN on your device', + }, + pinTitle: { + id: ETranslations.device_setup_pin_title, + defaultMessage: 'Create PIN', + }, + restoreDesc: { + id: ETranslations.device_setup_restore_desc, + defaultMessage: 'Choose how you want to restore your wallet:', + }, + restoreInstruction: { + id: ETranslations.device_setup_restore_instruction, + defaultMessage: 'Choose a restore method', + }, + restoreOptionPhraseDesc: { + id: ETranslations.device_setup_restore_option_phrase_desc, + defaultMessage: 'Use a recovery phrase from an existing wallet.', + }, + restoreOptionSeedCardDesc: { + id: ETranslations.device_setup_restore_option_seedcard_desc, + defaultMessage: 'Use your SeedCard to restore wallet access.', + }, + restorePhraseDesc: { + id: ETranslations.device_setup_restore_phrase_desc, + defaultMessage: + 'Keep it private while restoring. Never enter it in the app or on a website.', + }, + restorePhraseInstruction: { + id: ETranslations.device_setup_restore_phrase_instruction, + defaultMessage: 'Enter the recovery phrase on your device', + }, + restorePhraseTitle: { + id: ETranslations.device_setup_restore_phrase_title, + defaultMessage: 'Restore with recovery phrase', + }, + restoreSeedCardDesc: { + id: ETranslations.device_setup_restore_seedcard_desc, + defaultMessage: 'Keep your SeedCard nearby and follow the device prompts.', + }, + restoreSeedCardTitle: { + id: ETranslations.device_setup_restore_seedcard_title, + defaultMessage: 'Restore with SeedCard', + }, + walletDesc: { + id: ETranslations.device_setup_wallet_desc, + defaultMessage: 'Create a new wallet or restore an existing one:', + }, + walletInstruction: { + id: ETranslations.device_setup_wallet_instruction, + defaultMessage: 'Choose an option on your device', + }, + walletOptionCreateDesc: { + id: ETranslations.device_setup_wallet_option_create_desc, + defaultMessage: 'Generate a new recovery phrase on your device.', + }, + walletOptionRestore: { + id: ETranslations.device_setup_wallet_option_restore, + defaultMessage: 'Restore wallet', + }, + walletOptionRestoreDesc: { + id: ETranslations.device_setup_wallet_option_restore_desc, + defaultMessage: 'Restore from a recovery phrase or SeedCard.', + }, + walletTitle: { + id: ETranslations.device_setup_wallet_title, + defaultMessage: 'Set up wallet', + }, +}); + +export function supportsDeviceDrivenOnboarding( + device: { deviceType?: IDeviceType } | undefined, +): boolean { + return device?.deviceType === EDeviceType.Pro2; +} + +// --------------------------------------------------------------------------- +// Pure mapper: status → macro phase +// --------------------------------------------------------------------------- + +export type IDeviceSetupPhase = 'checking' | 'needsSetup' | 'ready'; + +export function onboardingStatusToPhase( + status: IPro2OnboardingStatus | undefined, +): IDeviceSetupPhase { + if (!status) { + return 'checking'; + } + const viewState = mapPro2OnboardingStatus(status); + if (viewState.step === EPro2OnboardingStep.Checking) { + return 'checking'; + } + if (viewState.step === EPro2OnboardingStep.Done) { + return 'ready'; + } + return 'needsSetup'; +} + +// --------------------------------------------------------------------------- +// Step content +// --------------------------------------------------------------------------- + +function TextBody({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function OptionRow({ title, desc }: { title: string; desc: string }) { + return ( + + {title} + {desc} + + ); +} + +// A "Create New Wallet" sub-card body: a 90×90 illustration + title + +// description (the glow/shimmer background is supplied via backgroundSlot). +function MediaCardBody({ + title, + illustration, + children, +}: { + title: string; + illustration: ReactNode; + children: ReactNode; +}) { + return ( + + {illustration} + + {title} + {children} + + + ); +} + +interface IStepContent { + body: ReactNode; + instruction: string; + background?: ReactNode; +} + +// Native-glow / web-shimmer geometry for the create sub-cards: the glow bleeds +// down from above the 90×90 illustration and the shimmer field is clipped to sit +// over it. Identical for both create variants — only the color theme differs. +const CREATE_CARD_BG_GEOM = { + glowSize: 534, + glowTop: -266, + shimmerHeight: 220, +}; + +// MOCK: external "learn more" target for the SeedCard card — placeholder until +// the real product-page URL is wired. +const SEED_CARD_LEARN_URL = 'https://onekey.so'; + +function getSetupStepContent( + intl: IntlShape, + setup: ReturnType['setup'], +): IStepContent { + // The same generic "do it on the device" prompt, reused by several screens. + const followSteps = intl.formatMessage(pro2Messages.followStepsInstruction); + // create branch + if (setup?.kind === 'create' && setup.card === 'recoveryPhrase') { + return { + instruction: followSteps, + background: ( + + ), + body: ( + + } + > + + {intl.formatMessage(pro2Messages.createPhraseDesc)} + + + ), + }; + } + if (setup?.kind === 'create' && setup.card === 'seedCard') { + return { + instruction: followSteps, + background: ( + + ), + body: ( + // "OneKey SeedCard" is a product name — untranslatable, so hardcoded. + } + > + + {intl.formatMessage(pro2Messages.createSeedCardDesc)} + + {/* External link — Anchor adds the underline + ↗ and opens the URL + (web: , native: Linking.openURL). */} + + {intl.formatMessage(pro2Messages.createSeedCardLink)} + + + ), + }; + } + // restore branch + if (setup?.kind === 'restore' && setup.method === 'recoveryPhrase') { + return { + instruction: intl.formatMessage(pro2Messages.restorePhraseInstruction), + body: ( + + + + ), + }; + } + if (setup?.kind === 'restore' && setup.method === 'seedCard') { + return { + instruction: followSteps, + body: ( + + + + ), + }; + } + if (setup?.kind === 'restore') { + return { + instruction: intl.formatMessage(pro2Messages.restoreInstruction), + body: ( + + + {intl.formatMessage(pro2Messages.restoreDesc)} + + + {/* "SeedCard" is a product name — untranslatable, so hardcoded. */} + + + ), + }; + } + // choice (default) + return { + instruction: intl.formatMessage(pro2Messages.walletInstruction), + body: ( + + + {intl.formatMessage(pro2Messages.walletDesc)} + + + + + ), + }; +} + +function getStepContent( + intl: IntlShape, + step: EPro2OnboardingStep, + status: ReturnType, +): IStepContent { + if (step === EPro2OnboardingStep.Personalization) { + return { + instruction: intl.formatMessage(pro2Messages.personalizeInstruction), + body: ( + {intl.formatMessage(pro2Messages.personalizeDesc)} + ), + }; + } + if (step === EPro2OnboardingStep.Pin) { + return { + instruction: intl.formatMessage(pro2Messages.pinInstruction), + body: {intl.formatMessage(pro2Messages.pinDesc)}, + }; + } + // Setup + return getSetupStepContent(intl, status.setup); +} + +// --------------------------------------------------------------------------- +// Stepper view +// --------------------------------------------------------------------------- + +function stepStateForIndex(index: number, currentIndex: number) { + if (index < currentIndex) { + return 'done' as const; + } + if (index === currentIndex) { + return 'inProgress' as const; + } + return 'pending' as const; +} + +// Identity of the current inProgress body, so the Setup card cross-fades as its +// sub-status cycles (choice → create → restore → …) instead of hard-swapping. +// Personalization/Pin each have a single body, keyed by the step name. +function stepContentKey( + step: EPro2OnboardingStep, + setup: ReturnType['setup'], +): string { + if (step !== EPro2OnboardingStep.Setup) { + return EPro2OnboardingStep[step]; + } + if (!setup || setup.kind === 'choice') { + return 'Setup:choice'; + } + if (setup.kind === 'create') { + return `Setup:create:${setup.card}`; + } + return setup.method ? `Setup:restore:${setup.method}` : 'Setup:restore'; +} + +// The three stepper rows, in order. Titles carry no device name, so each is a +// stable translation id — hoisted so the array isn't reallocated on every +// status change. +const STEPPER_STEPS = [ + { + step: EPro2OnboardingStep.Personalization, + title: pro2Messages.personalizeTitle, + }, + { + step: EPro2OnboardingStep.Pin, + title: pro2Messages.pinTitle, + }, + { + step: EPro2OnboardingStep.Setup, + title: pro2Messages.walletTitle, + }, +]; + +export function Pro2OnboardingStepper({ + status, +}: { + status: IPro2OnboardingStatus; +}) { + const intl = useIntl(); + const viewState = mapPro2OnboardingStatus(status); + const currentIndex = viewState.step - EPro2OnboardingStep.Personalization; + return ( + + {STEPPER_STEPS.map((def, i) => { + const state = stepStateForIndex(i, currentIndex); + const isLast = i === STEPPER_STEPS.length - 1; + const content = + state === 'inProgress' + ? getStepContent(intl, def.step, viewState) + : undefined; + return ( + + {content?.body} + + ); + })} + + ); +} diff --git a/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.test.ts b/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.test.ts new file mode 100644 index 000000000000..325b79b9d5b8 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.test.ts @@ -0,0 +1,69 @@ +import { createFirmwareRecheckTimer } from './firmwareRecheckUtils'; + +describe('createFirmwareRecheckTimer', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('fires after the remaining post-update delay', () => { + const onFire = jest.fn(); + createFirmwareRecheckTimer({ + finishTime: 10_000, + delayMs: 10_000, + now: () => 15_000, + onFire, + }); + + jest.advanceTimersByTime(4999); + expect(onFire).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(onFire).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending recheck so Skip cannot be overwritten', () => { + const onFire = jest.fn(); + const cancel = createFirmwareRecheckTimer({ + finishTime: 0, + delayMs: 10_000, + now: () => 0, + onFire, + }); + + cancel(); + jest.advanceTimersByTime(10_000); + expect(onFire).not.toHaveBeenCalled(); + }); + + it('caps the delay when the system clock moves backwards', () => { + const onFire = jest.fn(); + createFirmwareRecheckTimer({ + finishTime: 20_000, + delayMs: 10_000, + now: () => 5000, + onFire, + }); + + jest.advanceTimersByTime(9999); + expect(onFire).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(onFire).toHaveBeenCalledTimes(1); + }); + + it('does not fire more than once', () => { + const onFire = jest.fn(); + createFirmwareRecheckTimer({ + finishTime: 0, + delayMs: 0, + now: () => 0, + onFire, + }); + + jest.runOnlyPendingTimers(); + jest.runOnlyPendingTimers(); + expect(onFire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.ts b/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.ts new file mode 100644 index 000000000000..1a4fc2aedafe --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/firmwareRecheckUtils.ts @@ -0,0 +1,35 @@ +export type IFirmwareRecheckTimer = () => void; + +export function createFirmwareRecheckTimer({ + finishTime, + delayMs, + now = Date.now, + onFire, +}: { + finishTime: number; + delayMs: number; + now?: () => number; + onFire: () => void; +}): IFirmwareRecheckTimer { + let active = true; + const boundedDelayMs = Math.max(0, delayMs); + const remainingDelay = Math.min( + boundedDelayMs, + Math.max(0, boundedDelayMs - (now() - finishTime)), + ); + const timeoutId = setTimeout(() => { + if (!active) { + return; + } + active = false; + onFire(); + }, remainingDelay); + + return () => { + if (!active) { + return; + } + active = false; + clearTimeout(timeoutId); + }; +} diff --git a/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.test.ts b/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.test.ts new file mode 100644 index 000000000000..76d90c1adf8b --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.test.ts @@ -0,0 +1,135 @@ +import { + OnboardingPhase, + OnboardingSetupKind, + OnboardingSetupMethod, + OnboardingStep, +} from '@onekeyfe/hd-transport'; + +import { + EPro2OnboardingStep, + mapPro2OnboardingStatus, +} from './pro2OnboardingStatus'; + +describe('mapPro2OnboardingStatus', () => { + it.each([ + [OnboardingStep.ONBOARDING_STEP_UNKNOWN, 'checking'], + [OnboardingStep.ONBOARDING_STEP_CHECKING, 'checking'], + [OnboardingStep.ONBOARDING_STEP_PERSONALIZATION, 'personalization'], + [OnboardingStep.ONBOARDING_STEP_PIN, 'setup'], + [OnboardingStep.ONBOARDING_STEP_SETUP, 'setup'], + ] as const)('maps %s to %s', (step, expectedPhase) => { + expect(mapPro2OnboardingStatus({ step }).phase).toBe(expectedPhase); + }); + + it('maps create recovery phrase setup from explicit setup fields', () => { + expect( + mapPro2OnboardingStatus({ + step: OnboardingStep.ONBOARDING_STEP_SETUP, + phase: OnboardingPhase.ONBOARDING_PHASE_RECOVERY_PHRASE_VIEW, + setup: { + kind: OnboardingSetupKind.ONBOARDING_SETUP_KIND_CREATE, + method: OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_RECOVERY_PHRASE, + }, + pin_set: true, + wallet_initialized: false, + }), + ).toMatchObject({ + step: EPro2OnboardingStep.Setup, + setup: { kind: 'create', card: 'recoveryPhrase' }, + ready: false, + }); + }); + + it('maps SeedCard restore from numeric protobuf enum values', () => { + expect( + mapPro2OnboardingStatus({ + step: 4, + phase: 10, + setup: { kind: 3, method: 2 }, + pin_set: true, + wallet_initialized: false, + }), + ).toMatchObject({ + step: EPro2OnboardingStep.Setup, + setup: { kind: 'restore', method: 'seedCard' }, + }); + }); + + it.each([ + OnboardingPhase.ONBOARDING_PHASE_WALLET_READY, + OnboardingPhase.ONBOARDING_PHASE_SEEDCARD_BACKUP_PROMPT, + OnboardingPhase.ONBOARDING_PHASE_SEEDCARD_BACKUP, + ])('maps backup phase %s to the backup presentation', (phase) => { + expect( + mapPro2OnboardingStatus({ + step: OnboardingStep.ONBOARDING_STEP_SETUP, + phase, + setup: { + kind: OnboardingSetupKind.ONBOARDING_SETUP_KIND_CREATE, + method: OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_SEEDCARD, + }, + pin_set: true, + wallet_initialized: true, + }), + ).toMatchObject({ + phase: 'backup', + step: EPro2OnboardingStep.Setup, + setup: { kind: 'create', card: 'seedCard' }, + ready: false, + }); + }); + + it('requires DONE, PIN and initialized wallet before becoming ready', () => { + expect( + mapPro2OnboardingStatus({ + step: OnboardingStep.ONBOARDING_STEP_DONE, + pin_set: true, + wallet_initialized: true, + }), + ).toMatchObject({ + phase: 'ready', + step: EPro2OnboardingStep.Done, + ready: true, + }); + + expect( + mapPro2OnboardingStatus({ + step: OnboardingStep.ONBOARDING_STEP_DONE, + pin_set: true, + wallet_initialized: false, + }), + ).toMatchObject({ + phase: 'checking', + step: EPro2OnboardingStep.Checking, + ready: false, + }); + }); + + it('keeps unknown future enum values in checking state', () => { + expect( + mapPro2OnboardingStatus({ + step: 999 as OnboardingStep, + }), + ).toMatchObject({ + phase: 'checking', + step: EPro2OnboardingStep.Checking, + ready: false, + }); + }); + + it('does not accept removed DEV onboarding enum names', () => { + expect( + mapPro2OnboardingStatus({ + step: ['DEV', 'ONBOARDING', 'STEP', 'DONE'].join( + '_', + ) as unknown as OnboardingStep, + pin_set: true, + wallet_initialized: true, + }), + ).toMatchObject({ + phase: 'checking', + step: EPro2OnboardingStep.Checking, + ready: false, + }); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.ts b/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.ts new file mode 100644 index 000000000000..c9570b82564a --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/pro2OnboardingStatus.ts @@ -0,0 +1,197 @@ +import { + OnboardingPhase, + OnboardingSetupKind, + OnboardingSetupMethod, + OnboardingStep, +} from '@onekeyfe/hd-transport'; + +import type { OnboardingStatus } from '@onekeyfe/hd-transport'; + +export type IPro2OnboardingPhase = + | 'checking' + | 'personalization' + | 'setup' + | 'backup' + | 'ready'; + +export enum EPro2OnboardingStep { + Checking = 0, + Personalization = 1, + Pin = 2, + Setup = 3, + Done = 4, +} + +export type IPro2SetupSubStatus = + | { kind: 'choice' } + | { kind: 'create'; card: 'recoveryPhrase' | 'seedCard' } + | { kind: 'restore'; method?: 'recoveryPhrase' | 'seedCard' }; + +export type IPro2OnboardingStatus = OnboardingStatus; + +type IPro2NormalizedStep = + | 'unknown' + | 'checking' + | 'personalization' + | 'pin' + | 'setup' + | 'done'; + +export type IPro2OnboardingViewState = { + phase: IPro2OnboardingPhase; + step: EPro2OnboardingStep; + setup?: IPro2SetupSubStatus; + devicePhase?: OnboardingPhase; + pinSet: boolean; + walletInitialized: boolean; + ready: boolean; +}; + +const STEP_BY_VALUE: Record = { + [OnboardingStep.ONBOARDING_STEP_UNKNOWN]: 'unknown', + [OnboardingStep.ONBOARDING_STEP_CHECKING]: 'checking', + [OnboardingStep.ONBOARDING_STEP_PERSONALIZATION]: 'personalization', + [OnboardingStep.ONBOARDING_STEP_PIN]: 'pin', + [OnboardingStep.ONBOARDING_STEP_SETUP]: 'setup', + [OnboardingStep.ONBOARDING_STEP_DONE]: 'done', + ONBOARDING_STEP_UNKNOWN: 'unknown', + ONBOARDING_STEP_CHECKING: 'checking', + ONBOARDING_STEP_PERSONALIZATION: 'personalization', + ONBOARDING_STEP_PIN: 'pin', + ONBOARDING_STEP_SETUP: 'setup', + ONBOARDING_STEP_DONE: 'done', +}; + +function normalizeStep(step: unknown): IPro2NormalizedStep { + if (typeof step !== 'number' && typeof step !== 'string') { + return 'unknown'; + } + return STEP_BY_VALUE[step] ?? 'unknown'; +} + +function normalizeSetupKind( + kind: unknown, +): 'choice' | 'create' | 'restore' | undefined { + switch (kind) { + case OnboardingSetupKind.ONBOARDING_SETUP_KIND_CHOICE: + case 'ONBOARDING_SETUP_KIND_CHOICE': + return 'choice'; + case OnboardingSetupKind.ONBOARDING_SETUP_KIND_CREATE: + case 'ONBOARDING_SETUP_KIND_CREATE': + return 'create'; + case OnboardingSetupKind.ONBOARDING_SETUP_KIND_RESTORE: + case 'ONBOARDING_SETUP_KIND_RESTORE': + return 'restore'; + default: + return undefined; + } +} + +function normalizeSetupMethod( + method: unknown, +): 'recoveryPhrase' | 'seedCard' | undefined { + switch (method) { + case OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_RECOVERY_PHRASE: + case 'ONBOARDING_SETUP_METHOD_RECOVERY_PHRASE': + return 'recoveryPhrase'; + case OnboardingSetupMethod.ONBOARDING_SETUP_METHOD_SEEDCARD: + case 'ONBOARDING_SETUP_METHOD_SEEDCARD': + return 'seedCard'; + default: + return undefined; + } +} + +function isBackupPhase(phase: unknown): boolean { + return [ + OnboardingPhase.ONBOARDING_PHASE_WALLET_READY, + OnboardingPhase.ONBOARDING_PHASE_SEEDCARD_BACKUP_PROMPT, + OnboardingPhase.ONBOARDING_PHASE_SEEDCARD_BACKUP, + 'ONBOARDING_PHASE_WALLET_READY', + 'ONBOARDING_PHASE_SEEDCARD_BACKUP_PROMPT', + 'ONBOARDING_PHASE_SEEDCARD_BACKUP', + ].includes(phase as OnboardingPhase | string); +} + +function mapSetup( + status: IPro2OnboardingStatus, +): IPro2SetupSubStatus | undefined { + const kind = normalizeSetupKind(status.setup?.kind); + const method = normalizeSetupMethod(status.setup?.method); + if (kind === 'choice') { + return { kind: 'choice' }; + } + if (kind === 'create') { + return { + kind: 'create', + card: method === 'seedCard' ? 'seedCard' : 'recoveryPhrase', + }; + } + if (kind === 'restore') { + return { kind: 'restore', ...(method ? { method } : {}) }; + } + return undefined; +} + +export function mapPro2OnboardingStatus( + status: IPro2OnboardingStatus, +): IPro2OnboardingViewState { + const normalizedStep = normalizeStep(status.step); + const pinSet = status.pin_set === true; + const walletInitialized = status.wallet_initialized === true; + const ready = normalizedStep === 'done' && pinSet && walletInitialized; + + if (ready) { + return { + phase: 'ready', + step: EPro2OnboardingStep.Done, + devicePhase: status.phase, + pinSet, + walletInitialized, + ready: true, + }; + } + + switch (normalizedStep) { + case 'personalization': + return { + phase: 'personalization', + step: EPro2OnboardingStep.Personalization, + devicePhase: status.phase, + pinSet, + walletInitialized, + ready: false, + }; + case 'pin': + return { + phase: 'setup', + step: EPro2OnboardingStep.Pin, + devicePhase: status.phase, + pinSet, + walletInitialized, + ready: false, + }; + case 'setup': + return { + phase: isBackupPhase(status.phase) ? 'backup' : 'setup', + step: EPro2OnboardingStep.Setup, + setup: mapSetup(status), + devicePhase: status.phase, + pinSet, + walletInitialized, + ready: false, + }; + case 'checking': + case 'unknown': + case 'done': + default: + return { + phase: 'checking', + step: EPro2OnboardingStep.Checking, + devicePhase: status.phase, + pinSet, + walletInitialized, + ready: false, + }; + } +} diff --git a/packages/kit/src/views/Onboardingv2/utils.tsx b/packages/kit/src/views/Onboardingv2/utils.tsx index e94e9bc1dc40..49f6390e8edb 100644 --- a/packages/kit/src/views/Onboardingv2/utils.tsx +++ b/packages/kit/src/views/Onboardingv2/utils.tsx @@ -3,13 +3,16 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EConnectDeviceChannel } from '@onekeyhq/shared/types/connectDevice'; -import type { IConnectYourDeviceItem } from '@onekeyhq/shared/types/device'; +import type { + IConnectYourDeviceItem, + IOneKeyDeviceFeatures, +} from '@onekeyhq/shared/types/device'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../background/instance/backgroundApiProxy'; import type { IDeviceType } from '@onekeyfe/hd-core'; -import type { EDeviceType } from '@onekeyfe/hd-shared'; +import type { EDeviceType, HardwareConnectProtocol } from '@onekeyfe/hd-shared'; import type { Features } from '@onekeyfe/hd-transport'; // Helper function to convert transport type enum to analytics string @@ -40,6 +43,7 @@ export function getHardwareCommunicationTypeString( // Helper function to map user-selected channel to forced transport type export async function getForceTransportType( channel: EConnectDeviceChannel, + options?: { connectProtocol?: HardwareConnectProtocol }, ): Promise { switch (channel) { case EConnectDeviceChannel.bluetooth: @@ -51,10 +55,10 @@ export async function getForceTransportType( if (platformEnv.isNative) return EHardwareTransportType.BLE; if (platformEnv.isDesktop) { const dev = await backgroundApiProxy.serviceDevSetting.getDevSetting(); - const usbCommunicationMode = dev?.settings?.usbCommunicationMode; - if (usbCommunicationMode === 'bridge') - return EHardwareTransportType.Bridge; - return EHardwareTransportType.WEBUSB; + return deviceUtils.getDesktopUsbTransportType({ + usbCommunicationMode: dev?.settings?.usbCommunicationMode, + connectProtocol: options?.connectProtocol, + }); } // For web/extension, use system setting transport type const currentTransportType = @@ -69,12 +73,15 @@ export async function getForceTransportType( } } -export async function getDesktopForceUSBTransportType(): Promise { +export async function getDesktopForceUSBTransportType(options?: { + connectProtocol?: HardwareConnectProtocol; +}): Promise { if (platformEnv.isDesktop) { const dev = await backgroundApiProxy.serviceDevSetting.getDevSetting(); - const usbCommunicationMode = dev?.settings?.usbCommunicationMode; - if (usbCommunicationMode === 'bridge') return EHardwareTransportType.Bridge; - return EHardwareTransportType.WEBUSB; + return deviceUtils.getDesktopUsbTransportType({ + usbCommunicationMode: dev?.settings?.usbCommunicationMode, + connectProtocol: options?.connectProtocol, + }); } return null; } @@ -123,7 +130,7 @@ export const trackHardwareWalletConnection = async ({ status: 'success' | 'failure'; deviceType: IDeviceType; isSoftwareWalletOnlyUser: boolean; - features?: Features; + features?: IOneKeyDeviceFeatures; hardwareTransportType: EHardwareTransportType | undefined | 'QRCode'; vendor?: EHardwareVendor; }) => { diff --git a/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx new file mode 100644 index 000000000000..e0ac69b6afb0 --- /dev/null +++ b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx @@ -0,0 +1,157 @@ +import { useCallback } from 'react'; + +import { + Button, + ESwitchSize, + Page, + SizableText, + Switch, + XStack, + YStack, +} from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { ListItem } from '@onekeyhq/kit/src/components/ListItem'; +import { useFirmwareUpdateDevSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { IFirmwareUpdateDevSettings } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import type { IPro2FirmwareUpdateTarget } from '@onekeyhq/shared/types/device'; +import { PRO2_FIRMWARE_UPDATE_TARGETS } from '@onekeyhq/shared/types/device'; + +import { FirmwareUpdateActions } from '../Tab/DevSettingsSection/FirmwareUpdateActions'; + +const PRO2_FIRMWARE_UPDATE_TARGET_OPTIONS = PRO2_FIRMWARE_UPDATE_TARGETS.map( + (value) => ({ value, label: value }), +); + +const EMPTY_PRO2_FIRMWARE_UPDATE_TARGETS: IPro2FirmwareUpdateTarget[] = []; + +function Pro2FirmwareUpdateTargetRow({ + target, +}: { + target: (typeof PRO2_FIRMWARE_UPDATE_TARGET_OPTIONS)[number]; +}) { + const [devSetting, setDevSetting] = useFirmwareUpdateDevSettingsPersistAtom(); + const targets = + devSetting.pro2ForceUpdateTargets ?? EMPTY_PRO2_FIRMWARE_UPDATE_TARGETS; + const onceTargets = + devSetting.pro2ForceUpdateOnceTargets ?? EMPTY_PRO2_FIRMWARE_UPDATE_TARGETS; + + const updateTargets = useCallback( + async ({ + nextTargets, + nextOnceTargets, + }: { + nextTargets: IPro2FirmwareUpdateTarget[]; + nextOnceTargets: IPro2FirmwareUpdateTarget[]; + }) => { + const values = { + pro2ForceUpdateTargets: nextTargets, + pro2ForceUpdateOnceTargets: nextOnceTargets, + }; + setDevSetting((previous) => ({ ...previous, ...values })); + await backgroundApiProxy.serviceDevSetting.updateFirmwareUpdateDevSettings( + values, + ); + }, + [setDevSetting], + ); + + const setTargetEnabled = useCallback( + async (enabled: boolean) => { + const nextTargets = enabled + ? Array.from(new Set([...targets, target.value])) + : targets.filter((item) => item !== target.value); + await updateTargets({ nextTargets, nextOnceTargets: onceTargets }); + }, + [onceTargets, target.value, targets, updateTargets], + ); + + const setOnceTargetEnabled = useCallback( + async (enabled: boolean) => { + const nextOnceTargets = enabled + ? Array.from(new Set([...onceTargets, target.value])) + : onceTargets.filter((item) => item !== target.value); + await updateTargets({ nextTargets: targets, nextOnceTargets }); + }, + [onceTargets, target.value, targets, updateTargets], + ); + + return ( + + + + + force + + + + + + once + + + + + + ); +} + +function FirmwareUpdatePro2DevSettings() { + const [devSetting, setDevSetting] = useFirmwareUpdateDevSettingsPersistAtom(); + + const resetPro2ForceTargets = useCallback(async () => { + const values: Pick< + IFirmwareUpdateDevSettings, + 'pro2ForceUpdateTargets' | 'pro2ForceUpdateOnceTargets' + > = { + pro2ForceUpdateTargets: [], + pro2ForceUpdateOnceTargets: [], + }; + setDevSetting((previous) => ({ ...previous, ...values })); + await backgroundApiProxy.serviceDevSetting.updateFirmwareUpdateDevSettings( + values, + ); + }, [setDevSetting]); + + return ( + + + {PRO2_FIRMWARE_UPDATE_TARGET_OPTIONS.map((target) => ( + + ))} + + + + {JSON.stringify(devSetting, null, 2)} + + + ); +} + +export default function PageFirmwareUpdatePro2DevSettings() { + return ( + + + + + ); +} diff --git a/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/index.tsx b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/index.tsx index 174f300158e2..5e5b19a3451d 100644 --- a/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/index.tsx +++ b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/index.tsx @@ -1,3 +1,5 @@ import PageFirmwareUpdateDevSettings from './PageFirmwareUpdateDevSettings'; +export { default as PageFirmwareUpdatePro2DevSettings } from './PageFirmwareUpdatePro2DevSettings'; + export default PageFirmwareUpdateDevSettings; diff --git a/packages/kit/src/views/Setting/pages/Tab/CustomElement.tsx b/packages/kit/src/views/Setting/pages/Tab/CustomElement.tsx index 166eabfd8d71..b830fddaf35d 100644 --- a/packages/kit/src/views/Setting/pages/Tab/CustomElement.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/CustomElement.tsx @@ -73,7 +73,10 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import type { IModalSettingParamList } from '@onekeyhq/shared/src/routes'; import { EModalSettingRoutes, ERootRoutes } from '@onekeyhq/shared/src/routes'; import { EOnboardingV2OneKeyIDLoginMode } from '@onekeyhq/shared/src/routes/onboardingv2'; -import { openUrlExternal } from '@onekeyhq/shared/src/utils/openUrlUtils'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import openUrlUtils, { + openUrlExternal, +} from '@onekeyhq/shared/src/utils/openUrlUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EReasonForNeedPassword } from '@onekeyhq/shared/types/setting'; @@ -315,7 +318,11 @@ export function HardwareTransportTypeListItem(props: ICustomElementProps) { if (platformEnv.isDesktop) { const usb = devPersist?.settings?.usbCommunicationMode; const desktopTransportList: ISelectItem[] = []; - if (usb === 'bridge') { + if ( + deviceUtils.getDesktopUsbTransportType({ + usbCommunicationMode: usb, + }) === EHardwareTransportType.Bridge + ) { desktopTransportList.push({ label: 'Bridge', value: EHardwareTransportType.Bridge, diff --git a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/FirmwareUpdateActions.tsx b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/FirmwareUpdateActions.tsx index 43b334a5be67..9191f98b6ffc 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/FirmwareUpdateActions.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/FirmwareUpdateActions.tsx @@ -50,13 +50,16 @@ function ResetDetectTimeCheck() { } function BootloaderModeUpdateButton() { + const { activeAccount } = useActiveAccount({ num: 0 }); + const connectId = activeAccount.device?.connectId; const [retryInfo] = useFirmwareUpdateRetryAtom(); const actions = useFirmwareUpdateActions(); return (