diff --git a/.github/workflows/startup-graph-budget.yml b/.github/workflows/startup-graph-budget.yml index 8b0f7ee6d7a2..cb58dcc435f7 100644 --- a/.github/workflows/startup-graph-budget.yml +++ b/.github/workflows/startup-graph-budget.yml @@ -80,9 +80,10 @@ jobs: NODE_OPTIONS: '--max_old_space_size=8192' ENABLE_NATIVE_BACKGROUND_THREAD: 'true' ENTRY: background - # background entry currently: ~2314 modules / ~18.34 MB + # background entry currently: ~2377 modules / ~19.21 MB + # (hardware SDK 1.2.0 Pro2/Neo mainline; matches hotfix/v6.5.2's 19.5 budget) STARTUP_MODULE_BUDGET: '2600' - STARTUP_SIZE_BUDGET_MB: '18.4' + STARTUP_SIZE_BUDGET_MB: '19.5' run: node apps/mobile/scripts/check-startup-graph-budget.js - name: Architecture Check (three-bundle rules) diff --git a/.skillshare/skills/1k-retrospective/references/case-studies.md b/.skillshare/skills/1k-retrospective/references/case-studies.md index 59700da77f4c..e2dd1e5bdab3 100644 --- a/.skillshare/skills/1k-retrospective/references/case-studies.md +++ b/.skillshare/skills/1k-retrospective/references/case-studies.md @@ -74,3 +74,9 @@ Cases are appended by AI after each bug fix. Do NOT reorder or delete entries **Root Cause**: `swapTypeSwitchAction` remapped From/To when the Pro target equaled the restored FromToken, but `SwapHeaderContainer` synced the account from the pre-switch `fromToken.networkId` captured in the React closure. **Fix**: Return the settled FromToken from `swapTypeSwitchAction` and sync the account network from that value after the type switch leaves the Pro owner. **Catchable by**: Section 4: Logic moved between files carries its surrounding guard/condition and scope; Section 4: Data flow end-to-end after a state remapping +## Case: BLE pairing dialog shown while device already paired and communicating +**Date**: 2026-08-13 | **Platforms**: Desktop (macOS/Windows desktop BLE) +**Symptom**: Creating a wallet over Bluetooth showed the "Pairing with your device" dialog mid-flow (OK-60091) even though the device was OS-paired and actively communicating on a live Noble session; the dialog's repair then re-scanned and "discovered" the very connectId the caller passed in. +**Root Cause**: `getCompatibleConnectId` triggered the USB→BLE pairing repair purely from DB bookkeeping (device record missing `bleConnectId` — recreated that way by a USB wallet creation after wallet removal deleted the record), never recognizing the caller's incoming connectId as the live BLE endpoint. DB binding state is neither necessary nor sufficient evidence of OS pairing state. +**Fix**: Before the dialog fallback, silently verify and persist the caller-held connectId, gated by runtime evidence: id differs from the record's USB identifiers, carried real device traffic within 60s (stamped by DEVICE.STATE/DEVICE.CONNECT, invalidated on DEVICE.DISCONNECT), probed with silentMode (no global error dialog from error constructors), a bounded 10s timeout, and the session's remembered protocol pinned (forced re-detection sends a V2 Ping into an active V1 session, which the device may not answer — SDK error 713); the probed deviceId must match before persisting. +**Catchable by**: NEW — not covered (interactive dialog triggered from persistence bookkeeping instead of live transport evidence) diff --git a/apps/cli/package.json b/apps/cli/package.json index 3dabb543fb1a..a5437e2930ea 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.176", + "@onekeyfe/hd-core": "1.2.0-alpha.176", + "@onekeyfe/hd-transport-usb": "1.2.0-alpha.176", "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/apps/desktop/app/app.ts b/apps/desktop/app/app.ts index 829981f2f956..d62fe0a515fc 100644 --- a/apps/desktop/app/app.ts +++ b/apps/desktop/app/app.ts @@ -57,6 +57,10 @@ import { import { ipcMessageKeys } from './config'; import { ElectronTranslations, i18nText, initLocale } from './i18n'; import { scheduleCrashDumpCleanup } from './libs/crashDumpCleanup'; +import { + DESKTOP_API_ALLOWED_MODULES, + isDesktopApiMethodAllowed, +} from './libs/desktopApiModuleAllowlist'; import { applyDesktopNetworkThrottleToKnownSessions, applyDesktopNetworkThrottleToWebContents, @@ -73,8 +77,6 @@ import { shouldGrantMainWindowDevicePermission } from './libs/webUsbDeviceSelect import './logger'; import initProcess from './process'; import { setMainWindowForHttpServer } from './process/HttpServer'; -import { logTrezorBleFlags } from './process/trezorBleFlags'; -import { createTrezorBlePairingIpcMain } from './process/trezorBlePairing'; import { createRecoveryWindow } from './recoveryWindow'; import { getAppStaticResourcesPath, @@ -1134,23 +1136,7 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { // New invoke-based handler for contextIsolation-compatible API calls ipcMain.removeHandler('DESKTOP_API_CALL'); - const allowedModules = new Set([ - 'system', - 'security', - 'storage', - 'webview', - 'notification', - 'dev', - 'inAppPurchase', - 'bluetooth', - 'appUpdate', - 'bundleUpdate', - 'cloudKit', - 'keychain', - 'sniRequest', - 'oauthLocalServer', - 'appleAuth', - ]); + const allowedModules = new Set(DESKTOP_API_ALLOWED_MODULES); ipcMain.handle( 'DESKTOP_API_CALL', async ( @@ -1178,14 +1164,7 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { `DESKTOP_API_CALL: unknown module "${module}"`, ); } - // Block inherited prototype methods and private methods - if ( - typeof method !== 'string' || - method.startsWith('_') || - ['constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes( - method, - ) - ) { + if (!isDesktopApiMethodAllowed(module, method)) { throw new OneKeyLocalError( `DESKTOP_API_CALL: disallowed method "${method}"`, ); @@ -1642,6 +1621,7 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE, EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT, + EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE, EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, EOneKeyBleMessageKeys.NOBLE_BLE_WRITE, EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE, @@ -1684,21 +1664,8 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { }, removeHandler: (channel) => ipcMain.removeHandler(channel), }; - logTrezorBleFlags(); initTrezorBleSupport(browserWindow.webContents, { - // Insert Windows OS-pairing at the connect seam (SDK stays untouched): - // caches scan address, runs the WinRT pairing helper before noble connects. - // No-op on non-Windows / builds without the bundled helper. - ipcMain: createTrezorBlePairingIpcMain( - trezorBleSenderGatedIpcMain, - browserWindow, - ), - // NO nobleFactory override. A proxy used to sit here to replay `discover` - // events into the SDK's cache; it blinded noble entirely (the SDK saw zero - // peripherals while a WinRT watcher in another process saw 29 at the same - // moment) and, because it did not forward `connectAsync`, it would also have - // disabled the SDK's connect-by-id fallback. The SDK owns both behaviors as - // of 1.1.32-alpha.1 — let it use plain noble. + ipcMain: trezorBleSenderGatedIpcMain, logger: (entry) => { const message = `[hwk:${entry.scope}] ${entry.event}`; // THP debug payloads can carry handshake packets / pairing credentials / diff --git a/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts b/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts new file mode 100644 index 000000000000..44c2ec0a3dbb --- /dev/null +++ b/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts @@ -0,0 +1,50 @@ +import { + DESKTOP_API_ALLOWED_MODULES, + isDesktopApiMethodAllowed, + isDesktopApiModuleAllowed, +} from './desktopApiModuleAllowlist'; + +describe('desktop API module allowlist', () => { + it('allows the firmware artifact module required by desktop upgrades', () => { + expect(DESKTOP_API_ALLOWED_MODULES).toContain('firmwareArtifact'); + expect(isDesktopApiModuleAllowed('firmwareArtifact')).toBe(true); + }); + + it('rejects modules outside the explicit allowlist', () => { + expect(isDesktopApiModuleAllowed('__proto__')).toBe(false); + }); + + it.each([ + 'getCapabilities', + 'download', + 'cancelDownloads', + 'materialize', + 'open', + 'read', + 'close', + 'createLease', + 'retain', + 'releaseLease', + 'sweepOrphans', + ])('allows firmwareArtifact.%s', (method) => { + expect(isDesktopApiMethodAllowed('firmwareArtifact', method)).toBe(true); + }); + + it.each([ + 'validateDownloadInput', + 'downloadLocked', + 'streamResponseToFile', + 'writeResponseBody', + 'promoteArtifact', + 'resolveArtifactPath', + '__proto__', + 'constructor', + ])('rejects firmwareArtifact.%s', (method) => { + expect(isDesktopApiMethodAllowed('firmwareArtifact', method)).toBe(false); + }); + + it('keeps the legacy method policy for existing modules', () => { + expect(isDesktopApiMethodAllowed('system', 'getSystemInfo')).toBe(true); + expect(isDesktopApiMethodAllowed('system', '_privateMethod')).toBe(false); + }); +}); diff --git a/apps/desktop/app/libs/desktopApiModuleAllowlist.ts b/apps/desktop/app/libs/desktopApiModuleAllowlist.ts new file mode 100644 index 000000000000..08ba5a4c433d --- /dev/null +++ b/apps/desktop/app/libs/desktopApiModuleAllowlist.ts @@ -0,0 +1,68 @@ +export const DESKTOP_API_ALLOWED_MODULES = Object.freeze([ + 'system', + 'security', + 'storage', + 'webview', + 'notification', + 'dev', + 'inAppPurchase', + 'bluetooth', + 'appUpdate', + 'bundleUpdate', + 'cloudKit', + 'keychain', + 'sniRequest', + 'oauthLocalServer', + 'appleAuth', + 'firmwareArtifact', +] as const); + +const DESKTOP_API_ALLOWED_METHODS_BY_MODULE: Readonly< + Partial< + Record<(typeof DESKTOP_API_ALLOWED_MODULES)[number], readonly string[]> + > +> = Object.freeze({ + firmwareArtifact: Object.freeze([ + 'getCapabilities', + 'download', + 'cancelDownloads', + 'materialize', + 'open', + 'read', + 'close', + 'createLease', + 'retain', + 'releaseLease', + 'sweepOrphans', + ]), +}); + +const DESKTOP_API_DISALLOWED_METHODS = new Set([ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', +]); + +export const isDesktopApiModuleAllowed = (module: string): boolean => + DESKTOP_API_ALLOWED_MODULES.includes( + module as (typeof DESKTOP_API_ALLOWED_MODULES)[number], + ); + +export const isDesktopApiMethodAllowed = ( + module: string, + method: unknown, +): boolean => { + if ( + typeof method !== 'string' || + method.startsWith('_') || + DESKTOP_API_DISALLOWED_METHODS.has(method) + ) { + return false; + } + const allowedMethods = + DESKTOP_API_ALLOWED_METHODS_BY_MODULE[ + module as (typeof DESKTOP_API_ALLOWED_MODULES)[number] + ]; + return allowedMethods ? allowedMethods.includes(method) : true; +}; diff --git a/apps/desktop/app/preload.ts b/apps/desktop/app/preload.ts index 1309939b9921..09ae9e54af9c 100644 --- a/apps/desktop/app/preload.ts +++ b/apps/desktop/app/preload.ts @@ -8,9 +8,32 @@ import { OAUTH_CALLBACK_DESKTOP_CHANNEL } from '@onekeyhq/shared/src/consts/auth import { ipcMessageKeys } from './config'; +import type { EBleDisconnectReason } from '@onekeyfe/hd-shared'; import type { NobleBleAPI } from '@onekeyfe/hd-transport-electron'; import type { TrezorBleApi } from '@onekeyfe/hwk-trezor-connector-electron-ble'; +const DESKTOP_BLE_CONNECTED_ONLY_SCOPE_TTL_MS = 150_000; +const desktopBleConnectedOnlyScopes = new Map>(); +let desktopBleConnectedOnlyScopeId = 0; + +function isDesktopBleConnectedOnlyScopeActive(uuid: string) { + const scopes = desktopBleConnectedOnlyScopes.get(uuid); + if (!scopes) { + return false; + } + const now = Date.now(); + for (const [scopeId, expiresAt] of scopes) { + if (expiresAt <= now) { + scopes.delete(scopeId); + } + } + if (!scopes.size) { + desktopBleConnectedOnlyScopes.delete(uuid); + return false; + } + return true; +} + export interface IVerifyUpdateParams { downloadedFile?: string; downloadUrl?: string; @@ -19,6 +42,7 @@ export interface IVerifyUpdateParams { export interface IInstallUpdateParams extends IVerifyUpdateParams { buildNumber: string; + latestVersion?: string; } export type IDesktopEventUnSubscribe = () => void; @@ -230,18 +254,49 @@ const desktopApi = { }, // Desktop Bluetooth nobleBle: { + beginConnectedOnlyScope: (uuid: string) => { + desktopBleConnectedOnlyScopeId += 1; + const scopes = desktopBleConnectedOnlyScopes.get(uuid) ?? new Map(); + scopes.set( + desktopBleConnectedOnlyScopeId, + Date.now() + DESKTOP_BLE_CONNECTED_ONLY_SCOPE_TTL_MS, + ); + desktopBleConnectedOnlyScopes.set(uuid, scopes); + return desktopBleConnectedOnlyScopeId; + }, + endConnectedOnlyScope: (uuid: string, scopeId: number) => { + const scopes = desktopBleConnectedOnlyScopes.get(uuid); + scopes?.delete(scopeId); + if (!scopes?.size) { + desktopBleConnectedOnlyScopes.delete(uuid); + } + }, enumerate: () => ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE), + stopScan: () => + ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN), getDevice: (uuid: string) => ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE, uuid), connect: (uuid: string) => - ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT, uuid), + isDesktopBleConnectedOnlyScopeActive(uuid) + ? Promise.resolve() + : ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT, uuid), + release: (uuid: string, keepSession?: boolean) => + ipcRenderer.invoke( + EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE, + uuid, + keepSession, + ), disconnect: (uuid: string) => - ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, uuid), + isDesktopBleConnectedOnlyScopeActive(uuid) + ? Promise.resolve() + : ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT, uuid), subscribe: (uuid: string) => ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE, uuid), unsubscribe: (uuid: string) => - ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE, uuid), + isDesktopBleConnectedOnlyScopeActive(uuid) + ? Promise.resolve() + : ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE, uuid), write: (uuid: string, data: string) => ipcRenderer.invoke(EOneKeyBleMessageKeys.NOBLE_BLE_WRITE, uuid, data), cancelPairing: () => @@ -261,12 +316,33 @@ const desktopApi = { ); }; }, + onMtuChanged: (callback: (device: { id: string; mtu: number }) => void) => { + const subscription = ( + _: unknown, + device: { id: string; mtu: number }, + ) => { + callback(device); + }; + ipcRenderer.on(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, subscription); + return () => { + ipcRenderer.removeListener( + EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, + subscription, + ); + }; + }, onDeviceDisconnected: ( - callback: (device: { id: string; name: string }) => void, + callback: (device: { + id: string; + name: string; + reason?: EBleDisconnectReason; + }) => void, ) => { + // Forward the payload whole so `reason` reaches the transport, which + // logs it. It does not gate what happens: every link drop is reported. const subscription = ( _: unknown, - device: { id: string; name: string }, + device: { id: string; name: string; reason?: EBleDisconnectReason }, ) => { callback(device); }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5a5a68023a74..dd92b9722b59 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "clean": "rimraf ./build-electron && rimraf .tamagui && rimraf ./app/build && rimraf ./app/dist && rimraf __generated__", "clean:build": "rimraf ./build-electron && rimraf ./app/build && rimraf ./app/dist && rimraf ./node_modules/.cache", "start": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" yarn dev", - "start:rspack": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" yarn dev:rspack", + "start:rspack": "cross-env NODE_OPTIONS=\"--max-old-space-size=16384\" yarn dev:rspack", "install-app-deps": "electron-builder install-app-deps && node scripts/apply-runtime-patches.js && node scripts/verify-runtime-patches.js", "dev": "npx concurrently \"yarn build:main:dev\" \"yarn dev:renderer\" \"cross-env LAUNCH_ELECTRON=true node scripts/dev.js\"", "dev:rspack": "npx concurrently \"yarn build:main:dev\" \"yarn dev:renderer:rspack\" \"cross-env LAUNCH_ELECTRON=true node scripts/dev.js\"", diff --git a/apps/desktop/scripts/electron-updater-runtime-patch.test.js b/apps/desktop/scripts/electron-updater-runtime-patch.test.js index f25e9dcbbcf4..26e067136014 100644 --- a/apps/desktop/scripts/electron-updater-runtime-patch.test.js +++ b/apps/desktop/scripts/electron-updater-runtime-patch.test.js @@ -1,9 +1,12 @@ +const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { AppUpdater } = require('electron-updater/out/AppUpdater'); -const { BaseUpdater } = require('electron-updater/out/BaseUpdater'); +const { + DownloadedUpdateHelper, +} = require('electron-updater/out/DownloadedUpdateHelper'); describe('electron-updater runtime patch', () => { test('resets cached update-check state before a retry', async () => { @@ -85,44 +88,105 @@ describe('electron-updater runtime patch', () => { expect(getOrCreateStagingUserId).toHaveBeenCalledTimes(2); }); - test('rehydrates the persisted installer metadata after an app restart', async () => { + test('rehydrates a cached installer through the upstream validation path', async () => { const cacheDir = fs.mkdtempSync( path.join(os.tmpdir(), 'electron-updater-test-'), ); - const downloadedFileInfo = { - fileName: 'OneKey-Wallet-9906.17.0-win-x64.exe', - sha512: 'sha512-value', - isAdminRightsRequired: false, - }; - const downloadedUpdateHelper = { - cacheDirForPendingUpdate: cacheDir, - updateFile: jest.fn(), - updateDownloadedFileInfo: jest.fn(), - }; - const updater = { - downloadedUpdateHelper, - _logger: { info: jest.fn() }, - }; + const helper = new DownloadedUpdateHelper(cacheDir); + const pendingDir = helper.cacheDirForPendingUpdate; + const fileName = 'OneKey-Wallet-6.6.0-win-x64.exe'; + const installerPath = path.join(pendingDir, fileName); + const installer = 'verified installer'; + const sha512 = crypto + .createHash('sha512') + .update(installer) + .digest('base64'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(installerPath, installer); + fs.writeFileSync( + path.join(pendingDir, 'update-info.json'), + JSON.stringify({ + fileName, + sha512, + isAdminRightsRequired: false, + version: '6.6.0', + }), + ); try { - fs.writeFileSync( - path.join(cacheDir, 'update-info.json'), - JSON.stringify(downloadedFileInfo), - ); + await expect( + helper.validateDownloadedPath( + installerPath, + { version: '6.6.0' }, + { info: { sha512 } }, + { info: jest.fn(), warn: jest.fn() }, + ), + ).resolves.toBe(installerPath); + expect(helper.file).toBe(installerPath); + expect(helper.downloadedFileInfo).toMatchObject({ version: '6.6.0' }); + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); - await BaseUpdater.prototype.updateInstallerPath.call( - updater, - 'C:\\Users\\asus\\AppData\\Local\\OneKey\\pending\\installer.exe', - ); + test('rejects cached metadata for a different trusted feed version', async () => { + const cacheDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'electron-updater-test-'), + ); + const helper = new DownloadedUpdateHelper(cacheDir); + const pendingDir = helper.cacheDirForPendingUpdate; + const fileName = 'OneKey-Wallet.exe'; + const installerPath = path.join(pendingDir, fileName); + const sha512 = crypto + .createHash('sha512') + .update('same installer bytes') + .digest('base64'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync(installerPath, 'same installer bytes'); + fs.writeFileSync( + path.join(pendingDir, 'update-info.json'), + JSON.stringify({ + fileName, + sha512, + isAdminRightsRequired: false, + version: '6.5.0', + }), + ); - expect(downloadedUpdateHelper.updateFile).toHaveBeenCalledWith( - 'C:\\Users\\asus\\AppData\\Local\\OneKey\\pending\\installer.exe', - ); - expect( - downloadedUpdateHelper.updateDownloadedFileInfo, - ).toHaveBeenCalledWith(downloadedFileInfo); + try { + await expect( + helper.validateDownloadedPath( + installerPath, + { version: '6.6.0' }, + { info: { sha512 } }, + { info: jest.fn(), warn: jest.fn() }, + ), + ).resolves.toBeNull(); + expect(helper.file).toBeNull(); + expect(fs.existsSync(installerPath)).toBe(false); } finally { fs.rmSync(cacheDir, { recursive: true, force: true }); } }); + + test('exposes the updater-bound installer path on every updater', () => { + const updater = { + downloadedUpdateHelper: { + file: '/tmp/OneKey-Wallet-verified.zip', + }, + }; + + expect( + AppUpdater.prototype.isInstallerPath.call( + updater, + '/tmp/OneKey-Wallet-verified.zip', + ), + ).toBe(true); + expect( + AppUpdater.prototype.isInstallerPath.call( + updater, + '/tmp/OneKey-Wallet-stale.zip', + ), + ).toBe(false); + }); }); diff --git a/apps/desktop/scripts/verify-runtime-patches.js b/apps/desktop/scripts/verify-runtime-patches.js index ea1bc10d1ab7..a1eae1550e24 100644 --- a/apps/desktop/scripts/verify-runtime-patches.js +++ b/apps/desktop/scripts/verify-runtime-patches.js @@ -66,12 +66,15 @@ if (runtimePackage.version !== workspacePackage.version) { const expectedRuntimePatchMarkers = [ ['out/AppUpdater.js', 'resetForRetry()'], ['out/AppUpdater.js', 'this.emit("update-download-fileInfo", fileInfo);'], - ['out/BaseUpdater.js', 'isExistInstallerPath()'], - ['out/BaseUpdater.js', 'async updateInstallerPath(installerPath)'], - ['out/DownloadedUpdateHelper.js', 'updateFile(file)'], + ['out/AppUpdater.js', 'isInstallerPath(installerPath)'], + ['out/DownloadedUpdateHelper.js', 'version: versionInfo.version'], [ 'out/DownloadedUpdateHelper.js', - 'updateDownloadedFileInfo(downloadedFileInfo)', + 'this._downloadedFileInfo?.version !== updateInfo.version', + ], + [ + 'out/DownloadedUpdateHelper.js', + 'readJson)(updateInfoFilePath, { encoding: "utf8" })', ], ]; for (const [relativePath, marker] of expectedRuntimePatchMarkers) { diff --git a/apps/ext/src/offscreen/offscreenSetup.test.ts b/apps/ext/src/offscreen/offscreenSetup.test.ts new file mode 100644 index 000000000000..fd8a2ab18fcc --- /dev/null +++ b/apps/ext/src/offscreen/offscreenSetup.test.ts @@ -0,0 +1,53 @@ +import { bridgeSetup } from '@onekeyfe/extension-bridge-hosted'; + +import appGlobals from '@onekeyhq/shared/src/appGlobals'; + +import { offscreenSetup } from './offscreenSetup'; + +jest.mock('@onekeyfe/extension-bridge-hosted', () => ({ + bridgeSetup: { + offscreen: { createOffscreenJsBridge: jest.fn() }, + }, +})); + +jest.mock('@onekeyhq/kit-bg/src/offscreens/instance/offscreenApi', () => ({ + __esModule: true, + default: { callOffscreenApiMethod: jest.fn() }, +})); + +jest.mock('@onekeyhq/shared/src/appGlobals', () => ({ + __esModule: true, + default: {}, +})); + +describe('offscreenSetup', () => { + it('recreates the offscreen bridge after its background port disconnects', () => { + jest.useFakeTimers(); + const bridges = [{ id: 1 }, { id: 2 }]; + const createOffscreenJsBridge = jest.mocked( + bridgeSetup.offscreen.createOffscreenJsBridge, + ); + createOffscreenJsBridge + .mockReturnValueOnce(bridges[0] as never) + .mockReturnValueOnce(bridges[1] as never); + + expect(offscreenSetup()).toBe(bridges[0]); + expect(appGlobals.extJsBridgeOffscreenToBg).toBe(bridges[0]); + + const addDisconnectListener = jest.fn(); + const firstConfig = createOffscreenJsBridge.mock.calls[0]?.[0]; + firstConfig?.onPortConnect({ + onDisconnect: { addListener: addDisconnectListener }, + } as never); + const onDisconnect = addDisconnectListener.mock.calls[0]?.[0] as + | (() => void) + | undefined; + onDisconnect?.(); + jest.advanceTimersByTime(100); + + expect(createOffscreenJsBridge).toHaveBeenCalledTimes(2); + expect(appGlobals.extJsBridgeOffscreenToBg).toBe(bridges[1]); + + jest.useRealTimers(); + }); +}); diff --git a/apps/ext/src/offscreen/offscreenSetup.ts b/apps/ext/src/offscreen/offscreenSetup.ts index fd211155c756..964be5c3bbb2 100644 --- a/apps/ext/src/offscreen/offscreenSetup.ts +++ b/apps/ext/src/offscreen/offscreenSetup.ts @@ -9,9 +9,22 @@ import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import type { JsBridgeBase } from '@onekeyfe/cross-inpage-provider-core'; -export function offscreenSetup() { +let reconnectTimer: ReturnType | undefined; + +const createOffscreenBridge = () => { const offscreenBridge = bridgeSetup.offscreen.createOffscreenJsBridge({ - onPortConnect() {}, + onPortConnect(port) { + port.onDisconnect.addListener(() => { + if (reconnectTimer) { + return; + } + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; + appGlobals.extJsBridgeOffscreenToBg = + createOffscreenBridge() as unknown as JsBridgeBase; + }, 100); + }); + }, async receiveHandler(payload, bridge) { const msg = payload.data as IOffscreenApiMessagePayload | undefined; if (msg && msg.type === OFFSCREEN_API_MESSAGE_TYPE) { @@ -22,6 +35,12 @@ export function offscreenSetup() { }, }); + return offscreenBridge; +}; + +export function offscreenSetup() { + const offscreenBridge = createOffscreenBridge(); + appGlobals.extJsBridgeOffscreenToBg = offscreenBridge as unknown as JsBridgeBase; return offscreenBridge; diff --git a/apps/mobile/ios/AppDelegate.swift b/apps/mobile/ios/AppDelegate.swift index ed528e2656a7..d4bc2e9bf3ca 100644 --- a/apps/mobile/ios/AppDelegate.swift +++ b/apps/mobile/ios/AppDelegate.swift @@ -249,16 +249,8 @@ public class AppDelegate: ExpoAppDelegate { } // Background URLSession events (concurrent/background downloads). - // When the app is relaunched in the background to finish a background - // download, hand the completion handler to the downloader via a notification. - // We post rather than call directly because the Nitro module's C++ umbrella - // header can't be imported into this Swift AppDelegate (see the - // NSClassFromString bridges above). If the downloader instance isn't live yet - // the events are processed on the next foreground launch instead — the - // download itself still completed in the background. - // // Posted under a generic name (RangeDownloaderBackgroundEvents) so any number - // of channels (bundle / apk / chart) route through one notification; the + // of channels route through one notification; the // shared range-downloader filters by its own session identifier prefix (and // still recognizes the legacy identifier prefix for in-flight downloads that // span an app update). diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index e6bf9d73a635..1ce41a5d869b 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -4006,7 +4006,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.78): + - ReactNativeRangeDownloader (3.0.81-alpha.11): - boost - DoubleConversion - fast_float @@ -4036,6 +4036,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeNativeLogger - SocketRocket + - SSZipArchive (= 2.5.5) - Yoga - ReactNativeSplashScreen (3.0.78): - boost @@ -4738,7 +4739,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SniConnect (3.0.78): + - SniConnect (3.0.81-alpha.9): - boost - DoubleConversion - EMASCurl (= 1.5.5) @@ -5595,7 +5596,7 @@ SPEC CHECKSUMS: ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd ReactNativePerfMemory: 7ef4df212ac2a19e5125deb9f2067ee110235e48 ReactNativePerfStats: d1368e3a14b5387dea7cc87bca9a566810636f5e - ReactNativeRangeDownloader: fb71689f6c2ccf99ad63b59411a84ee1581bc7ac + ReactNativeRangeDownloader: 93e90ff445d0404cb97a6eea7a617b4b4b209c12 ReactNativeSplashScreen: 0bc82cdce113b2f60366b3d0f2a495ff86e13022 ReactNativeZipArchive: bb4a2b338281c0166bee97142bf59ef9cd124c62 RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 @@ -5620,7 +5621,7 @@ SPEC CHECKSUMS: SegmentSlider: e3507345e9bf6a48fd301181765d056a70420512 Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 Skeleton: e04f3e3d91865cdb03ef256a095d128688acf3fb - SniConnect: 4093cf48264f2b78081f8cfa8482d7a6453803c5 + SniConnect: eb28a03028065e205739894cb10be4c80b8cabd5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a01f9afbb7d7..7f785a4985b5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,6 +14,7 @@ "ios:build": "cd ios && xcodebuild -workspace OneKeyWallet.xcworkspace -scheme OneKeyWallet -configuration Release -archivePath ./build/OneKeyWallet.xcarchive", "ios:pod-install": "cd ios && pod install && cd ..", "native-bundle": "ENABLE_NATIVE_BACKGROUND_THREAD=true NODE_OPTIONS='--max_old_space_size=8192' WITH_ROZENITE=true react-native start", + "native-bundle:bg": "ENABLE_NATIVE_BACKGROUND_THREAD=true NODE_OPTIONS='--max_old_space_size=8192' WITH_ROZENITE=true react-native start --port 8082", "storybook": "NODE_OPTIONS='--max_old_space_size=8192' STORYBOOK_ENABLED=true expo start", "detox:build:ios:sim:debug": "detox build -c ios.sim.debug", "detox:test:ios:sim:debug": "detox test -c ios.sim.debug", @@ -75,11 +76,11 @@ "@onekeyfe/react-native-perf-memory": "3.0.78", "@onekeyfe/react-native-perf-stats": "3.0.78", "@onekeyfe/react-native-perp-depth-bar": "3.0.78", - "@onekeyfe/react-native-range-downloader": "3.0.78", + "@onekeyfe/react-native-range-downloader": "3.0.81-alpha.11", "@onekeyfe/react-native-scroll-guard": "3.0.78", "@onekeyfe/react-native-segment-slider": "3.0.78", "@onekeyfe/react-native-skeleton": "3.0.78", - "@onekeyfe/react-native-sni-connect": "3.0.78", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.9", "@onekeyfe/react-native-splash-screen": "3.0.78", "@onekeyfe/react-native-split-bundle-loader": "3.0.78", "@onekeyfe/react-native-tab-view": "3.0.78", diff --git a/apps/mobile/src/backgroundThread/rpcProtocol.test.ts b/apps/mobile/src/backgroundThread/rpcProtocol.test.ts index 0318a3fedef7..9906ac25169b 100644 --- a/apps/mobile/src/backgroundThread/rpcProtocol.test.ts +++ b/apps/mobile/src/backgroundThread/rpcProtocol.test.ts @@ -1,3 +1,4 @@ +import { isOneKeyHardwareError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import { LOCAL_SECRET_ENVELOPE_CREDENTIAL_ERROR_DATA_TYPE, LOCAL_SECRET_ENVELOPE_ERROR_DATA_TYPE_FIELD, @@ -14,7 +15,7 @@ import { } from './rpcProtocol'; describe('background thread RPC protocol', () => { - it('preserves error payload metadata across response serialization', () => { + it('preserves hardware error identity across response serialization', () => { const payload = { connectId: 'CE:1F:0C:F1:CA:A9', deviceId: 'device-1', @@ -23,10 +24,11 @@ describe('background thread RPC protocol', () => { }, }; const error = { - name: 'OneKeyHardwareError', - message: 'Please enable Passphrase', - className: 'DeviceNotOpenedPassphrase', - code: 801, + name: 'DeviceNotFound', + message: 'Device not found', + className: 'DeviceNotFound', + $isHardwareError: true, + code: 710, data: { [LOCAL_SECRET_ENVELOPE_ERROR_DATA_TYPE_FIELD]: LOCAL_SECRET_ENVELOPE_CREDENTIAL_ERROR_DATA_TYPE, @@ -46,6 +48,8 @@ describe('background thread RPC protocol', () => { [LOCAL_SECRET_ENVELOPE_ERROR_DATA_TYPE_FIELD]: LOCAL_SECRET_ENVELOPE_CREDENTIAL_ERROR_DATA_TYPE, }); + expect(response?.error?.$isHardwareError).toBe(true); + expect(isOneKeyHardwareError(response?.error)).toBe(true); }); it('keeps only the LSE marker from background error data', () => { diff --git a/apps/mobile/src/backgroundThread/rpcProtocol.ts b/apps/mobile/src/backgroundThread/rpcProtocol.ts index 06777eee14c2..423128f9460c 100644 --- a/apps/mobile/src/backgroundThread/rpcProtocol.ts +++ b/apps/mobile/src/backgroundThread/rpcProtocol.ts @@ -98,6 +98,7 @@ export type IBackgroundThreadResponseErrorPayload = { // Preserve OneKeyError metadata across RPC so toast/i18n/dedup keep working. autoToast?: boolean; className?: string; + $isHardwareError?: boolean; code?: string | number; key?: string; requestId?: string; diff --git a/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.ts b/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.ts index 7366e432bfae..322445b54431 100644 --- a/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.ts +++ b/apps/mobile/src/backgroundThread/setupBackgroundThreadRPCHandler.ts @@ -142,6 +142,7 @@ function buildErrorPayload(error: unknown) { const runtimeError = error as Error & { autoToast?: unknown; className?: unknown; + $isHardwareError?: unknown; code?: unknown; key?: unknown; requestId?: unknown; @@ -156,6 +157,7 @@ function buildErrorPayload(error: unknown) { stack?: string; autoToast?: boolean; className?: string; + $isHardwareError?: boolean; code?: string | number; key?: string; requestId?: string; @@ -176,6 +178,9 @@ function buildErrorPayload(error: unknown) { if (typeof runtimeError?.className === 'string') { errorPayload.className = runtimeError.className; } + if (runtimeError?.$isHardwareError === true) { + errorPayload.$isHardwareError = true; + } if ( typeof runtimeError?.code === 'string' || typeof runtimeError?.code === 'number' diff --git a/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.ts b/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.ts index 7603f07e49a9..67faf8ab81d3 100644 --- a/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.ts +++ b/apps/mobile/src/backgroundThread/setupMainThreadBackgroundRunner.ts @@ -963,6 +963,7 @@ function handleBackgroundThreadResponse( stack?: string; autoToast?: boolean; className?: string; + $isHardwareError?: boolean; code?: string | number; key?: string; requestId?: string; @@ -985,6 +986,9 @@ function handleBackgroundThreadResponse( if (typeof errorInfo?.className === 'string') { error.className = errorInfo.className; } + if (errorInfo?.$isHardwareError === true) { + error.$isHardwareError = true; + } if ( typeof errorInfo?.code === 'string' || typeof errorInfo?.code === 'number' diff --git a/development/debug-hardware-sdk.js b/development/debug-hardware-sdk.js deleted file mode 100644 index 2ad6e6455c3b..000000000000 --- a/development/debug-hardware-sdk.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @file debug-hardware-sdk.js - * hardware sdk debug script - * hardware sdk publish script: yarn publish:yalc - * - * example: yarn debug:hardware-sdk -v 0.2.40 - */ -const { exec, execSync } = require('child_process'); - -const argv = require('minimist')(process.argv.slice(2)); - -const LIB_VERSION = argv.v || 'latest'; - -// Check whether yalc is installed -exec('which yalc', (error) => { - if (error) { - // If yalc is not installed, run the installation command - console.log('yalc not installed, start installing...'); - installYalc(); - return; - } - - console.log('yalc installed, start adding libraries...'); - addLibrary(); -}); - -const needDependenceLibrary = [ - 'hd-core', - 'hd-ble-sdk', - 'hd-transport', - 'hd-web-sdk', - 'hd-shared', -]; - -/** - * Dependence Hardware SDK - */ -function addLibrary() { - needDependenceLibrary.forEach((library) => { - try { - execSync(`yalc add @onekeyfe/${library}@${LIB_VERSION}`); - console.log(`add @onekeyfe/${library}@${LIB_VERSION} Done`); - } catch (error) { - console.error(`An error occurred while executing the command: ${error}`); - } - }); -} - -/** - * install yalc - */ -function installYalc() { - exec('npm install -g yalc', (error) => { - if (error) { - console.error(`An error occurred while executing the command: ${error}`); - return; - } - console.log('yalc installed, start adding libraries...'); - addLibrary(); - }); -} diff --git a/development/perf-ci/thresholds/web.cold.json b/development/perf-ci/thresholds/web.cold.json index aef2b2cfdead..b67889297926 100644 --- a/development/perf-ci/thresholds/web.cold.json +++ b/development/perf-ci/thresholds/web.cold.json @@ -10,7 +10,7 @@ }, "startupGraph": { "moduleCount": 3300, - "sourceSizeBytes": 13107200, + "sourceSizeBytes": 13172736, "initialScriptCount": 45, "initialScriptRawBytes": 6291456, "initialScriptGzipBytes": 2097152, @@ -50,7 +50,7 @@ "swap": { "resourceCount": 220, "scriptCount": 169, - "jsDecodedBytes": 17458790, + "jsDecodedBytes": 17475174, "longTaskTotalMs": 1200 }, "defi": { diff --git a/development/spellCheckerSkipWords.txt b/development/spellCheckerSkipWords.txt index c0d5d4d2ebc0..c990d99e9c82 100644 --- a/development/spellCheckerSkipWords.txt +++ b/development/spellCheckerSkipWords.txt @@ -801,6 +801,7 @@ sompi Sompi Sparkline splitter +Stax sr25519 starcoin Starcoin 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..7d43fee30567 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "app:web-embed": "yarn workspace @onekeyhq/web-embed start", "app:web-embed:build": "yarn workspace @onekeyhq/web-embed build", "app:playground": "yarn workspace @onekeyhq/playground sb:dev", - "app:native-bundle": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" yarn workspace @onekeyhq/mobile native-bundle", + "app:native-bundle": "cross-env ENABLE_NATIVE_BACKGROUND_THREAD=true NODE_OPTIONS=\"--max-old-space-size=8192\" concurrently --kill-others-on-fail \"yarn workspace @onekeyhq/mobile native-bundle\" \"yarn workspace @onekeyhq/mobile native-bundle:bg\"", + "app:native-bundle:bg": "cross-env ENABLE_NATIVE_BACKGROUND_THREAD=true NODE_OPTIONS=\"--max-old-space-size=8192\" yarn workspace @onekeyhq/mobile native-bundle:bg", "app:split-bundle": "yarn workspace @onekeyhq/mobile split-bundle", "app:build-bundle": "yarn workspace @onekeyhq/mobile build-bundle", "app:build-bundle:ios": "yarn workspace @onekeyhq/mobile build-bundle:ios", @@ -159,21 +160,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.176", + "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.176", + "@onekeyfe/hd-core": "1.2.0-alpha.176", + "@onekeyfe/hd-shared": "1.2.0-alpha.176", + "@onekeyfe/hd-transport": "1.2.0-alpha.176", + "@onekeyfe/hd-transport-electron": "1.2.0-alpha.176", + "@onekeyfe/hd-web-sdk": "1.2.0-alpha.176", + "@onekeyfe/hwk-adapter-core": "1.2.0-alpha.176", + "@onekeyfe/hwk-ledger-adapter": "1.2.0-alpha.176", + "@onekeyfe/hwk-ledger-connector-ble": "1.2.0-alpha.176", + "@onekeyfe/hwk-ledger-connector-webhid": "1.2.0-alpha.176", + "@onekeyfe/hwk-trezor-adapter": "1.2.0-alpha.176", + "@onekeyfe/hwk-trezor-connector-electron-ble": "1.2.0-alpha.176", + "@onekeyfe/hwk-trezor-connector-rn-ble": "1.2.0-alpha.176", + "@onekeyfe/hwk-trezor-connector-webusb": "1.2.0-alpha.176", "@onekeyfe/onekey-cross-webview": "2.2.73", "@polkadot/extension-inject": "0.54.1", "@polkadot/types": "14.3.1", @@ -316,6 +317,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 +417,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.176", + "@onekeyfe/hd-shared": "1.2.0-alpha.176", + "@onekeyfe/hd-transport": "1.2.0-alpha.176", + "@onekeyfe/hd-transport-http": "1.2.0-alpha.176", + "@onekeyfe/hd-transport-web-device": "1.2.0-alpha.176", "promise": "^8.3.0", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", diff --git a/packages/components/src/composite/SegmentSlider/index.native.tsx b/packages/components/src/composite/SegmentSlider/index.native.tsx index 9f82e5643fd4..733a83121044 100644 --- a/packages/components/src/composite/SegmentSlider/index.native.tsx +++ b/packages/components/src/composite/SegmentSlider/index.native.tsx @@ -81,6 +81,8 @@ export interface ISegmentSliderProps { max?: number; disabled?: boolean; showBubble?: boolean; + /** Web-only alignment option. Native segment geometry remains evenly spaced. */ + alignSegmentMarksToIntegerValues?: boolean; /** * When true, the slider fills from center (0) instead of left edge. * Negative values fill left from center, positive values fill right from center. diff --git a/packages/components/src/composite/SegmentSlider/index.test.tsx b/packages/components/src/composite/SegmentSlider/index.test.tsx new file mode 100644 index 000000000000..f18aa9e95f50 --- /dev/null +++ b/packages/components/src/composite/SegmentSlider/index.test.tsx @@ -0,0 +1,44 @@ +/** + * @jest-environment jsdom + */ + +import { SegmentSlider } from '.'; + +import { fireEvent, render } from '@testing-library/react'; + +jest.mock('../../hooks/useStyle', () => ({ + useTheme: () => ({ + bgPrimary: { val: '#ffffff' }, + neutral5: { val: '#555555' }, + bg: { val: '#000000' }, + borderStrong: { val: '#777777' }, + borderActive: { val: '#999999' }, + }), +})); + +describe('SegmentSlider integer-aligned segment marks', () => { + it('moves through the same integer values used by the brightness marks', () => { + const onChange = jest.fn(); + const { getByRole } = render( + , + ); + const slider = getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(onChange).toHaveBeenNthCalledWith(1, 32); + expect(onChange).toHaveBeenNthCalledWith(2, 55); + expect(onChange).toHaveBeenNthCalledWith(3, 78); + expect(onChange).toHaveBeenNthCalledWith(4, 100); + }); +}); diff --git a/packages/components/src/composite/SegmentSlider/index.tsx b/packages/components/src/composite/SegmentSlider/index.tsx index fec177450593..4bcc2d432f64 100644 --- a/packages/components/src/composite/SegmentSlider/index.tsx +++ b/packages/components/src/composite/SegmentSlider/index.tsx @@ -21,6 +21,15 @@ const MARK_HIT_AREA = 24; const DEFAULT_TRACK_HEIGHT = 4; const HIT_AREA_HEIGHT = 24; +// Keep half-step marks symmetric when the slider emits integer values. +function roundHalfToEven(value: number) { + const lower = Math.floor(value); + if (value - lower !== 0.5) { + return Math.round(value); + } + return lower % 2 === 0 ? lower : lower + 1; +} + export interface ISegmentSliderProps { value: number; sliderHeight?: number; @@ -36,6 +45,8 @@ export interface ISegmentSliderProps { max?: number; disabled?: boolean; showBubble?: boolean; + /** Aligns fractional segment marks to the integer values emitted by the web slider. */ + alignSegmentMarksToIntegerValues?: boolean; /** * When true, the slider fills from center (0) instead of left edge. * Negative values fill left from center, positive values fill right from @@ -166,6 +177,7 @@ function SegmentSliderComponent({ max = 100, disabled = false, showBubble = true, + alignSegmentMarksToIntegerValues = false, centerOrigin = false, }: ISegmentSliderProps) { const trackRef = useRef(null); @@ -208,14 +220,36 @@ function SegmentSliderComponent({ const centerPct = useMemo(() => valueToPct(0), [valueToPct]); + const segmentIndexToValue = useCallback( + (index: number) => { + const segmentValue = min + index * stepValue; + return alignSegmentMarksToIntegerValues + ? roundHalfToEven(segmentValue) + : Math.round(segmentValue); + }, + [alignSegmentMarksToIntegerValues, min, stepValue], + ); + + const segmentIndexToPct = useCallback( + (index: number) => + alignSegmentMarksToIntegerValues + ? valueToPct(segmentIndexToValue(index)) + : (index / segments) * 100, + [ + alignSegmentMarksToIntegerValues, + segmentIndexToValue, + segments, + valueToPct, + ], + ); + const applyMarkActiveStates = useCallback( (v: number) => { if (!hasSegments) return; - const total = segments; const valuePct = valueToPct(v); markRefs.current.forEach((el, idx) => { if (!el) return; - const markPct = (idx / total) * 100; + const markPct = segmentIndexToPct(idx); let active = false; if (centerOrigin) { if (v === 0) { @@ -236,8 +270,8 @@ function SegmentSliderComponent({ }, [ hasSegments, - segments, valueToPct, + segmentIndexToPct, centerOrigin, centerPct, bgPrimary, @@ -329,14 +363,16 @@ function SegmentSliderComponent({ const emit = useCallback( (rawValue: number) => { const snapped = snap(rawValue); - const rounded = Math.round(snapped); + const rounded = alignSegmentMarksToIntegerValues + ? roundHalfToEven(snapped) + : Math.round(snapped); applyVisual(rounded); if (rounded !== lastEmittedRef.current) { lastEmittedRef.current = rounded; onChange(rounded); } }, - [snap, onChange, applyVisual], + [alignSegmentMarksToIntegerValues, snap, onChange, applyVisual], ); const setBubbleVisible = useCallback((visible: boolean) => { @@ -348,14 +384,14 @@ function SegmentSliderComponent({ const snapToStep = useCallback( (idx: number) => { if (!hasSegments) return; - const snapped = Math.round(min + idx * stepValue); + const snapped = segmentIndexToValue(idx); applyVisual(snapped); if (snapped !== lastEmittedRef.current) { lastEmittedRef.current = snapped; onChange(snapped); } }, - [hasSegments, min, stepValue, applyVisual, onChange], + [hasSegments, segmentIndexToValue, applyVisual, onChange], ); const handlePointerDown = useCallback( @@ -460,10 +496,26 @@ function SegmentSliderComponent({ } if (delta !== 0) { e.preventDefault(); - emit(lastEmittedRef.current + delta); + if (alignSegmentMarksToIntegerValues && hasSegments) { + const currentIndex = Math.round( + (lastEmittedRef.current - min) / stepValue, + ); + const nextIndex = currentIndex + (delta > 0 ? 1 : -1); + emit(min + nextIndex * stepValue); + } else { + emit(lastEmittedRef.current + delta); + } } }, - [disabled, hasSegments, stepValue, emit, min, max], + [ + disabled, + alignSegmentMarksToIntegerValues, + hasSegments, + stepValue, + emit, + min, + max, + ], ); const handleFocus = useCallback((e: React.FocusEvent) => { @@ -622,7 +674,7 @@ function SegmentSliderComponent({ ('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..d907cd82535b --- /dev/null +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.deviceState.test.ts @@ -0,0 +1,875 @@ +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('repairs a canonical Pro 2 wallet name polluted by a compact BLE advertisement', 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: 'Pro 2 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('persists the complete SDK settings snapshot after a settings read', async () => { + const current = createState({ + revision: 1, + updatedAt: 100, + label: 'My Pro 2', + language: 'en-US', + }); + current.settings.brightness = 30; + current.settings.autoLockDelayMs = 60_000; + + const incoming = createState({ + revision: 2, + updatedAt: 200, + label: 'My Pro 2', + language: 'en-US', + }); + incoming.settings.brightness = 70; + incoming.settings.autoLockDelayMs = 300_000; + const db = new DeviceStateTestLocalDb(current); + + await db.updateDeviceState({ + connectId: 'ABC-DEF', + state: incoming, + revision: incoming.revision, + source: 'settings-read', + changedKeys: ['settings.brightness'], + }); + + const persisted = JSON.parse(db.device.deviceState || '{}'); + expect(persisted.settings.brightness).toBe(70); + expect(persisted.settings.autoLockDelayMs).toBe(300_000); + }); + + 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.test.ts b/packages/kit-bg/src/dbs/local/LocalDbBase.test.ts index 65ac10464bbb..6c271f839ad6 100644 --- a/packages/kit-bg/src/dbs/local/LocalDbBase.test.ts +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.test.ts @@ -1,3 +1,4 @@ +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import { IDBFactory } from 'fake-indexeddb'; import { @@ -60,6 +61,7 @@ import type { ILocalDBTxAddRecordsResult, ILocalDBTxGetAllRecordsParams, ILocalDBTxGetAllRecordsResult, + ILocalDBTxRemoveRecordsParams, ILocalDBTxUpdateRecordsParams, } from './types'; @@ -67,6 +69,8 @@ jest.setTimeout(120_000); function buildNoopSyncManager() { return { + buildSyncTargetByDBQuery: jest.fn(async () => ({})), + buildSyncKeyAndPayload: jest.fn(async () => undefined), buildExistingSyncItemsInfo: jest.fn(async () => ({ existingSyncItems: {}, newSyncItems: {}, @@ -101,8 +105,15 @@ class TestLocalDb extends LocalDbBase { credentials: IDBCredentialBase[] = []; + removedDeviceIds: string[] = []; + addHDNextIndexedAccountCalls = 0; + buildCreateResultCalls: { + walletId: string; + withoutRefillWallet?: boolean; + }[] = []; + constructor() { super(); @@ -243,6 +254,12 @@ class TestLocalDb extends LocalDbBase { return { ...wallet }; } + override async getAllWallets(): Promise<{ wallets: IDBWallet[] }> { + return { + wallets: this.wallets.map((wallet) => ({ ...wallet })), + }; + } + override async getRecordsByIds({ name, ids, @@ -269,6 +286,16 @@ class TestLocalDb extends LocalDbBase { }: ILocalDBTxGetAllRecordsParams): Promise< ILocalDBTxGetAllRecordsResult > { + if (name === ELocalDBStoreNames.Wallet) { + const records = this.wallets.map((wallet) => ({ ...wallet })); + return { + records: records as ILocalDBTxGetAllRecordsResult['records'], + recordPairs: records.map((record) => [ + record, + null, + ]) as ILocalDBTxGetAllRecordsResult['recordPairs'], + }; + } if (name === ELocalDBStoreNames.Credential) { const records = this.credentials.map((credential) => ({ ...credential, @@ -335,11 +362,30 @@ class TestLocalDb extends LocalDbBase { return undefined; } + override async txRemoveRecords({ + name, + ids = [], + recordPairs = [], + }: ILocalDBTxRemoveRecordsParams): Promise { + const targetIds = [...ids, ...recordPairs.map(([record]) => record.id)]; + if (name === ELocalDBStoreNames.Wallet) { + this.wallets = this.wallets.filter( + (wallet) => !targetIds.includes(wallet.id), + ); + } + if (name === ELocalDBStoreNames.Device) { + this.removedDeviceIds.push(...targetIds); + } + } + override async buildCreateHDAndHWWalletResult({ walletId, + withoutRefillWallet, }: { walletId: string; + withoutRefillWallet?: boolean; }) { + this.buildCreateResultCalls.push({ walletId, withoutRefillWallet }); return { wallet: this.wallets.find((wallet) => wallet.id === walletId)!, indexedAccount: undefined, @@ -492,6 +538,47 @@ async function buildLegacyLocalSecretEnvelopeVerifyString({ }); } +describe('LocalDbBase.removeWallet hardware device lifecycle', () => { + const buildHardwareWallet = (): IDBWallet => ({ + id: 'hw-wallet-1', + name: 'OneKey Pro 2', + type: 'hw', + backuped: true, + accounts: [], + nextIds: {}, + associatedDevice: 'device-1', + walletNo: 1, + }); + + it('retains a mocked standard wallet as the hidden-wallet device proxy', async () => { + const db = new TestLocalDb(); + db.wallets = [buildHardwareWallet()]; + + await db.removeWallet({ + walletId: 'hw-wallet-1', + isRemoveToMocked: true, + }); + + expect(db.wallets).toEqual([ + expect.objectContaining({ + id: 'hw-wallet-1', + isMocked: true, + }), + ]); + expect(db.removedDeviceIds).toEqual([]); + }); + + it('deletes the wallet and device record when the device is removed', async () => { + const db = new TestLocalDb(); + db.wallets = [buildHardwareWallet()]; + + await db.removeWallet({ walletId: 'hw-wallet-1' }); + + expect(db.wallets).toEqual([]); + expect(db.removedDeviceIds).toEqual(['device-1']); + }); +}); + describe('LocalDbBase.createHDWallet', () => { it('keeps nextHD stable while preserving unique walletNo for override wallet ids', async () => { const db = new TestLocalDb(); @@ -577,6 +664,74 @@ describe('LocalDbBase.createHDWallet', () => { }); }); +describe('LocalDbBase.createHwWallet', () => { + it('returns the persisted wallet before refill for label synchronization', async () => { + const db = new TestLocalDb(); + db.wallets = [ + { + id: 'hw-device-db-1', + name: 'Previous device name', + type: 'hw', + backuped: true, + accounts: [], + nextIds: {}, + associatedDevice: 'device-db-1', + walletNo: 1, + }, + ]; + jest.spyOn(db, 'buildHwWalletId').mockResolvedValue({ + dbDeviceId: 'device-db-1', + dbWalletId: 'hw-device-db-1', + deviceUUID: 'PRO2_SERIAL', + rawDeviceId: 'PRO2_DEVICE_ID', + }); + jest.spyOn(db, 'timeNow').mockResolvedValue(1); + + const result = await db.createHwWallet({ + device: { + connectId: 'PRO2_USB', + uuid: 'PRO2_SERIAL', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Pro2, + name: 'Pro2 6136', + }, + features: { + label: 'Current device name', + bleName: 'Pro2 6136', + deviceType: EDeviceType.Pro2, + deviceId: 'PRO2_DEVICE_ID', + serialNo: 'PRO2_SERIAL', + } as never, + deviceState: { + schemaVersion: 1, + revision: 1, + updatedAt: 1, + protocol: 'V2', + identity: { + deviceType: EDeviceType.Pro2, + firmwareType: EFirmwareType.Universal, + model: 'pro2', + vendor: 'onekey.so', + deviceId: 'PRO2_DEVICE_ID', + serialNo: 'PRO2_SERIAL', + label: 'Current device name', + bleName: 'Pro2 6136', + }, + status: { mode: 'normal' }, + settings: {}, + versions: {}, + capabilities: [], + } as never, + }); + + expect(result.wallet.name).toBe('Previous device name'); + expect(db.buildCreateResultCalls.at(-1)).toEqual({ + walletId: 'hw-device-db-1', + withoutRefillWallet: true, + }); + }); +}); + describe('LocalDbBase local secret envelope credentials', () => { it('starts post-password lazy upgrade after getContext verifies the password', async () => { const db = new TestLocalDb(); diff --git a/packages/kit-bg/src/dbs/local/LocalDbBase.trezor.test.ts b/packages/kit-bg/src/dbs/local/LocalDbBase.trezor.test.ts index 3154a5866826..0a928d140730 100644 --- a/packages/kit-bg/src/dbs/local/LocalDbBase.trezor.test.ts +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.trezor.test.ts @@ -1,4 +1,3 @@ -import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; @@ -7,8 +6,8 @@ import { buildThirdPartyFeaturesInfoFromDevice, buildTrezorDesktopBleUsbConnectId, clearTrezorThpSettingsRaw, - getThirdPartyDeviceAvatarImage, getThirdPartyDeviceModelName, + resolveBleConnectIdForCreate, } from './LocalDbBase'; describe('clearTrezorThpSettingsRaw', () => { @@ -219,36 +218,6 @@ describe('getThirdPartyDeviceModelName', () => { }); }); -describe('getThirdPartyDeviceAvatarImage', () => { - it.each([ - 'Safe 3', - 'Safe 5', - 'Safe 7', - 'Trezor Safe 7', - 'Model One', - 'Model T', - ])( - 'uses the Trezor vendorModelName avatar key when the %s asset is registered', - (modelName) => { - expect( - getThirdPartyDeviceAvatarImage({ - profile: getVendorProfile(EHardwareVendor.trezor), - modelName, - }), - ).toBe(modelName); - }, - ); - - it('falls back to the Trezor vendor avatar for unknown model assets', () => { - expect( - getThirdPartyDeviceAvatarImage({ - profile: getVendorProfile(EHardwareVendor.trezor), - modelName: 'Unknown Model', - }), - ).toBe('trezor'); - }); -}); - describe('buildTrezorDesktopBleUsbConnectId', () => { it('uses firmware device_id as usbConnectId only for Trezor Desktop BLE', () => { expect( @@ -280,3 +249,33 @@ describe('buildTrezorDesktopBleUsbConnectId', () => { ).toBeUndefined(); }); }); + +describe('resolveBleConnectIdForCreate', () => { + it('uses the current scan connectId for the first desktop BLE wallet', () => { + expect( + resolveBleConnectIdForCreate({ + connectId: 'BLE_PERIPHERAL_ID', + transportType: EHardwareTransportType.DesktopWebBle, + }), + ).toBe('BLE_PERIPHERAL_ID'); + }); + + it('prefers an existing explicit desktop BLE endpoint', () => { + expect( + resolveBleConnectIdForCreate({ + connectId: 'DEVICE_CONNECT_ID', + explicitBleConnectId: 'BLE_PERIPHERAL_ID', + transportType: EHardwareTransportType.DesktopWebBle, + }), + ).toBe('BLE_PERIPHERAL_ID'); + }); + + it('uses the current scan connectId on native BLE', () => { + expect( + resolveBleConnectIdForCreate({ + connectId: 'NATIVE_BLE_ID', + transportType: EHardwareTransportType.BLE, + }), + ).toBe('NATIVE_BLE_ID'); + }); +}); diff --git a/packages/kit-bg/src/dbs/local/LocalDbBase.ts b/packages/kit-bg/src/dbs/local/LocalDbBase.ts index 28888549cdd3..c9477a0743ca 100644 --- a/packages/kit-bg/src/dbs/local/LocalDbBase.ts +++ b/packages/kit-bg/src/dbs/local/LocalDbBase.ts @@ -1,9 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ // eslint-disable-next-line max-classes-per-file -import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; +import { + EDeviceType, + EFirmwareType, + isSameOnekeyBleName, +} from '@onekeyfe/hd-shared'; import { Semaphore } from 'async-mutex'; import { + cloneDeep, debounce, isEmpty, isNil, @@ -86,6 +91,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'; @@ -95,10 +105,10 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { checkIsDefined } from '@onekeyhq/shared/src/utils/assertUtils'; import { - AllWalletAvatarImages, getDeviceAvatarImage, + getThirdPartyDeviceAvatarImage, } from '@onekeyhq/shared/src/utils/avatarUtils'; -import type { IAllWalletAvatarImageNamesWithoutDividers } from '@onekeyhq/shared/src/utils/avatarUtils'; +import type { IThirdPartyWalletAvatarImageNames } from '@onekeyhq/shared/src/utils/avatarUtils'; import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; import perfUtils, { EPerformanceTimerLogNames, @@ -106,6 +116,7 @@ import perfUtils, { import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import type { IAvatarInfo } from '@onekeyhq/shared/src/utils/emojiUtils'; import { randomAvatar } from '@onekeyhq/shared/src/utils/emojiUtils'; +import { resolveQrWalletDeviceType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; @@ -122,6 +133,7 @@ import type { IDeviceHomeScreen, IDeviceVersionCacheInfo, IOneKeyDeviceFeatures, + IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import type { IKeylessCloudSyncCredential } from '@onekeyhq/shared/types/keylessCloudSync'; import type { @@ -201,6 +213,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; @@ -325,6 +407,25 @@ export function buildTrezorDesktopBleUsbConnectId({ return undefined; } +/** Select the BLE endpoint persisted during wallet creation using x-branch semantics. */ +export function resolveBleConnectIdForCreate({ + connectId, + explicitBleConnectId, + transportType, +}: { + connectId?: string | null; + explicitBleConnectId?: string | null; + transportType?: EHardwareTransportType; +}): string | undefined { + if (transportType === EHardwareTransportType.DesktopWebBle) { + return explicitBleConnectId || connectId || undefined; + } + if (transportType === EHardwareTransportType.BLE) { + return connectId || undefined; + } + return undefined; +} + function getExtraDeviceFieldString( device: IDBCreateHwWalletParams['device'], field: @@ -533,21 +634,20 @@ export function buildThirdPartyDeviceDisplayName({ return `${vendorName} Device`; } -export function getThirdPartyDeviceAvatarImage({ - profile, - modelName, +function getThirdPartyDeviceModelCode({ + device, + features, }: { - profile: ReturnType; - modelName?: string; -}): IAllWalletAvatarImageNamesWithoutDividers { - if ( - profile.vendor === EHardwareVendor.trezor && - modelName && - modelName in AllWalletAvatarImages - ) { - return modelName as IAllWalletAvatarImageNamesWithoutDividers; - } - return profile.avatarKey as IAllWalletAvatarImageNamesWithoutDividers; + device: IDBCreateHwWalletParams['device']; + features: IOneKeyDeviceFeatures; +}): string | undefined { + const featureRecord = features as IOneKeyDeviceFeatures & { + internal_model?: string; + }; + return ( + featureRecord.internal_model || + getExtraDeviceFieldString(device, 'vendorModel') + ); } function parseDeviceSettingsRaw(settingsRaw?: string): IDBDeviceSettings { @@ -555,7 +655,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 +831,11 @@ type IResolveExistingDeviceParams = { }; export abstract class LocalDbBase extends LocalDbBaseContainer { + private deviceStateEventOrderByDeviceId = new Map< + string, + { sdkEventSequence: number; sdkInstanceEpoch: number } + >(); + tempWallets: { [walletId: string]: boolean; } = {}; @@ -3322,10 +3428,16 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { if (shouldFixAvatar) { if (profile.isThirdParty) { - // Third-party vendor: fix avatar to match vendor key - const expectedImg = - profile.avatarKey as IAllWalletAvatarImageNamesWithoutDividers; - if (avatarInfo?.img && avatarInfo.img !== expectedImg) { + // Resolve per-model avatar; parseDeviceSettingsRaw is a defensive fallback. + const deviceSettings = + device?.settings ?? parseDeviceSettingsRaw(device?.settingsRaw); + const expectedImg = getThirdPartyDeviceAvatarImage({ + vendor: profile.vendor, + vendorModel: deviceSettings.vendorModel, + vendorModelName: deviceSettings.vendorModelName, + fallback: profile.avatarKey as IThirdPartyWalletAvatarImageNames, + }); + if (avatarInfo?.img !== expectedImg) { wallet.avatarInfo = { ...avatarInfo, img: expectedImg }; wallet.avatar = JSON.stringify(wallet.avatarInfo); } @@ -3386,16 +3498,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 && + isSameOnekeyBleName(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; } } } @@ -4329,10 +4454,12 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { walletId, addedHdAccountIndex, isOverrideWallet, + withoutRefillWallet, }: { walletId: string; addedHdAccountIndex: number; isOverrideWallet?: boolean; + withoutRefillWallet?: boolean; }): Promise<{ wallet: IDBWallet; indexedAccount: IDBIndexedAccount | undefined; @@ -4341,6 +4468,7 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { }> { const dbWallet = await this.getWallet({ walletId, + withoutRefill: withoutRefillWallet, }); let dbIndexedAccount: IDBIndexedAccount | undefined; @@ -4953,9 +5081,13 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { async updateDevice({ features, preciseUpdateFields, + skipFeaturesUpdateEvent, }: { features: IOneKeyDeviceFeatures; preciseUpdateFields?: Partial; + // Set when the caller emits its own refresh signal after this write, + // so listeners are not refreshed twice for one mutation. + skipFeaturesUpdateEvent?: boolean; }) { const device = await this.getDeviceByQuery({ features, @@ -4975,6 +5107,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 +5122,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; @@ -4992,10 +5132,228 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { }); }); if (isUpdated) { - appEventBus.emit(EAppEventBusNames.HardwareFeaturesUpdate, { - deviceId: device.id, + // Drop record caches that a concurrent read may have re-filled with + // pre-commit data before notifying listeners that re-read the DB. + this.clearStoreCachedDataIfMatch(ELocalDBStoreNames.Device); + if (!skipFeaturesUpdateEvent) { + appEventBus.emit(EAppEventBusNames.HardwareFeaturesUpdate, { + deviceId: device.id, + }); + } + } + } + + async updateDeviceState({ + changedKeys, + connectId, + sdkEventSequence, + sdkInstanceEpoch, + source, + 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, + source, + }); + 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; + }, }); + }); + // Record caches are invalidated when the write starts; a concurrent read + // can re-fill them with pre-commit data. Clear again after the commit so + // refreshes triggered by the events emitted for this update cannot be + // served the stale snapshot. + this.clearStoreCachedDataIfMatch(ELocalDBStoreNames.Device); + 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({ @@ -5005,8 +5363,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 +5406,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 +5428,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; }, }); @@ -5302,8 +5625,10 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { let xfpHash = ''; let xfpHashLegacy = ''; - // TODO support OneKey Pro device only - const deviceType: IDeviceType = EDeviceType.Pro; + const deviceType = resolveQrWalletDeviceType({ + deviceName: qrDevice.name, + deviceType: qrDevice.deviceType, + }); // TODO name should be OneKey Pro-xxxxxx let deviceName = qrDevice.name || 'OneKey Pro'; const nameArr = deviceName.split('-'); @@ -5536,6 +5861,7 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { ids: [dbDeviceId], updater: async (item) => { item.updatedAt = now; + item.deviceType = deviceType; // TODO update qrDevice last version(not updated version) if (!item.features && featuresStr) { @@ -5710,8 +6036,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { } async buildHwWalletId(params: IDBCreateHwWalletParams) { - const { getDeviceType, getDeviceUUID } = await CoreSDKLoader(); - const { name, device, @@ -5720,7 +6044,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, @@ -5822,7 +6147,12 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { deviceType: EDeviceType.Unknown, firmwareType: thirdPartyDeviceUtils.getFirmwareType({ features }), avatar: { - img: getThirdPartyDeviceAvatarImage({ profile, modelName }), + img: getThirdPartyDeviceAvatarImage({ + vendor: profile.vendor, + vendorModel: getThirdPartyDeviceModelCode({ device, features }), + vendorModelName: modelName, + fallback: profile.avatarKey as IThirdPartyWalletAvatarImageNames, + }), }, deviceName: finalDeviceName, featuresInfo: buildThirdPartyFeaturesInfoFromDevice({ @@ -5900,8 +6230,6 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { hiddenDefaultWalletName = hiddenWalletNameInfo.hiddenWalletName; } - const featuresStr = JSON.stringify(featuresInfo); - const firstAccountIndex = 0; let addedHdAccountIndex = -1; @@ -5911,6 +6239,14 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { let usbConnectId: string | undefined; let bleConnectId: string | undefined; let compatibleConnectId: string | undefined; + const runtimeDevice = device as typeof device & { + bleConnectId?: string; + }; + const resolvedBleConnectId = resolveBleConnectIdForCreate({ + connectId, + explicitBleConnectId: runtimeDevice.bleConnectId, + transportType, + }); if (transportType) { switch (transportType) { @@ -5921,24 +6257,29 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { compatibleConnectId = connectId ?? undefined; break; case EHardwareTransportType.BLE: - bleConnectId = connectId ?? undefined; + bleConnectId = resolvedBleConnectId; compatibleConnectId = connectId ?? undefined; break; case EHardwareTransportType.DesktopWebBle: - // 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 + bleConnectId = resolvedBleConnectId; 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 +6299,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 +6422,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; @@ -6184,6 +6548,7 @@ export abstract class LocalDbBase extends LocalDbBaseContainer { walletId: dbWalletId, addedHdAccountIndex, isOverrideWallet: Boolean(existingWallet && !existingWallet?.isMocked), + withoutRefillWallet: true, // isOverrideWallet: existingWallet && !isExistingHiddenWallet, }); } @@ -8256,7 +8621,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 +8782,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 +8813,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 +8857,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.lazyEntities.test.ts b/packages/kit-bg/src/dbs/simple/base/SimpleDb.lazyEntities.test.ts index 7b60a549e0dd..e2b964881afe 100644 --- a/packages/kit-bg/src/dbs/simple/base/SimpleDb.lazyEntities.test.ts +++ b/packages/kit-bg/src/dbs/simple/base/SimpleDb.lazyEntities.test.ts @@ -84,7 +84,7 @@ describe('SimpleDb lazy entities', () => { .filter(([, descriptor]) => typeof descriptor.get === 'function') .map(([name]) => name); - expect(entityNames).toHaveLength(64); + expect(entityNames).toHaveLength(65); entityNames.forEach((entityName) => { const first = Reflect.get(simpleDb, entityName) as Record< PropertyKey, diff --git a/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts b/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts index 16bf2da47325..c9503bd582ea 100644 --- a/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts +++ b/packages/kit-bg/src/dbs/simple/base/SimpleDb.ts @@ -556,6 +556,19 @@ export class SimpleDb { return value; } + get hardwarePortfolioSync() { + const value = createLazyServiceProxy({ + serviceName: 'simpleDb@hardwarePortfolioSync', + loader: () => + import('../entity/SimpleDbEntityHardwarePortfolioSync').then( + ({ SimpleDbEntityHardwarePortfolioSync }) => + 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/SimpleDbEntityAsyncMethods.test.ts b/packages/kit-bg/src/dbs/simple/base/SimpleDbEntityAsyncMethods.test.ts index 7aa05f2518b8..53e72e0a3bb3 100644 --- a/packages/kit-bg/src/dbs/simple/base/SimpleDbEntityAsyncMethods.test.ts +++ b/packages/kit-bg/src/dbs/simple/base/SimpleDbEntityAsyncMethods.test.ts @@ -102,7 +102,7 @@ describe('SimpleDb entity async method contract', () => { }); }); - expect(entityClassCount).toBe(65); + expect(entityClassCount).toBe(66); expect(violations).toEqual([]); }); }); 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/SimpleDbEntityEarnExtra.ts b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityEarnExtra.ts index dd5d09a5ebe8..b1e389227a07 100644 --- a/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityEarnExtra.ts +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityEarnExtra.ts @@ -1,4 +1,5 @@ import { backgroundMethod } from '@onekeyhq/shared/src/background/backgroundDecorators'; +import type { IEarnPageBannerListItem } from '@onekeyhq/shared/types/earn'; import { SimpleDbEntityBase } from '../base/SimpleDbEntityBase'; @@ -8,6 +9,12 @@ export interface IEarnExtraData { // OK-59196: one-time earn risk disclaimer. Device-scoped (same as the perp // Hyperliquid terms flag) — once accepted, the dialog never shows again. riskDisclaimerAccepted?: boolean; + /** + * Last banner list the Earn home successfully fetched. Persisted so a cold + * start can paint the banner at its real height instead of occupying 0pt and + * expanding once the network answers (OK-60299). + */ + pageBannerList?: IEarnPageBannerListItem[]; } export class SimpleDbEntityEarnExtra extends SimpleDbEntityBase { @@ -49,6 +56,20 @@ export class SimpleDbEntityEarnExtra extends SimpleDbEntityBase })); } + @backgroundMethod() + async getPageBannerList(): Promise { + const data = await this.getRawData(); + return data?.pageBannerList ?? []; + } + + @backgroundMethod() + async setPageBannerList(pageBannerList: IEarnPageBannerListItem[]) { + await this.setRawData((v) => ({ + ...v, + pageBannerList, + })); + } + @backgroundMethod() async isFirstOperation( networkId: 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..8a8eaae3c0ba --- /dev/null +++ b/packages/kit-bg/src/dbs/simple/entity/SimpleDbEntityHardwarePortfolioSync.ts @@ -0,0 +1,61 @@ +import { SimpleDbEntityBase } from '../base/SimpleDbEntityBase'; + +export type IHardwarePortfolioSyncTargetState = { + // Last real hardware upload attempt. Unlike lastTransferAt, this also + // throttles failed BLE attempts so a noisy producer cannot keep waking the + // device after a transient transport failure. + lastAttemptAt?: number; + // 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; + // Native BLE may be disabled by firmware while USB owns the device link. + // Keep this durable across bg runtime restarts; only a successful explicit + // mobile hardware operation is allowed to resume silent Portfolio uploads. + bleSilentSyncDisabled?: boolean; + bleSilentSyncDisabledAt?: number; + bleSilentSyncDisabledReason?: 'link-disabled'; +}; + +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/desktopApis/DesktopApiAppUpdate.test.ts b/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.test.ts new file mode 100644 index 000000000000..d135b5e90935 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.test.ts @@ -0,0 +1,497 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + EAppUpdatePackageAvailabilityStatus, + EAppUpdatePackageErrorCode, +} from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; + +const mockUpdaterHandlers = new Map void>(); +const mockDownloadUpdate = jest.fn, [unknown]>(); +const mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +}; +let mockPreparedInstallerPath: string | undefined; +const mockOpenPath = jest.fn(); +const mockShowMessageBox = jest.fn(); +const mockAutoUpdater = { + app: { baseCachePath: '' }, + autoDownload: false, + autoInstallOnAppQuit: false, + checkForUpdates: jest.fn(), + disableDifferentialDownload: false, + downloadUpdate: mockDownloadUpdate, + forceDevUpdateConfig: false, + isInstallerPath: jest.fn(), + logger: mockLogger, + on: jest.fn((event: string, handler: (...args: unknown[]) => void): void => { + mockUpdaterHandlers.set(event, handler); + }), + quitAndInstall: jest.fn(), + requestHeaders: {}, + setFeedURL: jest.fn(), +}; + +jest.mock('electron-updater', () => ({ + CancellationToken: class { + cancel = jest.fn(); + }, + autoUpdater: mockAutoUpdater, +})); + +jest.mock('electron', () => ({ + BrowserWindow: { getAllWindows: jest.fn(() => []) }, + app: { + exit: jest.fn(), + getVersion: jest.fn(() => '5.9.0'), + isReady: jest.fn(() => true), + removeAllListeners: jest.fn(), + relaunch: jest.fn(), + whenReady: jest.fn(async () => undefined), + }, + autoUpdater: { once: jest.fn() }, + dialog: { showMessageBox: mockShowMessageBox }, + shell: { openPath: mockOpenPath }, +})); + +jest.mock('electron-is-dev', () => ({ __esModule: true, default: false })); +jest.mock('electron-log/main', () => ({ + __esModule: true, + default: mockLogger, +})); +jest.mock('openpgp', () => ({ + readCleartextMessage: jest.fn(), + readKey: jest.fn(), +})); +jest.mock('@onekeyhq/desktop/app/config', () => ({ + ipcMessageKeys: new Proxy({}, { get: () => 'ipc-key' }), +})); +jest.mock('@onekeyhq/desktop/app/constant/gpg', () => ({ PUBLIC_KEY: '' })); +jest.mock('@onekeyhq/desktop/app/i18n', () => ({ + ElectronTranslations: new Proxy({}, { get: (_, key) => String(key) }), + i18nText: (key: string) => key, +})); +jest.mock( + '@onekeyhq/desktop/app/libs/store', + () => new Proxy({}, { get: () => jest.fn() }), +); +jest.mock('@onekeyhq/desktop/app/libs/utils', () => ({ + b2t: (value: boolean) => String(value), + toHumanReadable: (value: number) => String(value), +})); +jest.mock('@onekeyhq/desktop/app/windowProgressBar', () => ({ + clearWindowProgressBar: jest.fn(), + updateWindowProgressBar: jest.fn(), +})); +jest.mock('@onekeyhq/shared/src/config/appConfig', () => ({ + buildServiceEndpoint: jest.fn(() => 'https://example.com'), +})); +jest.mock('@onekeyhq/shared/src/request/customUA', () => ({ + withCustomUAHeaders: jest.fn( + async (_url: string, headers: Record) => headers, + ), +})); + +let DesktopApiAppUpdate: typeof import('./DesktopApiAppUpdate').default; +let originalPlatformDescriptor: PropertyDescriptor | undefined; +let originalSkipGPGVerification: string | undefined; +let tempDir: string; + +const mainWindow = { + isDestroyed: jest.fn(() => false), + webContents: { send: jest.fn() }, +}; + +function emitUpdaterEvent(event: string, payload: unknown) { + if ( + event === 'update-downloaded' && + typeof payload === 'object' && + payload !== null && + 'downloadedFile' in payload + ) { + mockPreparedInstallerPath = String(payload.downloadedFile); + } + mockUpdaterHandlers.get(event)?.(payload); +} + +function createCachedPackage() { + const cacheDir = path.join(tempDir, '@onekeyhqdesktop-updater', 'pending'); + fs.mkdirSync(cacheDir, { recursive: true }); + const downloadedFile = path.join(cacheDir, 'app.zip'); + fs.writeFileSync(downloadedFile, 'cached package'); + return downloadedFile; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +beforeAll(async () => { + originalPlatformDescriptor = Object.getOwnPropertyDescriptor( + process, + 'platform', + ); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + ({ default: DesktopApiAppUpdate } = await import('./DesktopApiAppUpdate')); +}); + +afterAll(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } +}); + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockUpdaterHandlers.clear(); + mockDownloadUpdate.mockReset(); + mockPreparedInstallerPath = undefined; + mockAutoUpdater.isInstallerPath.mockImplementation( + (installerPath: string) => installerPath === mockPreparedInstallerPath, + ); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onekey-app-update-')); + mockAutoUpdater.app.baseCachePath = tempDir; + originalSkipGPGVerification = process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION; + ( + globalThis as unknown as { + $desktopMainAppFunctions: { + getSafelyMainWindow: () => typeof mainWindow; + }; + } + ).$desktopMainAppFunctions = { + getSafelyMainWindow: () => mainWindow, + }; +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + if (originalSkipGPGVerification === undefined) { + delete process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION; + } else { + process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION = + originalSkipGPGVerification; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('DesktopApiAppUpdate macOS cache rehydrate', () => { + test('preserves a valid unprepared package for one rehydrate attempt', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + const availability = + await api.getDownloadedFileAvailability(downloadedFile); + const removeSpy = jest.spyOn(fs, 'rmSync'); + mockDownloadUpdate.mockImplementationOnce(async () => { + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + return [downloadedFile]; + }); + + expect(availability).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }); + + await api.downloadUpdate(); + + expect(removeSpy).not.toHaveBeenCalled(); + expect(api.downloadedEvent?.downloadedFile).toBe(downloadedFile); + expect(api.downloadedEvent?.isUpdaterRehydrated).toBe(true); + expect(mockLogger.info).toHaveBeenCalledWith( + 'auto-updater', + expect.arrayContaining(['Updater cache rehydrate prepared:']), + ); + }); + + test('uses the normal cache-clearing download after rehydrate fails', async () => { + const downloadedFile = createCachedPackage(); + const cachePath = path.join(tempDir, '@onekeyhqdesktop-updater'); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + await api.getDownloadedFileAvailability(downloadedFile); + const removeSpy = jest.spyOn(fs, 'rmSync'); + const rehydrateError = Object.assign(new Error('cache read failed'), { + code: 'EIO', + }); + mockDownloadUpdate.mockRejectedValueOnce(rehydrateError); + + await expect(api.downloadUpdate()).rejects.toBe(rehydrateError); + expect(removeSpy).not.toHaveBeenCalled(); + + mockDownloadUpdate.mockResolvedValueOnce([]); + await api.downloadUpdate(); + + expect(removeSpy).toHaveBeenCalledWith(cachePath, { + recursive: true, + force: true, + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'auto-updater', + expect.arrayContaining([ + 'Updater cache rehydrate failed:', + '- Error code: EIO', + '- Next action: retry with cache clear', + ]), + ); + }); + + test('logs when cache validation falls back to a network download', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + await api.getDownloadedFileAvailability(downloadedFile); + mockDownloadUpdate.mockImplementationOnce(async () => { + emitUpdaterEvent('download-progress', { + bytesPerSecond: 1, + delta: 1, + percent: 1, + total: 100, + transferred: 1, + }); + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + return [downloadedFile]; + }); + + await api.downloadUpdate(); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'auto-updater', + expect.arrayContaining([ + 'Updater cache rehydrate is using network fallback:', + ]), + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'auto-updater', + expect.arrayContaining(['- Network progress observed: true']), + ); + }); + + test('keeps the candidate when metadata checking fails before rehydrate starts', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + await api.getDownloadedFileAvailability(downloadedFile); + emitUpdaterEvent('error', new Error('net::ERR_CONNECTION_RESET')); + const removeSpy = jest.spyOn(fs, 'rmSync'); + mockDownloadUpdate.mockResolvedValueOnce([]); + + await api.downloadUpdate(); + + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test('preserves a prepared package when a later metadata check fails', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + + emitUpdaterEvent('error', new Error('net::ERR_CONNECTION_RESET')); + + await expect( + api.getDownloadedFileAvailability(downloadedFile), + ).resolves.toEqual({ + status: EAppUpdatePackageAvailabilityStatus.available, + }); + expect(api.downloadedEvent?.version).toBe('6.0.0'); + }); + + test('manual install can open a valid package without MacUpdater preparation', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION = 'true'; + mockOpenPath.mockResolvedValueOnce(''); + + await expect( + api.manualInstallPackage({ + buildNumber: '1', + downloadedFile, + downloadUrl: 'https://example.com/app.zip', + skipGPGVerification: true, + }), + ).resolves.toBeUndefined(); + + expect(mockOpenPath).toHaveBeenCalledWith(path.dirname(downloadedFile)); + }); + + test('returns false when the user postpones installation', async () => { + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + mockShowMessageBox.mockResolvedValueOnce({ response: 1 }); + + await expect( + api.installPackage({ + buildNumber: '1', + latestVersion: '2.0.0', + downloadedFile: '/tmp/app.zip', + downloadUrl: 'https://example.com/app.zip', + }), + ).resolves.toBe(false); + + expect(mockAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test.each([ + [ + 'a renderer path that differs from the main event', + '/tmp/other.zip', + '6.0.0', + ], + [ + 'a renderer version that differs from the main event', + 'prepared', + '6.0.1', + ], + ['a prepared version that is not an upgrade', 'prepared', '5.8.0'], + ])('rejects %s', async (_label, requestedFile, requestedVersion) => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + const preparedVersion = + requestedVersion === '5.8.0' ? requestedVersion : '6.0.0'; + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: preparedVersion, + }); + mockShowMessageBox.mockResolvedValueOnce({ response: 0 }); + + let installError: Error | undefined; + try { + await api.installPackage({ + buildNumber: '1', + latestVersion: requestedVersion, + downloadedFile: + requestedFile === 'prepared' ? downloadedFile : requestedFile, + downloadUrl: 'https://example.com/app.zip', + }); + } catch (error) { + installError = error as Error; + } + + expect(installError?.message).toContain( + EAppUpdatePackageErrorCode.packageNotPrepared, + ); + expect(mockAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test('installs the exact upgrade prepared by the current main process', async () => { + const downloadedFile = createCachedPackage(); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION = 'true'; + mockShowMessageBox.mockResolvedValueOnce({ response: 0 }); + + await expect( + api.installPackage({ + buildNumber: '1', + latestVersion: '6.0.0', + downloadedFile, + downloadUrl: 'https://example.com/app.zip', + skipGPGVerification: true, + }), + ).resolves.toBe(true); + + expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalledWith(false); + }); + + test('rejects when updater state changes during package verification', async () => { + const downloadedFile = createCachedPackage(); + const replacementFile = path.join(path.dirname(downloadedFile), 'next.zip'); + fs.writeFileSync(replacementFile, 'replacement package'); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + const verificationStarted = createDeferred(); + const verificationResult = createDeferred(); + jest.spyOn(api, 'verifyFile').mockImplementationOnce(() => { + verificationStarted.resolve(); + return verificationResult.promise; + }); + mockShowMessageBox.mockResolvedValueOnce({ response: 0 }); + + const installPromise = api.installPackage({ + buildNumber: '1', + latestVersion: '6.0.0', + downloadedFile, + downloadUrl: 'https://example.com/app.zip', + }); + await verificationStarted.promise; + emitUpdaterEvent('update-downloaded', { + downloadedFile: replacementFile, + files: [{ url: 'https://example.com/next.zip' }], + releaseDate: '2026-08-13', + version: '6.1.0', + }); + verificationResult.resolve(true); + + await expect(installPromise).rejects.toThrow( + EAppUpdatePackageErrorCode.packageNotPrepared, + ); + expect(mockAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test('rejects when the updater path changes before its event is published', async () => { + const downloadedFile = createCachedPackage(); + const replacementFile = path.join(path.dirname(downloadedFile), 'next.zip'); + fs.writeFileSync(replacementFile, 'replacement package'); + const api = new DesktopApiAppUpdate({ desktopApi: {} as never }); + emitUpdaterEvent('update-downloaded', { + downloadedFile, + files: [{ url: 'https://example.com/app.zip' }], + releaseDate: '2026-08-12', + version: '6.0.0', + }); + const verificationStarted = createDeferred(); + const verificationResult = createDeferred(); + jest.spyOn(api, 'verifyFile').mockImplementationOnce(() => { + verificationStarted.resolve(); + return verificationResult.promise; + }); + mockShowMessageBox.mockResolvedValueOnce({ response: 0 }); + + const installPromise = api.installPackage({ + buildNumber: '1', + latestVersion: '6.0.0', + downloadedFile, + downloadUrl: 'https://example.com/app.zip', + }); + await verificationStarted.promise; + mockPreparedInstallerPath = replacementFile; + verificationResult.resolve(true); + + await expect(installPromise).rejects.toThrow( + EAppUpdatePackageErrorCode.packageNotPrepared, + ); + expect(api.downloadedEvent?.downloadedFile).toBe(downloadedFile); + expect(mockAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.ts b/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.ts index e1ae5c9836c4..16cca299d7f1 100644 --- a/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.ts +++ b/packages/kit-bg/src/desktopApis/DesktopApiAppUpdate.ts @@ -10,8 +10,13 @@ import { } from 'electron'; import isDev from 'electron-is-dev'; import logger from 'electron-log/main'; -import { CancellationToken, autoUpdater } from 'electron-updater'; +import { + CancellationToken, + type UpdateCheckResult, + autoUpdater, +} from 'electron-updater'; import { readCleartextMessage, readKey } from 'openpgp'; +import semver from 'semver'; import { ipcMessageKeys } from '@onekeyhq/desktop/app/config'; import { PUBLIC_KEY } from '@onekeyhq/desktop/app/constant/gpg'; @@ -26,12 +31,18 @@ import { } from '@onekeyhq/desktop/app/windowProgressBar'; import { buildServiceEndpoint } from '@onekeyhq/shared/src/config/appConfig'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; -import type { IUpdateDownloadedEvent } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; +import { + EAppUpdatePackageAvailabilityStatus, + EAppUpdatePackageErrorCode, + type IAppUpdatePackageAvailability, + type IUpdateDownloadedEvent, +} from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; import { withCustomUAHeaders } from '@onekeyhq/shared/src/request/customUA'; import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; +import { getDownloadedFileAvailability as resolveDownloadedFileAvailability } from './appUpdatePackageAvailability'; + import type { IDesktopApi } from './base/types'; -import type { UpdateCheckResult } from 'electron-updater'; function isNetworkError(errorObject: Error) { return ( @@ -85,6 +96,17 @@ export interface IUpdateProgressUpdate { transferred: number; } +interface IUpdaterRehydrateCandidate { + downloadedFile: string; + detectedAt: number; +} + +interface IUpdaterRehydrateAttempt { + downloadedFile: string; + startedAt: number; + networkProgressObserved: boolean; +} + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.disableDifferentialDownload = true; @@ -120,6 +142,25 @@ class DesktopApiAppUpdate { updateCancellationToken: CancellationToken | undefined; + private updaterRehydrateCandidate: IUpdaterRehydrateCandidate | undefined; + + private activeUpdaterRehydrate: IUpdaterRehydrateAttempt | undefined; + + private failActiveUpdaterRehydrate(error: unknown): void { + const attempt = this.activeUpdaterRehydrate; + if (!attempt) { + return; + } + logger.warn('auto-updater', [ + 'Updater cache rehydrate failed:', + `- Downloaded file: ${path.basename(attempt.downloadedFile)}`, + `- Duration: ${Date.now() - attempt.startedAt}ms`, + `- Error code: ${(error as NodeJS.ErrnoException)?.code || 'UNKNOWN'}`, + '- Next action: retry with cache clear', + ]); + this.activeUpdaterRehydrate = undefined; + } + private isSkipGPGAllowed(skipGPGVerification?: boolean) { return ( process.env.ONEKEY_ALLOW_SKIP_GPG_VERIFICATION === 'true' && @@ -132,7 +173,7 @@ class DesktopApiAppUpdate { this.isManualCheck = false; this.latestVersion = {} as ILatestVersion; this.isDownloading = false; - this.downloadedEvent = {} as IUpdateDownloadedEvent; + this.downloadedEvent = undefined; if (!isStoreVersion) { if (app.isReady()) { this.initAppAutoUpdateEvents(); @@ -216,6 +257,8 @@ class DesktopApiAppUpdate { autoUpdater.on('error', (err) => { logger.error('auto-updater', `An error happened: ${err.toString()}`); + this.failActiveUpdaterRehydrate(err); + this.isDownloading = false; const mainWindow = this.getMainWindow(); if (!mainWindow) { return; @@ -225,7 +268,6 @@ class DesktopApiAppUpdate { ? 'Network exception, please check your internet connection.' : err.message; - this.isDownloading = false; if (mainWindow.isDestroyed()) { void dialog .showMessageBox({ @@ -249,6 +291,18 @@ class DesktopApiAppUpdate { }); autoUpdater.on('download-progress', (progressObj) => { + if ( + this.activeUpdaterRehydrate && + !this.activeUpdaterRehydrate.networkProgressObserved + ) { + this.activeUpdaterRehydrate.networkProgressObserved = true; + logger.info('auto-updater', [ + 'Updater cache rehydrate is using network fallback:', + `- Downloaded file: ${path.basename( + this.activeUpdaterRehydrate.downloadedFile, + )}`, + ]); + } logger.debug( 'auto-updater', `Downloading ${progressObj.percent}% (${toHumanReadable( @@ -271,10 +325,31 @@ class DesktopApiAppUpdate { autoUpdater.on( 'update-downloaded', ({ version, releaseDate, downloadedFile, files }) => { + const rehydrateAttempt = this.activeUpdaterRehydrate; const downloadUrl = files.find((file) => file.url.endsWith(path.basename(downloadedFile)), )?.url; + this.updaterRehydrateCandidate = undefined; + this.activeUpdaterRehydrate = undefined; + this.downloadedEvent = { + version, + downloadedFile, + downloadUrl, + isUpdaterRehydrated: Boolean(rehydrateAttempt), + }; + + if (rehydrateAttempt) { + logger.info('auto-updater', [ + 'Updater cache rehydrate prepared:', + `- Downloaded file: ${path.basename(downloadedFile)}`, + `- Duration: ${Date.now() - rehydrateAttempt.startedAt}ms`, + `- Network progress observed: ${b2t( + rehydrateAttempt.networkProgressObserved, + )}`, + ]); + } + logger.info('auto-updater', [ 'Update downloaded:', `- Last version: ${version}`, @@ -288,6 +363,7 @@ class DesktopApiAppUpdate { version, downloadedFile, downloadUrl, + isUpdaterRehydrated: Boolean(rehydrateAttempt), }, ); setTimeout(() => { @@ -303,7 +379,73 @@ class DesktopApiAppUpdate { } async checkDownloadedFileExists(downloadedFile: string): Promise { - return fs.existsSync(downloadedFile); + const availability = + await this.getDownloadedFileAvailability(downloadedFile); + return ( + availability.status === EAppUpdatePackageAvailabilityStatus.available + ); + } + + async getDownloadedFileAvailability( + downloadedFile?: string, + ): Promise { + // electron-updater owns the installer path in process memory. A persisted + // renderer path is not installable after relaunch until this process emits + // update-downloaded from a trusted feed check/cache validation cycle. + const availability = resolveDownloadedFileAvailability(downloadedFile, { + requireCurrentProcessPreparation: true, + preparedDownloadedFile: this.downloadedEvent?.downloadedFile, + }); + if (downloadedFile) { + if ( + availability.status === EAppUpdatePackageAvailabilityStatus.notPrepared + ) { + if (this.updaterRehydrateCandidate?.downloadedFile !== downloadedFile) { + this.updaterRehydrateCandidate = { + downloadedFile, + detectedAt: Date.now(), + }; + logger.info('auto-updater', [ + 'Updater cache rehydrate candidate detected:', + `- Downloaded file: ${path.basename(downloadedFile)}`, + ]); + } + } else { + this.updaterRehydrateCandidate = undefined; + } + } + return availability; + } + + private async assertDownloadedFileAvailable( + downloadedFile?: string, + options?: { requireCurrentProcessPreparation?: boolean }, + ): Promise { + const availability = + options?.requireCurrentProcessPreparation === false + ? resolveDownloadedFileAvailability(downloadedFile) + : await this.getDownloadedFileAvailability(downloadedFile); + if (availability.status === EAppUpdatePackageAvailabilityStatus.missing) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageMissing); + } + if ( + availability.status === EAppUpdatePackageAvailabilityStatus.unavailable + ) { + throw new OneKeyLocalError( + `${EAppUpdatePackageErrorCode.packageUnavailable}:${ + availability.errorCode || 'IO_ERROR' + }`, + ); + } + if ( + availability.status === EAppUpdatePackageAvailabilityStatus.notPrepared + ) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageNotPrepared); + } + if (!downloadedFile) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageMissing); + } + return downloadedFile; } async clearUpdateCache(): Promise { @@ -311,6 +453,9 @@ class DesktopApiAppUpdate { this.updateCancellationToken.cancel(); } this.isDownloading = false; + this.updaterRehydrateCandidate = undefined; + this.activeUpdaterRehydrate = undefined; + this.downloadedEvent = undefined; try { // @ts-ignore const baseCachePath = autoUpdater?.app?.baseCachePath; @@ -385,6 +530,7 @@ class DesktopApiAppUpdate { if (this.isDownloading) { return; } + this.downloadedEvent = undefined; clearWindowProgressBar(this.getMainWindow()); store.setUpdateBuildNumber(''); logger.info( @@ -406,7 +552,25 @@ class DesktopApiAppUpdate { if (this.updateCancellationToken) { this.updateCancellationToken.cancel(); } - await clearUpdateCache(); + const rehydrateCandidate = this.updaterRehydrateCandidate; + this.updaterRehydrateCandidate = undefined; + this.activeUpdaterRehydrate = undefined; + if (rehydrateCandidate) { + this.activeUpdaterRehydrate = { + downloadedFile: rehydrateCandidate.downloadedFile, + startedAt: Date.now(), + networkProgressObserved: false, + }; + logger.info('auto-updater', [ + 'Updater cache rehydrate started:', + `- Downloaded file: ${path.basename( + rehydrateCandidate.downloadedFile, + )}`, + `- Candidate age: ${Date.now() - rehydrateCandidate.detectedAt}ms`, + ]); + } else { + await clearUpdateCache(); + } this.updateCancellationToken = new CancellationToken(); try { @@ -414,6 +578,7 @@ class DesktopApiAppUpdate { await autoUpdater.downloadUpdate(this.updateCancellationToken); logger.info('auto-updater', 'Download update success'); } catch (e) { + this.failActiveUpdaterRehydrate(e); this.isDownloading = false; logger.info('auto-updater', 'Update cancelled', e); // CancellationError @@ -434,10 +599,7 @@ class DesktopApiAppUpdate { downloadUrl, ); - if (!downloadedFile || !fs.existsSync(downloadedFile)) { - logger.info('auto-updater', 'no such file'); - throw new OneKeyLocalError('NOT_FOUND_FILE'); - } + await this.assertDownloadedFileAvailable(downloadedFile); if (downloadUrl) { try { @@ -555,17 +717,27 @@ class DesktopApiAppUpdate { return !!sha256; } - async verifyFile(verifyParams: IInstallUpdateParams): Promise { + async verifyFile( + verifyParams: IInstallUpdateParams, + options?: { requireCurrentProcessPreparation?: boolean }, + ): Promise { const { downloadedFile, downloadUrl } = verifyParams; - if (!downloadedFile || !downloadUrl) { + if (!downloadUrl) { logger.info('auto-updater', 'no such file'); return false; } + const verifiedDownloadedFile = await this.assertDownloadedFileAvailable( + downloadedFile, + options, + ); if (this.isSkipGPGAllowed(verifyParams?.skipGPGVerification)) { logger.info('auto-updater', 'verifyFile skipped by skipGPGVerification'); return true; } - logger.info('auto-updater', `verifyFile ${downloadedFile} ${downloadUrl}`); + logger.info( + 'auto-updater', + `verifyFile ${verifiedDownloadedFile} ${downloadUrl}`, + ); const sha256 = await this.getSha256(); if (!sha256) { @@ -574,13 +746,22 @@ class DesktopApiAppUpdate { } try { - const verified = await this.verifySha256(downloadedFile, sha256); + const verified = await this.verifySha256(verifiedDownloadedFile, sha256); if (!verified) { // sendValidError(); return false; } } catch (error) { logger.info('auto-updater', 'verifyFile error', error); + const errorCode = (error as NodeJS.ErrnoException)?.code; + if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageMissing); + } + if (errorCode) { + throw new OneKeyLocalError( + `${EAppUpdatePackageErrorCode.packageUnavailable}:${errorCode}`, + ); + } throw new OneKeyLocalError( ElectronTranslations.update_installation_package_possibly_compromised, ); @@ -624,15 +805,46 @@ class DesktopApiAppUpdate { return true; } - async installPackage(verifyParams: IInstallUpdateParams): Promise { - const verified = await this.verifyFile(verifyParams); - if (!verified) { - throw new OneKeyLocalError( - ElectronTranslations.update_installation_not_safe_alert_text, - ); + private getCurrentProcessPreparedInstallParams( + verifyParams: IInstallUpdateParams, + ): IInstallUpdateParams { + const preparedEvent = this.downloadedEvent; + const downloadedFile = verifyParams.downloadedFile; + const expectedVersion = verifyParams.latestVersion; + const preparedVersion = preparedEvent?.version; + const currentVersion = app.getVersion(); + if ( + !preparedEvent || + !downloadedFile || + !expectedVersion || + !preparedVersion + ) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageNotPrepared); } - const buildNumber = verifyParams.buildNumber; - logger.info('auto-updater', 'Installation request', buildNumber); + const isVersionBound = + preparedVersion === expectedVersion && + semver.valid(preparedVersion) !== null && + semver.valid(currentVersion) !== null && + semver.gt(preparedVersion, currentVersion); + const isMainEventBound = + preparedEvent?.downloadedFile === downloadedFile && isVersionBound; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const isUpdaterBound = Boolean(autoUpdater.isInstallerPath(downloadedFile)); + if (!isMainEventBound || !isUpdaterBound) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageNotPrepared); + } + return { + ...verifyParams, + latestVersion: preparedVersion, + downloadedFile: preparedEvent.downloadedFile, + downloadUrl: preparedEvent.downloadUrl || verifyParams.downloadUrl, + }; + } + + async installPackage(verifyParams: IInstallUpdateParams): Promise { + // Keep this native main-process confirmation as an authorization boundary: + // a compromised renderer must not be able to silently replace the app by + // invoking the install IPC. File integrity is verified again below. const selection = await dialog.showMessageBox({ type: 'question', buttons: [ @@ -642,86 +854,114 @@ class DesktopApiAppUpdate { defaultId: 0, message: i18nText(ElectronTranslations.update_new_update_downloaded), }); - if (selection.response === 0) { - store.setUpdateBuildNumber(buildNumber); - logger.info('auto-update', 'button[0] was clicked', buildNumber); - // https://github.com/electron-userland/electron-builder/issues/8997#issuecomment-2969507357 - /** - * On macOS 15+ auto-update / relaunch issues: - * - https://github.com/electron-userland/electron-builder/issues/8795 - * - https://github.com/electron-userland/electron-builder/issues/8997 - */ - if (isMac) { - app.removeAllListeners('before-quit'); - app.removeAllListeners('window-all-closed'); - BrowserWindow.getAllWindows().forEach((win) => { - if (win.isDestroyed()) { - return; - } - win.removeAllListeners('close'); - win.close(); - }); - nativeUpdater.once('before-quit-for-update', () => { - app.exit(); - }); - autoUpdater.quitAndInstall(false); - return; + if (selection.response !== 0) { + return false; + } + const buildNumber = verifyParams.buildNumber; + logger.info('auto-updater', 'Installation request', buildNumber); + const installVerifyParams = + this.getCurrentProcessPreparedInstallParams(verifyParams); + if (!isMac) { + // On Linux AppImage, bail out early if APPIMAGE env is unusable — + // quitAndInstall would otherwise crash inside electron-updater with + // `ENOENT: ... unlink ''` and leave the user stuck. + if (isLinux && isAppImage && !this.canAutoInstallAppImage()) { + await this.manualInstallPackage(installVerifyParams); + return true; } - if (!isMac) { - logger.info('auto-update', 'button[0] was clicked', buildNumber); - // On Linux AppImage, bail out early if APPIMAGE env is unusable — - // quitAndInstall would otherwise crash inside electron-updater with - // `ENOENT: ... unlink ''` and leave the user stuck. - if (isLinux && isAppImage && !this.canAutoInstallAppImage()) { - await this.manualInstallPackage(verifyParams); - return; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const isExist = autoUpdater?.isExistInstallerPath(); - const downloadedFilePath = verifyParams.downloadedFile; - if (!isExist && downloadedFilePath) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await autoUpdater?.updateInstallerPath(downloadedFilePath); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const isUpdated = autoUpdater?.isExistInstallerPath(); - logger.info('auto-update', 'isUpdated:', isUpdated, buildNumber); - if (!isUpdated) { - await this.manualInstallPackage(verifyParams); + } + const verified = await this.verifyFile(installVerifyParams); + if (!verified) { + throw new OneKeyLocalError( + ElectronTranslations.update_installation_not_safe_alert_text, + ); + } + // Rebind after async verification so a concurrent download cannot swap + // the updater state before the synchronous install handoff. + this.getCurrentProcessPreparedInstallParams(installVerifyParams); + store.setUpdateBuildNumber(buildNumber); + logger.info( + 'auto-update', + 'install confirmed in native dialog', + buildNumber, + ); + // https://github.com/electron-userland/electron-builder/issues/8997#issuecomment-2969507357 + /** + * On macOS 15+ auto-update / relaunch issues: + * - https://github.com/electron-userland/electron-builder/issues/8795 + * - https://github.com/electron-userland/electron-builder/issues/8997 + */ + if (isMac) { + app.removeAllListeners('before-quit'); + app.removeAllListeners('window-all-closed'); + BrowserWindow.getAllWindows().forEach((win) => { + if (win.isDestroyed()) { return; } - } + win.removeAllListeners('close'); + win.close(); + }); + nativeUpdater.once('before-quit-for-update', () => { + app.exit(); + }); autoUpdater.quitAndInstall(false); + return true; } + autoUpdater.quitAndInstall(false); + return true; } async manualInstallPackage( verifyParams: IInstallUpdateParams, ): Promise { + // Signature verification proves authenticity, but not that a renderer + // selected the current feed version. Keep Win/Linux bound to the package + // prepared by this process before opening its directory. + const installVerifyParams = isMac + ? verifyParams + : this.getCurrentProcessPreparedInstallParams(verifyParams); logger.info( 'auto-updater', 'Opening downloaded file', - verifyParams.buildNumber, - verifyParams, + installVerifyParams.buildNumber, + installVerifyParams, ); - const verified = await this.verifyFile(verifyParams); + const verified = await this.verifyFile(installVerifyParams, { + requireCurrentProcessPreparation: false, + }); if (!verified) { - return; + throw new OneKeyLocalError( + ElectronTranslations.update_installation_not_safe_alert_text, + ); } + await this.assertDownloadedFileAvailable( + installVerifyParams.downloadedFile, + { + requireCurrentProcessPreparation: false, + }, + ); logger.info( 'auto-updater', 'Manual installation request', - verifyParams.buildNumber, + installVerifyParams.buildNumber, ); - if (verifyParams.downloadedFile) { + if (installVerifyParams.downloadedFile) { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- dynamic require returns untyped // oxlint-disable-next-line @typescript-eslint/no-unsafe-call -- dynamic require returns untyped const { shell } = require('electron'); // oxlint-disable-next-line @typescript-eslint/no-unsafe-call -- shell from dynamic require is untyped - await shell.openPath(path.dirname(verifyParams.downloadedFile)); + const openPathError = await shell.openPath( + path.dirname(installVerifyParams.downloadedFile), + ); + if (openPathError) { + throw new OneKeyLocalError( + EAppUpdatePackageErrorCode.packageUnavailable, + ); + } } catch (error) { logger.error('auto-updater', 'Failed to open downloaded file', error); + throw error; } } else { logger.warn('auto-updater', 'No downloaded file to open'); diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts new file mode 100644 index 000000000000..78e882d87fc0 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts @@ -0,0 +1,460 @@ +import { createHash } from 'node:crypto'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { zipSync } from 'fflate'; + +import DesktopApiFirmwareArtifact, { + isFirmwareArtifactUrlAllowed, +} from './DesktopApiFirmwareArtifact'; + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp/onekey-firmware-artifact-jest'), + }, + session: { defaultSession: { fetch: jest.fn() } }, +})); + +describe('DesktopApiFirmwareArtifact URL admission', () => { + afterEach(async () => { + await rm('/tmp/onekey-firmware-artifact-jest', { + recursive: true, + force: true, + }); + }); + + it('accepts only exact reviewed artifact hosts', () => { + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://web.onekey-asset.com/firmware.bin'), + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://common.onekey-asset.com/firmware.bin'), + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL( + 'https://pub-568cac7a13bf4c42b7a8113ffffc6793.r2.dev/firmware.bin', + ), + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://web.onekey-asset.com.evil.test/firmware.bin'), + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://common.onekey-asset.com:8443/firmware.bin'), + ), + ).toBe(false); + }); + + it('skips hostname pinning only for pre-release downloads', () => { + const allow = { allowPreReleaseHosts: true }; + expect( + isFirmwareArtifactUrlAllowed( + new URL( + 'https://pub-568cac7a13bf4c42b7a8113ffffc6793.r2.dev/firmware.bin', + ), + allow, + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://leonbucket.blob.core.windows.net/pro2/resource.zip'), + allow, + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://web.onekey-asset.com/firmware.bin'), + allow, + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL( + 'https://pub-568cac7a13bf4c42b7a8113ffffc6793.r2.dev/firmware.bin', + ), + { allowPreReleaseHosts: false }, + ), + ).toBe(false); + // Structural checks still apply while hostname pinning is skipped. + expect( + isFirmwareArtifactUrlAllowed( + new URL('http://leonbucket.blob.core.windows.net/resource.zip'), + allow, + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://leonbucket.blob.core.windows.net:8443/resource.zip'), + allow, + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://user:pass@leonbucket.blob.core.windows.net/res.zip'), + allow, + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://leonbucket.blob.core.windows.net/res.zip#fragment'), + allow, + ), + ).toBe(false); + }); + + it('opens the pre-release admission only for the literal boolean true', () => { + // The flag crosses an IPC boundary, so it may arrive as any JSON value. + // Only `true` may widen the allowlist: a truthy coercion here would let a + // malformed or hostile payload skip hostname pinning. + const devUrl = new URL('https://leonbucket.blob.core.windows.net/res.zip'); + for (const value of [ + undefined, + null, + false, + 0, + 1, + 'true', + 'false', + {}, + [], + ]) { + expect( + isFirmwareArtifactUrlAllowed(devUrl, { + allowPreReleaseHosts: value as never, + }), + ).toBe(false); + } + expect( + isFirmwareArtifactUrlAllowed(devUrl, { allowPreReleaseHosts: true }), + ).toBe(true); + }); + + it('probes its root and keeps the minimum lease lifecycle in memory', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + expect(adapter.getCapabilities()).toMatchObject({ + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ['domain'], + maxReadBytes: 256 * 1024, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000001'; + const lease = await adapter.createLease(transactionId); + await adapter.releaseLease({ + leaseRef: lease.leaseRef, + disposition: 'safeCancelled', + }); + }); + + it('enforces host admission at the download entry point, not just the predicate', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000009'; + const lease = await adapter.createLease(transactionId); + const input = { + taskId: 'resource', + transactionId, + leaseRef: lease.leaseRef, + artifactId: 'resource', + url: 'https://leonbucket.blob.core.windows.net/pro2/resource.zip', + route: { routeType: 'domain' } as const, + expectedSize: 1, + expectedSha256: 'a'.repeat(64), + maxBytes: 1, + overallDeadlineSeconds: 1, + }; + + await expect(adapter.download(input)).rejects.toThrow( + 'ARTIFACT_INVALID_INPUT', + ); + // A non-boolean value must not coerce its way past hostname pinning. + await expect( + adapter.download({ + ...input, + allowPreReleaseHosts: 'true' as never, + }), + ).rejects.toThrow('ARTIFACT_INVALID_INPUT'); + // Pre-release admission preserves the optional integrity contract used by + // the production config path. + await expect( + adapter.download({ + ...input, + expectedSize: undefined, + expectedSha256: undefined, + allowPreReleaseHosts: true, + }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + // Admitted: dev flag plus a pinned SHA-256 reaches the network stage. + await expect( + adapter.download({ ...input, allowPreReleaseHosts: true }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + + await adapter.releaseLease({ + leaseRef: lease.leaseRef, + disposition: 'safeCancelled', + }); + }); + + it('rejects cancelled transactions until their lease is released', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000002'; + const lease = await adapter.createLease(transactionId); + const input = { + taskId: 'firmware', + transactionId, + leaseRef: lease.leaseRef, + artifactId: 'firmware', + url: 'https://web.onekey-asset.com/firmware.bin', + route: { routeType: 'domain' } as const, + expectedSize: 1, + expectedSha256: 'a'.repeat(64), + maxBytes: 1, + overallDeadlineSeconds: 1, + }; + await adapter.cancelDownloads(transactionId); + await expect(adapter.download(input)).rejects.toThrow('ARTIFACT_CANCELLED'); + await adapter.releaseLease({ + leaseRef: lease.leaseRef, + disposition: 'safeCancelled', + }); + const nextLease = await adapter.createLease(transactionId); + await expect( + adapter.download({ ...input, leaseRef: nextLease.leaseRef }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + }); + + it('uses distinct partial files for the same artifact across transactions', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const artifact = Buffer.from('firmware artifact'); + const expectedSha256 = createHash('sha256').update(artifact).digest('hex'); + const partialPaths: string[] = []; + const adapterWithStream = adapter as unknown as { + streamResponseToFile( + input: Parameters[0], + partialPath: string, + resumeOffset: number, + ): Promise; + }; + jest + .spyOn(adapterWithStream, 'streamResponseToFile') + .mockImplementation(async (_input, partialPath) => { + partialPaths.push(partialPath); + await writeFile(partialPath, artifact); + }); + const transactionIds = [ + 'fwtx:00000000-0000-4000-8000-000000000003', + 'fwtx:00000000-0000-4000-8000-000000000004', + ]; + const leases = await Promise.all( + transactionIds.map((transactionId) => adapter.createLease(transactionId)), + ); + const baseInput = { + taskId: `fw-${expectedSha256.slice(0, 24)}`, + artifactId: 'firmware', + url: 'https://web.onekey-asset.com/firmware.bin', + route: { routeType: 'domain' } as const, + expectedSize: artifact.byteLength, + expectedSha256, + maxBytes: artifact.byteLength, + overallDeadlineSeconds: 30, + }; + + await expect( + Promise.all( + transactionIds.map((transactionId, index) => + adapter.download({ + ...baseInput, + transactionId, + leaseRef: leases[index].leaseRef, + }), + ), + ), + ).resolves.toHaveLength(2); + expect(partialPaths).toHaveLength(2); + expect(new Set(partialPaths).size).toBe(2); + expect( + partialPaths.every((value) => path.extname(value) === '.partial'), + ).toBe(true); + }); + + it('restarts an unverified partial instead of mixing responses across attempts', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000005'; + const taskId = 'firmware-without-integrity'; + const url = 'https://web.onekey-asset.com/firmware.bin'; + const downloadToken = createHash('sha256').update(url).digest('hex'); + const transactionToken = createHash('sha256') + .update(transactionId) + .digest('hex') + .slice(0, 16); + const partialPath = path.join( + '/tmp/onekey-firmware-artifact-jest', + `${downloadToken}.${taskId}.${transactionToken}.partial`, + ); + await writeFile(partialPath, Buffer.from('stale partial')); + const artifact = Buffer.from('complete firmware artifact'); + const actualSha256 = createHash('sha256').update(artifact).digest('hex'); + const resumeOffsets: number[] = []; + const adapterWithStream = adapter as unknown as { + streamResponseToFile( + input: Parameters[0], + targetPath: string, + resumeOffset: number, + ): Promise; + }; + jest + .spyOn(adapterWithStream, 'streamResponseToFile') + .mockImplementation(async (_input, targetPath, resumeOffset) => { + resumeOffsets.push(resumeOffset); + await writeFile(targetPath, artifact); + }); + const { leaseRef } = await adapter.createLease(transactionId); + + await expect( + adapter.download({ + taskId, + transactionId, + leaseRef, + artifactId: 'firmware', + url, + route: { routeType: 'domain' }, + maxBytes: 512 * 1024 * 1024, + overallDeadlineSeconds: 30, + }), + ).resolves.toEqual({ + artifactRef: `fw:${actualSha256}`, + size: artifact.byteLength, + sha256: actualSha256, + expectedSha256Verified: false, + }); + expect(resumeOffsets).toEqual([0]); + }); + + it('materializes every file in a manifest-free RESC archive', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const imagesPackage = Buffer.from('signed images resource package'); + const bootResourcePackage = Buffer.from( + 'signed boot resource package with staging path', + ); + const hashReport = Buffer.from('resource hashes'); + const archive = zipSync({ + 'bundles/': [ + new Uint8Array(), + { + level: 0, + os: 3, + attrs: 0o4_0755 * 2 ** 16, + }, + ], + 'bundles/images-release.okpkg': [ + imagesPackage, + { + os: 3, + attrs: 0o10_0644 * 2 ** 16, + }, + ], + 'loaders/bootloader/boot_resource-release.okpkg': [ + bootResourcePackage, + { + os: 3, + attrs: 0o10_0644 * 2 ** 16, + }, + ], + 'resource_hash.txt': [ + hashReport, + { + os: 3, + attrs: 0o10_0644 * 2 ** 16, + }, + ], + }); + const archiveSha256 = createHash('sha256').update(archive).digest('hex'); + const imagesSha256 = createHash('sha256') + .update(imagesPackage) + .digest('hex'); + const bootResourceSha256 = createHash('sha256') + .update(bootResourcePackage) + .digest('hex'); + const hashReportSha256 = createHash('sha256') + .update(hashReport) + .digest('hex'); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000010'; + const { leaseRef } = await adapter.createLease(transactionId); + const adapterWithStream = adapter as unknown as { + streamResponseToFile( + input: Parameters[0], + targetPath: string, + resumeOffset: number, + ): Promise; + }; + jest + .spyOn(adapterWithStream, 'streamResponseToFile') + .mockImplementation(async (_input, targetPath) => { + await writeFile(targetPath, archive); + }); + const receipt = await adapter.download({ + taskId: 'resource', + transactionId, + leaseRef, + artifactId: 'resource', + url: 'https://web.onekey-asset.com/resource.zip', + route: { routeType: 'domain' }, + expectedSize: archive.byteLength, + expectedSha256: archiveSha256, + maxBytes: archive.byteLength, + overallDeadlineSeconds: 30, + }); + + await expect( + adapter.materialize({ + leaseRef, + archiveArtifactRef: receipt.artifactRef, + }), + ).resolves.toEqual([ + { + entryName: 'bundles/images-release.okpkg', + receipt: { + artifactRef: `fw:${imagesSha256}`, + size: imagesPackage.byteLength, + sha256: imagesSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'loaders/bootloader/boot_resource-release.okpkg', + receipt: { + artifactRef: `fw:${bootResourceSha256}`, + size: bootResourcePackage.byteLength, + sha256: bootResourceSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'resource_hash.txt', + receipt: { + artifactRef: `fw:${hashReportSha256}`, + size: hashReport.byteLength, + sha256: hashReportSha256, + expectedSha256Verified: false, + }, + }, + ]); + }); +}); diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts new file mode 100644 index 000000000000..7b5c0b7318c7 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts @@ -0,0 +1,1197 @@ +// cspell:ignore fwlease + +import { createHash, randomUUID } from 'node:crypto'; +import { + accessSync, + createReadStream, + createWriteStream, + constants as fsConstants, + mkdirSync, +} from 'node:fs'; +import { + type FileHandle, + mkdir, + open, + readdir, + rename, + rm, + stat, +} from 'node:fs/promises'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { app, session } from 'electron'; +import yauzl from 'yauzl'; + +import { FirmwareArtifactDesktopError } from './FirmwareArtifactDesktopError'; + +import type { IDesktopApi } from './instance/IDesktopApi'; +import type { + IFirmwareArtifactAdapter, + IFirmwareArtifactReceipt, +} from '../services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import type { Entry, ZipFile } from 'yauzl'; + +const MAX_READ_BYTES = 256 * 1024; +const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024; +const MAX_ARCHIVE_ENTRIES = 4096; +const MAX_ARCHIVE_ENTRY_BYTES = 128 * 1024 * 1024; +const UNIX_FILE_TYPE_MASK = 61_440; +const UNIX_DIRECTORY_FILE_TYPE = 16_384; +const UNIX_REGULAR_FILE_TYPE = 32_768; +const ARTIFACT_REF_PATTERN = /^fw:[a-f0-9]{64}$/u; +const SHA256_PATTERN = /^[a-fA-F0-9]{64}$/u; +const TASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,100}$/u; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]{1,160}$/u; +const LEASE_REF_PATTERN = /^fwlease:[a-f0-9-]{36}$/u; +const NESTED_ARCHIVE_PATTERN = /\.(?:zip|7z|rar|tar|gz|tgz)$/iu; +const FINAL_ARTIFACT_GRACE_MS = 24 * 60 * 60 * 1000; +const PARTIAL_ARTIFACT_GRACE_MS = 7 * 24 * 60 * 60 * 1000; +const FIRMWARE_ARTIFACT_HOSTNAMES = new Set([ + 'common.onekey-asset.com', + 'web.onekey-asset.com', +]); + +type IDownloadInput = Parameters[0]; +type IMaterializeInput = Parameters[0]; +type IArchiveRequirement = { + entryName: string; + expectedSize: number; + expectedSha256?: string; +}; + +type IStagedEntry = { + entryName: string; + size: number; + sha256: string; + filePath: string; +}; + +type IFirmwareArtifactLease = { + transactionId: string; + artifactRefs: Set; +}; + +const hashFile = async (filePath: string): Promise => { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + digest.update(chunk); + } + return digest.digest('hex'); +}; + +const assertSafeInteger = (value: number, label: string): void => { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + `${label} must be a positive safe integer`, + ); + } +}; + +// allowPreReleaseHosts is only sent by bg while developer mode + +// "Use pre-release config" are on: pre-release artifacts live in +// developer-owned buckets whose hostnames cannot be pinned in advance. It only +// widens hostname admission; all other validation, including optional integrity +// metadata, stays identical to the production config path. +export const isFirmwareArtifactUrlAllowed = ( + url: URL, + options?: { allowPreReleaseHosts?: boolean }, +): boolean => + url.protocol === 'https:' && + url.port === '' && + !url.username && + !url.password && + !url.hash && + (options?.allowPreReleaseHosts === true || + FIRMWARE_ARTIFACT_HOSTNAMES.has(url.hostname.toLowerCase())); + +const validatePortableEntryName = ( + name: string, + canonicalNames: Set, +): void => { + const normalized = name.normalize('NFC'); + const folded = normalized.toLowerCase(); + const parts = name.split('/'); + if ( + !name || + name.length > 512 || + name !== normalized || + name.startsWith('/') || + name.startsWith('\\') || + name.includes('\\') || + name.includes(':') || + Array.from(name).some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || code === 0x7f; + }) || + parts.some( + (part) => + !part || + part === '.' || + part === '..' || + part.endsWith('.') || + part.endsWith(' '), + ) || + NESTED_ARCHIVE_PATTERN.test(name) || + canonicalNames.has(folded) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry name is not portable', + ); + } + canonicalNames.add(folded); +}; + +const openZip = (filePath: string, options: yauzl.Options): Promise => + new Promise((resolve, reject) => { + yauzl.open(filePath, options, (error, zipFile) => { + if (error || !zipFile) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive cannot be opened', + { cause: error }, + ), + ); + } else { + resolve(zipFile); + } + }); + }); + +const openZipEntry = (zipFile: ZipFile, entry: Entry): Promise => + new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry cannot be opened', + { cause: error }, + ), + ); + } else { + resolve(stream); + } + }); + }); + +class DesktopApiFirmwareArtifact implements IFirmwareArtifactAdapter { + private readonly downloads = new Map< + string, + Promise + >(); + + private readonly cancelledTransactions = new Set(); + + private readonly cancellationCallbacks = new Map void>>(); + + private readonly readers = new Map< + string, + { handle: FileHandle; size: number; filePath: string } + >(); + + private readonly promotions = new Map>(); + + private readonly rootPath: string; + + private readonly leases = new Map(); + + constructor(_params: { desktopApi: IDesktopApi }) { + this.rootPath = path.join(app.getPath('userData'), 'firmware-artifacts'); + mkdirSync(this.rootPath, { recursive: true }); + accessSync(this.rootPath, fsConstants.R_OK | fsConstants.W_OK); + } + + getCapabilities() { + return { + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ['domain'], + supportsArchiveMaterialization: true, + maxReadBytes: MAX_READ_BYTES, + }; + } + + async download(input: IDownloadInput): Promise { + this.validateDownloadInput(input); + this.assertNotCancelled(input.transactionId); + if (input.expectedSha256) { + await this.retainExpected({ + leaseRef: input.leaseRef, + transactionId: input.transactionId, + artifactRef: `fw:${input.expectedSha256.toLowerCase()}`, + }); + } else { + await this.assertLeaseTransaction(input.leaseRef, input.transactionId); + } + const downloadToken = + input.expectedSha256?.toLowerCase() ?? + createHash('sha256').update(input.url).digest('hex'); + const key = `${downloadToken}:${input.taskId}:${input.transactionId}`; + const existing = this.downloads.get(key); + if (existing) return existing; + const task = this.downloadLocked(input, downloadToken) + .then(async (receipt) => { + await this.retainExpected({ + leaseRef: input.leaseRef, + transactionId: input.transactionId, + artifactRef: receipt.artifactRef, + }); + return receipt; + }) + .finally(() => { + this.downloads.delete(key); + }); + this.downloads.set(key, task); + return task; + } + + async cancelDownloads(transactionId: string): Promise { + if (!IDENTIFIER_PATTERN.test(transactionId)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware transactionId is invalid', + ); + } + this.cancelledTransactions.add(transactionId); + for (const cancel of this.cancellationCallbacks.get(transactionId) ?? []) { + cancel(); + } + } + + async materialize( + input: IMaterializeInput, + ): Promise< + readonly { entryName: string; receipt: IFirmwareArtifactReceipt }[] + > { + await this.assertLease(input.leaseRef); + const archivePath = await this.resolveArtifactPath( + input.archiveArtifactRef, + ); + const expectedEntries = input.expectedEntries + ? this.validateExpectedEntries(input.expectedEntries) + : undefined; + const requirements = await this.validateArchive( + archivePath, + expectedEntries, + ); + const scratchPath = path.join(this.rootPath, `archive-${randomUUID()}`); + await mkdir(scratchPath, { recursive: false }); + try { + const staged = await this.extractArchive( + archivePath, + scratchPath, + requirements, + ); + const result = []; + for (const entry of staged) { + const destination = this.artifactPath(entry.sha256); + if ( + !(await this.isStoredArtifactValid( + destination, + entry.size, + entry.sha256, + )) + ) { + await this.promoteArtifact({ + sourcePath: entry.filePath, + size: entry.size, + sha256: entry.sha256, + }); + } + const receipt = { + artifactRef: `fw:${entry.sha256}`, + size: entry.size, + sha256: entry.sha256, + expectedSha256Verified: input.expectedEntries !== undefined, + }; + await this.retainExpected({ + leaseRef: input.leaseRef, + artifactRef: receipt.artifactRef, + }); + result.push({ + entryName: entry.entryName, + receipt, + }); + } + return result; + } finally { + await rm(scratchPath, { recursive: true, force: true }); + } + } + + async open(artifactRef: string) { + const filePath = await this.resolveArtifactPath(artifactRef); + const fileStat = await stat(filePath); + const readerId = randomUUID(); + this.readers.set(readerId, { + handle: await open(filePath, 'r'), + size: fileStat.size, + filePath, + }); + return { readerId, size: fileStat.size }; + } + + async read({ + readerId, + offset, + length, + }: { + readerId: string; + offset: number; + length: number; + }): Promise { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length <= 0 || + length > MAX_READ_BYTES + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact read is invalid', + ); + } + const reader = this.readers.get(readerId); + if (!reader || offset + length > reader.size) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact read is out of bounds', + ); + } + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await reader.handle.read(buffer, 0, length, offset); + if (bytesRead !== length) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact returned a short read', + ); + } + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + } + + async close(readerId: string): Promise { + const reader = this.readers.get(readerId); + this.readers.delete(readerId); + await reader?.handle.close(); + } + + async createLease(transactionId: string): Promise<{ leaseRef: string }> { + if (!IDENTIFIER_PATTERN.test(transactionId)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware transactionId is invalid', + ); + } + if (this.leases.size >= 32) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Too many firmware artifact leases', + ); + } + const leaseRef = `fwlease:${randomUUID()}`; + this.leases.set(leaseRef, { + transactionId, + artifactRefs: new Set(), + }); + return { leaseRef }; + } + + async retain({ + leaseRef, + artifactRef, + }: { + leaseRef: string; + artifactRef: string; + }): Promise { + await this.resolveArtifactPath(artifactRef); + await this.retainExpected({ leaseRef, artifactRef }); + } + + async releaseLease({ + leaseRef, + disposition, + }: Parameters[0]): Promise { + if ( + disposition !== 'completed' && + disposition !== 'safeCancelled' && + disposition !== 'safeAbandoned' + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware lease disposition is invalid', + ); + } + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + this.leases.delete(leaseRef); + const { transactionId } = lease; + this.cancelledTransactions.delete(transactionId); + } + + async sweepOrphans(): Promise<{ + deletedFiles: number; + deletedBytes: number; + }> { + const retained = new Set( + [...this.leases.values()] + .flatMap((lease) => [...lease.artifactRefs]) + .map((artifactRef) => artifactRef.slice(3)), + ); + const active = new Set( + [...this.downloads.keys()].map((key) => key.slice(0, 64)), + ); + const openPaths = new Set( + [...this.readers.values()].map((reader) => reader.filePath), + ); + const files = await readdir(this.rootPath, { withFileTypes: true }).catch( + () => [], + ); + const now = Date.now(); + let deletedFiles = 0; + let deletedBytes = 0; + for (const entry of files) { + if (entry.isFile()) { + const sha256 = entry.name.slice(0, 64); + if ( + /^[a-f0-9]{64}$/u.test(sha256) && + !retained.has(sha256) && + !active.has(sha256) + ) { + const filePath = path.join(this.rootPath, entry.name); + if (!openPaths.has(filePath)) { + let grace: number | undefined; + if (entry.name.endsWith('.bin')) { + grace = FINAL_ARTIFACT_GRACE_MS; + } else if (entry.name.endsWith('.partial')) { + grace = PARTIAL_ARTIFACT_GRACE_MS; + } + if (grace) { + const fileStat = await stat(filePath).catch(() => undefined); + if (fileStat && now - fileStat.mtimeMs >= grace) { + await rm(filePath, { force: true }); + deletedFiles += 1; + deletedBytes += fileStat.size; + } + } + } + } + } + } + return { deletedFiles, deletedBytes }; + } + + private validateDownloadInput(input: IDownloadInput): void { + const url = new URL(input.url); + const allowPreReleaseHosts = input.allowPreReleaseHosts === true; + if (!isFirmwareArtifactUrlAllowed(url, { allowPreReleaseHosts })) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware URL is outside the reviewed artifact host allowlist', + ); + } + if (input.expectedSize !== undefined) { + assertSafeInteger(input.expectedSize, 'expectedSize'); + } + assertSafeInteger(input.maxBytes, 'maxBytes'); + if ( + input.maxBytes > MAX_ARTIFACT_BYTES || + (input.expectedSize !== undefined && + input.expectedSize > input.maxBytes) || + (input.expectedSha256 !== undefined && + !SHA256_PATTERN.test(input.expectedSha256)) || + !TASK_ID_PATTERN.test(input.taskId) || + !IDENTIFIER_PATTERN.test(input.transactionId) || + !IDENTIFIER_PATTERN.test(input.artifactId) || + !LEASE_REF_PATTERN.test(input.leaseRef) || + !Number.isFinite(input.overallDeadlineSeconds) || + input.overallDeadlineSeconds <= 0 + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifact constraints are invalid', + ); + } + if (input.route.routeType !== 'domain') { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware route constraints are invalid', + ); + } + } + + private async downloadLocked( + input: IDownloadInput, + downloadToken: string, + ): Promise { + this.assertNotCancelled(input.transactionId); + await mkdir(this.rootPath, { recursive: true }); + const expectedSha256 = input.expectedSha256?.toLowerCase(); + if (expectedSha256) { + const finalPath = this.artifactPath(expectedSha256); + if ( + await this.isStoredArtifactValid( + finalPath, + input.expectedSize, + expectedSha256, + input.maxBytes, + ) + ) { + const fileStat = await stat(finalPath); + return { + artifactRef: `fw:${expectedSha256}`, + size: fileStat.size, + sha256: expectedSha256, + expectedSha256Verified: true, + }; + } + } + + const partialPath = path.join( + this.rootPath, + `${downloadToken}.${input.taskId}.${createHash('sha256') + .update(input.transactionId) + .digest('hex') + .slice(0, 16)}.partial`, + ); + let partialSize = await stat(partialPath) + .then((value) => value.size) + .catch(() => 0); + if (!expectedSha256 && partialSize > 0) { + await rm(partialPath, { force: true }); + partialSize = 0; + } else if (partialSize > input.maxBytes) { + await rm(partialPath, { force: true }); + partialSize = 0; + } else if ( + input.expectedSize !== undefined && + partialSize === input.expectedSize && + (await this.isStoredArtifactValid( + partialPath, + input.expectedSize, + expectedSha256, + input.maxBytes, + )) + ) { + const actualSha256 = await hashFile(partialPath); + await this.promoteArtifact({ + sourcePath: partialPath, + size: input.expectedSize, + sha256: actualSha256, + }); + return { + artifactRef: `fw:${actualSha256}`, + size: input.expectedSize, + sha256: actualSha256, + expectedSha256Verified: expectedSha256 !== undefined, + }; + } else if ( + input.expectedSize !== undefined && + partialSize === input.expectedSize + ) { + await rm(partialPath, { force: true }); + partialSize = 0; + } + await this.streamResponseToFile(input, partialPath, partialSize); + this.assertNotCancelled(input.transactionId); + const completedStat = await stat(partialPath); + const actualSha256 = await hashFile(partialPath); + if ( + completedStat.size <= 0 || + completedStat.size > input.maxBytes || + (input.expectedSize !== undefined && + completedStat.size !== input.expectedSize) || + (expectedSha256 !== undefined && actualSha256 !== expectedSha256) + ) { + await rm(partialPath, { force: true }); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INTEGRITY_FAILED', + 'Firmware artifact size or SHA-256 is invalid', + ); + } + await this.promoteArtifact({ + sourcePath: partialPath, + size: completedStat.size, + sha256: actualSha256, + }); + return { + artifactRef: `fw:${actualSha256}`, + size: completedStat.size, + sha256: actualSha256, + expectedSha256Verified: expectedSha256 !== undefined, + }; + } + + private async streamResponseToFile( + input: IDownloadInput, + partialPath: string, + resumeOffset: number, + ): Promise { + const headers: Record = { + 'Accept-Encoding': 'identity', + ...(resumeOffset > 0 ? { Range: `bytes=${resumeOffset}-` } : {}), + }; + const timeout = Math.min( + Math.ceil(input.overallDeadlineSeconds * 1000), + 30 * 60 * 1000, + ); + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), timeout); + const unregister = this.registerCancellation(input.transactionId, () => + abortController.abort(), + ); + try { + const response = await session.defaultSession.fetch(input.url, { + method: 'GET', + headers, + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + signal: abortController.signal, + }); + if (!response.body) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response body is missing', + ); + } + await this.writeResponseBody({ + input, + partialPath, + resumeOffset, + statusCode: response.status, + contentRange: response.headers.get('content-range') ?? undefined, + contentEncoding: response.headers.get('content-encoding') ?? undefined, + body: Readable.fromWeb(response.body as unknown as NodeReadableStream), + }); + } catch (error) { + if (error instanceof FirmwareArtifactDesktopError) { + throw error; + } + if (this.cancelledTransactions.has(input.transactionId)) { + throw this.createCancelledError(); + } + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_NETWORK_FAILED', + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + } finally { + clearTimeout(timeoutId); + unregister(); + } + } + + private assertNotCancelled(transactionId: string): void { + if (this.cancelledTransactions.has(transactionId)) { + throw this.createCancelledError(); + } + } + + private createCancelledError(): FirmwareArtifactDesktopError { + return new FirmwareArtifactDesktopError( + 'ARTIFACT_CANCELLED', + 'Firmware artifact download was cancelled', + ); + } + + private registerCancellation( + transactionId: string, + cancel: () => void, + ): () => void { + const callbacks = + this.cancellationCallbacks.get(transactionId) ?? new Set<() => void>(); + callbacks.add(cancel); + this.cancellationCallbacks.set(transactionId, callbacks); + if (this.cancelledTransactions.has(transactionId)) cancel(); + return () => { + callbacks.delete(cancel); + if (!callbacks.size) this.cancellationCallbacks.delete(transactionId); + }; + } + + private async writeResponseBody({ + input, + partialPath, + resumeOffset, + statusCode, + contentRange, + contentEncoding, + body, + }: { + input: IDownloadInput; + partialPath: string; + resumeOffset: number; + statusCode: number; + contentRange: string | undefined; + contentEncoding: string | undefined; + body: Readable; + }): Promise { + if (statusCode !== 200 && statusCode !== 206) { + body.destroy(); + throw new FirmwareArtifactDesktopError( + `ARTIFACT_HTTP_${statusCode}`, + 'Firmware request failed', + ); + } + const append = resumeOffset > 0 && statusCode === 206; + if ( + statusCode === 206 && + !this.isValidContentRange( + contentRange, + append ? resumeOffset : 0, + input.expectedSize, + input.maxBytes, + ) + ) { + body.destroy(); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware Content-Range is invalid', + ); + } + if (contentEncoding && contentEncoding !== 'identity') { + body.destroy(); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response content encoding is invalid', + ); + } + let written = append ? resumeOffset : 0; + const limiter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + written += chunk.byteLength; + if (written > input.maxBytes) { + callback( + new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response exceeds maxBytes', + ), + ); + } else { + callback(null, chunk); + } + }, + }); + await pipeline( + body, + limiter, + createWriteStream(partialPath, { + flags: append ? 'a' : 'w', + }), + ); + } + + private isValidContentRange( + value: string | undefined, + expectedStart: number, + expectedTotal: number | undefined, + maxBytes: number, + ): boolean { + const match = /^bytes ([0-9]+)-([0-9]+)\/([0-9]+)$/iu.exec(value ?? ''); + if (!match) return false; + const start = Number(match[1]); + const end = Number(match[2]); + const total = Number(match[3]); + return ( + Number.isSafeInteger(start) && + Number.isSafeInteger(end) && + Number.isSafeInteger(total) && + start === expectedStart && + end >= start && + end < total && + (expectedTotal !== undefined + ? total === expectedTotal + : total > 0 && total <= maxBytes) + ); + } + + private validateExpectedEntries( + entries: NonNullable, + ): Map { + if (!entries.length || entries.length > MAX_ARCHIVE_ENTRIES) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive expected entry count is invalid', + ); + } + const result = new Map(); + const canonicalNames = new Set(); + let totalSize = 0; + for (const entry of entries) { + validatePortableEntryName(entry.entryName, canonicalNames); + assertSafeInteger(entry.expectedSize, 'archive entry size'); + totalSize += entry.expectedSize; + if ( + entry.expectedSize > MAX_ARCHIVE_ENTRY_BYTES || + totalSize > MAX_ARTIFACT_BYTES || + !SHA256_PATTERN.test(entry.expectedSha256) || + result.has(entry.entryName) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive expected entry is invalid', + ); + } + result.set(entry.entryName, entry); + } + return result; + } + + private async validateArchive( + filePath: string, + expectedRequirements: Map | undefined, + ): Promise> { + const zipFile = await openZip(filePath, { + autoClose: true, + lazyEntries: false, + strictFileNames: true, + validateEntrySizes: true, + }); + return new Promise>((resolve, reject) => { + const names = new Set(); + const canonicalNames = new Set(); + const requirements = new Map(); + let totalSize = 0; + zipFile.on('entry', (entry) => { + try { + const entryName = String(entry.fileName); + const expectedRequirement = expectedRequirements?.get(entryName); + const expectedSize = + expectedRequirement?.expectedSize ?? entry.uncompressedSize; + const hostSystem = entry.versionMadeBy >> 8; + const fileType = + (entry.externalFileAttributes >>> 16) & UNIX_FILE_TYPE_MASK; + const usesUnixAttributes = hostSystem === 3 || hostSystem === 19; + const isDirectoryEntry = entryName.endsWith('/'); + const isRegular = usesUnixAttributes + ? fileType === 0 || fileType === UNIX_REGULAR_FILE_TYPE + : (entry.externalFileAttributes & 0x10) === 0; + const hasDirectoryAttributes = usesUnixAttributes + ? fileType === UNIX_DIRECTORY_FILE_TYPE + : !isRegular; + const isEncrypted = (entry.generalPurposeBitFlag & 1) !== 0; + validatePortableEntryName( + isDirectoryEntry ? entryName.slice(0, -1) : entryName, + canonicalNames, + ); + if (isDirectoryEntry) { + if ( + !hasDirectoryAttributes || + entry.uncompressedSize !== 0 || + entry.compressedSize !== 0 || + isEncrypted || + entry.compressionMethod !== 0 + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive directory metadata is invalid', + ); + } + return; + } + totalSize += entry.uncompressedSize; + if ( + (expectedRequirements && !expectedRequirement) || + names.has(entryName) || + entryName.endsWith('/') || + entry.uncompressedSize !== expectedSize || + entry.uncompressedSize <= 0 || + entry.uncompressedSize > MAX_ARCHIVE_ENTRY_BYTES || + totalSize > MAX_ARTIFACT_BYTES || + entry.compressedSize < 0 || + entry.uncompressedSize > Math.max(entry.compressedSize, 1) * 1000 || + isEncrypted || + (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) || + !isRegular + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry metadata does not match the approved entry set', + ); + } + names.add(entryName); + requirements.set(entryName, { + entryName, + expectedSize, + ...(expectedRequirement?.expectedSha256 + ? { expectedSha256: expectedRequirement.expectedSha256 } + : {}), + }); + } catch (error) { + zipFile.close(); + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry metadata is invalid', + { cause: error }, + ), + ); + } + }); + zipFile.once('error', reject); + zipFile.once('end', () => { + if (expectedRequirements && names.size !== expectedRequirements.size) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive has missing or extra entries', + ), + ); + } else { + resolve(requirements); + } + }); + }); + } + + private async extractArchive( + archivePath: string, + scratchPath: string, + requirements: Map, + ): Promise { + const zipFile = await openZip(archivePath, { + autoClose: false, + lazyEntries: true, + strictFileNames: true, + validateEntrySizes: true, + }); + return new Promise((resolve, reject) => { + const staged: IStagedEntry[] = []; + let settled = false; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + zipFile.close(); + reject(error); + }; + zipFile.on('error', fail); + zipFile.on('end', () => { + if (settled) return; + settled = true; + zipFile.close(); + if (staged.length !== requirements.size) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive extraction is incomplete', + ), + ); + } else { + resolve(staged); + } + }); + zipFile.on('entry', (entry) => { + void (async () => { + const entryName = String(entry.fileName); + if (entryName.endsWith('/')) { + zipFile.readEntry(); + return; + } + const requirement = requirements.get(entryName); + if (!requirement) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive contains an unexpected entry', + ); + } + const filePath = path.join(scratchPath, `${staged.length}.entry`); + const digest = createHash('sha256'); + let size = 0; + const verifier = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.byteLength; + if (size > requirement.expectedSize) { + callback( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry exceeds its expected size', + ), + ); + } else { + digest.update(chunk); + callback(null, chunk); + } + }, + }); + await pipeline( + await openZipEntry(zipFile, entry), + verifier, + createWriteStream(filePath, { flags: 'wx' }), + ); + const expectedSha256 = requirement.expectedSha256?.toLowerCase(); + const sha256 = digest.digest('hex'); + if ( + size !== requirement.expectedSize || + (expectedSha256 !== undefined && sha256 !== expectedSha256) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry integrity is invalid', + ); + } + staged.push({ + entryName, + size, + sha256, + filePath, + }); + zipFile.readEntry(); + })().catch(fail); + }); + zipFile.readEntry(); + }); + } + + private artifactPath(sha256: string): string { + return path.join(this.rootPath, `${sha256}.bin`); + } + + private async assertLease(leaseRef: string): Promise { + if (!this.leases.has(this.validateLeaseRef(leaseRef))) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + } + + private async assertLeaseTransaction( + leaseRef: string, + transactionId: string, + ): Promise { + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease || lease.transactionId !== transactionId) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_MISMATCH', + 'Firmware artifact lease transaction does not match', + ); + } + } + + private async promoteArtifact({ + sourcePath, + size, + sha256, + }: { + sourcePath: string; + size: number; + sha256: string; + }): Promise { + const previous = this.promotions.get(sha256) ?? Promise.resolve(); + const promotion = previous + .catch(() => undefined) + .then(async () => { + const destination = this.artifactPath(sha256); + if (await this.isStoredArtifactValid(destination, size, sha256)) { + await rm(sourcePath, { force: true }); + return; + } + await rm(destination, { force: true }); + await rename(sourcePath, destination); + }); + this.promotions.set(sha256, promotion); + try { + await promotion; + } finally { + if (this.promotions.get(sha256) === promotion) { + this.promotions.delete(sha256); + } + } + } + + private async retainExpected({ + leaseRef, + transactionId, + artifactRef, + }: { + leaseRef: string; + transactionId?: string; + artifactRef: string; + }): Promise { + if (!ARTIFACT_REF_PATTERN.test(artifactRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifactRef is invalid', + ); + } + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + if (transactionId && lease.transactionId !== transactionId) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_MISMATCH', + 'Firmware artifact lease transaction does not match', + ); + } + lease.artifactRefs.add(artifactRef); + } + + private validateLeaseRef(leaseRef: string): string { + if (!LEASE_REF_PATTERN.test(leaseRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware leaseRef is invalid', + ); + } + return leaseRef; + } + + private async resolveArtifactPath(artifactRef: string): Promise { + if (!ARTIFACT_REF_PATTERN.test(artifactRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifactRef is invalid', + ); + } + const sha256 = artifactRef.slice(3); + const filePath = this.artifactPath(sha256); + if ((await hashFile(filePath).catch(() => '')) !== sha256) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INTEGRITY_FAILED', + 'Firmware artifact is missing or corrupt', + ); + } + return filePath; + } + + private async isStoredArtifactValid( + filePath: string, + expectedSize: number | undefined, + expectedSha256: string | undefined, + maxBytes = MAX_ARTIFACT_BYTES, + ): Promise { + try { + const fileStat = await stat(filePath); + return ( + fileStat.isFile() && + fileStat.size > 0 && + fileStat.size <= maxBytes && + (expectedSize === undefined || fileStat.size === expectedSize) && + (expectedSha256 === undefined || + (await hashFile(filePath)) === expectedSha256) + ); + } catch { + return false; + } + } +} + +export default DesktopApiFirmwareArtifact; diff --git a/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts b/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts new file mode 100644 index 000000000000..cac1f13422bd --- /dev/null +++ b/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts @@ -0,0 +1,8 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +export class FirmwareArtifactDesktopError extends OneKeyLocalError { + constructor(code: string, message: string, options?: ErrorOptions) { + super(`${code}: ${message}`); + if (options?.cause !== undefined) this.cause = options.cause; + } +} diff --git a/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.test.ts b/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.test.ts new file mode 100644 index 000000000000..a6aa77734504 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.test.ts @@ -0,0 +1,159 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { EAppUpdatePackageAvailabilityStatus } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; + +import { getDownloadedFileAvailability } from './appUpdatePackageAvailability'; + +describe('getDownloadedFileAvailability', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onekey-app-update-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + test('returns missing when path is absent', () => { + expect(getDownloadedFileAvailability()).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + expect( + getDownloadedFileAvailability(path.join(tempDir, 'missing.zip')), + ).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + }); + + test('returns available only for a non-empty regular file', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + + expect(getDownloadedFileAvailability(packagePath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.available, + }); + }); + + test('requires the updater to prepare a macOS package in the current process', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + + expect( + getDownloadedFileAvailability(packagePath, { + requireCurrentProcessPreparation: true, + }), + ).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }); + expect( + getDownloadedFileAvailability(packagePath, { + requireCurrentProcessPreparation: true, + preparedDownloadedFile: packagePath, + }), + ).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.available, + }); + }); + + test('does not accept a different package prepared in the current process', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + + expect( + getDownloadedFileAvailability(packagePath, { + requireCurrentProcessPreparation: true, + preparedDownloadedFile: path.join(tempDir, 'other.zip'), + }), + ).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }); + }); + + test('treats an empty file and a directory as missing', () => { + const emptyPath = path.join(tempDir, 'empty.zip'); + fs.writeFileSync(emptyPath, ''); + + expect(getDownloadedFileAvailability(emptyPath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + expect(getDownloadedFileAvailability(tempDir)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + }); + + test('rejects a symbolic link even when its target is a valid package', () => { + const packagePath = path.join(tempDir, 'package.zip'); + const linkPath = path.join(tempDir, 'package-link.zip'); + fs.writeFileSync(packagePath, 'package'); + fs.symlinkSync(packagePath, linkPath); + + expect(getDownloadedFileAvailability(linkPath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + }); + + test('rejects a path replaced between lstat and fstat', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + const pathStat = fs.lstatSync(packagePath); + jest.spyOn(fs, 'fstatSync').mockReturnValueOnce({ + dev: pathStat.dev, + ino: pathStat.ino + 1, + isFile: () => true, + size: pathStat.size, + } as fs.Stats); + + expect(getDownloadedFileAvailability(packagePath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.missing, + }); + }); + + test('closes the file descriptor when fstat fails', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + const error = new Error('read failure') as NodeJS.ErrnoException; + error.code = 'EIO'; + const closeSpy = jest.spyOn(fs, 'closeSync'); + jest.spyOn(fs, 'fstatSync').mockImplementationOnce(() => { + throw error; + }); + + expect(getDownloadedFileAvailability(packagePath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.unavailable, + errorCode: 'EIO', + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + test('keeps non-missing file-system failures distinct', () => { + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + jest.spyOn(fs, 'lstatSync').mockImplementationOnce(() => { + throw error; + }); + + expect(getDownloadedFileAvailability('/tmp/package.zip')).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.unavailable, + errorCode: 'EACCES', + }); + }); + + test('returns unavailable when a regular file cannot be opened for reading', () => { + const packagePath = path.join(tempDir, 'package.zip'); + fs.writeFileSync(packagePath, 'package'); + const error = new Error('permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + jest.spyOn(fs, 'openSync').mockImplementationOnce(() => { + throw error; + }); + + expect(getDownloadedFileAvailability(packagePath)).toEqual({ + status: EAppUpdatePackageAvailabilityStatus.unavailable, + errorCode: 'EACCES', + }); + }); +}); diff --git a/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.ts b/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.ts new file mode 100644 index 000000000000..93000f1285b3 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/appUpdatePackageAvailability.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; + +import type { IAppUpdatePackageAvailability } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; +import { + EAppUpdatePackageAvailabilityStatus, + EAppUpdatePackageErrorCode, +} from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; + +export function getDownloadedFileAvailability( + downloadedFile?: string, + options?: { + requireCurrentProcessPreparation?: boolean; + preparedDownloadedFile?: string; + }, +): IAppUpdatePackageAvailability { + if (!downloadedFile) { + return { + status: EAppUpdatePackageAvailabilityStatus.missing, + }; + } + try { + const pathStat = fs.lstatSync(downloadedFile); + if (!pathStat.isFile() || pathStat.size <= 0) { + return { + status: EAppUpdatePackageAvailabilityStatus.missing, + }; + } + const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; + const fileDescriptor = fs.openSync( + downloadedFile, + fs.constants.O_RDONLY | noFollowFlag, + ); + let openedFileStat: fs.Stats; + try { + openedFileStat = fs.fstatSync(fileDescriptor); + } finally { + fs.closeSync(fileDescriptor); + } + if ( + !openedFileStat.isFile() || + openedFileStat.size <= 0 || + openedFileStat.dev !== pathStat.dev || + openedFileStat.ino !== pathStat.ino + ) { + return { + status: EAppUpdatePackageAvailabilityStatus.missing, + }; + } + if ( + options?.requireCurrentProcessPreparation && + options.preparedDownloadedFile !== downloadedFile + ) { + return { + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }; + } + return { status: EAppUpdatePackageAvailabilityStatus.available }; + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException)?.code; + if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') { + return { + status: EAppUpdatePackageAvailabilityStatus.missing, + }; + } + return { + status: EAppUpdatePackageAvailabilityStatus.unavailable, + errorCode: errorCode || EAppUpdatePackageErrorCode.packageUnavailable, + }; + } +} diff --git a/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts b/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts index 5b90c1d5a6a3..929436b6d17f 100644 --- a/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts +++ b/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts @@ -4,6 +4,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -21,6 +22,7 @@ export interface IDesktopApi { webview: DesktopApiWebview; notification: DesktopApiNotification; dev: DesktopApiDev; + firmwareArtifact: DesktopApiFirmwareArtifact; inAppPurchase: DesktopApiInAppPurchase; bluetooth: DesktopApiBluetooth; appUpdate: DesktopApiAppUpdate; diff --git a/packages/kit-bg/src/desktopApis/instance/desktopApi.ts b/packages/kit-bg/src/desktopApis/instance/desktopApi.ts index 6c06bc60909c..5e2f798cda38 100644 --- a/packages/kit-bg/src/desktopApis/instance/desktopApi.ts +++ b/packages/kit-bg/src/desktopApis/instance/desktopApi.ts @@ -16,6 +16,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -115,6 +116,14 @@ class DesktopApi implements IDesktopApi { ); } + get firmwareArtifact(): DesktopApiFirmwareArtifact { + return this.getOrCreateModule('firmwareArtifact', () => + this.createModule( + require('../DesktopApiFirmwareArtifact') as typeof import('../DesktopApiFirmwareArtifact'), + ), + ); + } + get bluetooth(): DesktopApiBluetooth { return this.getOrCreateModule('bluetooth', () => this.createModule( diff --git a/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts b/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts index 12f1bfe97d95..573a0ac12f13 100644 --- a/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts +++ b/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts @@ -12,6 +12,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -73,6 +74,9 @@ export class DesktopApiProxy extends RemoteApiProxyBase implements IDesktopApi { dev: DesktopApiDev = this._createProxyModule('dev'); + firmwareArtifact: DesktopApiFirmwareArtifact = + this._createProxyModule('firmwareArtifact'); + inAppPurchase: DesktopApiInAppPurchase = this._createProxyModule('inAppPurchase'); 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/offscreens/instance/offscreenApi.ts b/packages/kit-bg/src/offscreens/instance/offscreenApi.ts index 3d721d4e465e..95ca0694c035 100644 --- a/packages/kit-bg/src/offscreens/instance/offscreenApi.ts +++ b/packages/kit-bg/src/offscreens/instance/offscreenApi.ts @@ -33,7 +33,21 @@ const createOffscreenApiModule = memoizee( }; // chrome.runtime.sendMessage(message); // TODO backgroundApiProxyInOffscreen - void appGlobals.extJsBridgeOffscreenToBg.request({ data: message }); + const bridge = appGlobals.extJsBridgeOffscreenToBg; + if (!bridge) { + console.error( + '[hardwareSDKLowLevel] background bridge is unavailable', + ); + return; + } + void Promise.resolve(bridge.request({ data: message })).catch( + (error: unknown) => { + console.error( + '[hardwareSDKLowLevel] failed to forward event to background', + error, + ); + }, + ); }); } return HardwareLowLevelSDK; 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..11ad2ebaa02d --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.deviceReset.test.ts @@ -0,0 +1,249 @@ +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 localDb from '../../dbs/local/localDb'; + +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: { + removeWallet: jest.fn(), + 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' }); + }); + + it('允许移除已被设备重置标记为 deprecated 的硬件钱包', async () => { + const promptPasswordVerifyByWallet = jest.fn(); + const service = new ServiceAccount({ + backgroundApi: { + servicePassword: { + promptPasswordVerifyByWallet, + }, + serviceDApp: { + removeDappConnectionAfterWalletRemove: jest.fn(), + }, + serviceDBBackup: { + removeBackupHDWallet: jest.fn(), + }, + }, + }); + service.getWalletSafe = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + deprecated: true, + associatedDevice: 'db-device-1', + }); + service.cleanupOrphanedHyperLiquidAgentCredentials = jest.fn(); + + await service.removeWallet({ walletId: 'hw-wallet-1' }); + + expect(promptPasswordVerifyByWallet).not.toHaveBeenCalled(); + expect(jest.mocked(localDb).removeWallet.mock.calls).toContainEqual([ + { + walletId: 'hw-wallet-1', + isRemoveToMocked: undefined, + }, + ]); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hiddenWalletStateSeeding.test.ts b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hiddenWalletStateSeeding.test.ts new file mode 100644 index 000000000000..27452fc1b859 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hiddenWalletStateSeeding.test.ts @@ -0,0 +1,327 @@ +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +import ServiceAccount from './ServiceAccount'; + +import type { IDBDevice } from '../../dbs/local/types'; + +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: { + createHwWallet: jest.fn(), + }, +})); + +const SEEDED_STATE = { + protocol: 'V1', + identity: { deviceId: 'DEVICE_ID_1', serialNo: 'SERIAL_1' }, + status: { mode: 'normal' }, +} as unknown as IOneKeyDeviceState; + +function buildDbDevice(overrides: Partial = {}): IDBDevice { + return { + id: 'device-db-id', + connectId: 'CONNECT_ID_1', + deviceId: 'DEVICE_ID_1', + vendor: EHardwareVendor.onekey, + ...overrides, + } as unknown as IDBDevice; +} + +function buildService({ + dbDevice, + callOrder, + seededState = SEEDED_STATE, + latestDbDevice, +}: { + dbDevice: IDBDevice; + callOrder: string[]; + seededState?: IOneKeyDeviceState; + latestDbDevice?: Partial; +}) { + const getDeviceStateMock = jest.fn().mockImplementation(() => { + callOrder.push('getDeviceState'); + return Promise.resolve(seededState); + }); + const getPassphraseStateMock = jest.fn().mockImplementation(() => { + callOrder.push('getPassphraseState'); + return Promise.resolve('passphrase-state-1'); + }); + const waitForDeviceStateSyncMock = jest.fn().mockImplementation(() => { + callOrder.push('waitForDeviceStateSync'); + return Promise.resolve(); + }); + const getDeviceByConnectIdMock = jest.fn().mockResolvedValue(latestDbDevice); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn().mockResolvedValue('CONNECT_ID_1'), + getDeviceState: getDeviceStateMock, + getPassphraseState: getPassphraseStateMock, + getDeviceByConnectId: getDeviceByConnectIdMock, + waitForDeviceStateSync: waitForDeviceStateSyncMock, + }, + serviceThirdPartyHardware: {}, + serviceHardwareUI: { + withHardwareProcessing: (fn: () => Promise) => fn(), + }, + serviceSetting: { + getHiddenWalletImmediately: jest.fn().mockResolvedValue(true), + }, + serviceAccountProfile: { + isSoftwareWalletOnlyUser: jest.fn().mockResolvedValue(false), + }, + }, + } as never) as unknown as { + createHWHiddenWallet(params: { walletId: string }): Promise; + getWallet: jest.Mock; + getWalletDevice: jest.Mock; + getFeaturesForHwWalletCreate: jest.Mock; + createHWWalletBase: jest.Mock; + setWalletTempStatus: jest.Mock; + }; + service.getWallet = jest + .fn() + .mockResolvedValue({ id: 'hw-wallet-1', deprecated: false }); + service.getWalletDevice = jest.fn().mockResolvedValue(dbDevice); + service.getFeaturesForHwWalletCreate = jest.fn().mockImplementation(() => { + callOrder.push('getFeaturesForHwWalletCreate'); + return Promise.resolve({ deviceId: 'DEVICE_ID_1' }); + }); + service.createHWWalletBase = jest + .fn() + .mockResolvedValue({ wallet: { id: 'hw-wallet-hidden-1' } }); + service.setWalletTempStatus = jest.fn().mockResolvedValue(undefined); + return { + service, + getDeviceStateMock, + getPassphraseStateMock, + getDeviceByConnectIdMock, + }; +} + +describe('createHWHiddenWallet canonical device state seeding', () => { + it('seeds device state before the passphrase session when no snapshot is persisted', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice({ connectProtocol: 'V1' }); + const { service, getDeviceStateMock } = buildService({ + dbDevice, + callOrder, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + expect(getDeviceStateMock).toHaveBeenCalledTimes(1); + expect(getDeviceStateMock).toHaveBeenCalledWith({ + connectId: 'CONNECT_ID_1', + params: { scope: 'runtime', connectProtocol: 'V1' }, + }); + // The live read must happen before the hidden-wallet session is opened, + // otherwise it restores the standard Protocol V1 session. Persistence + // drains happen after the passphrase call and before the final status read. + expect(callOrder).toEqual([ + 'getDeviceState', + 'getPassphraseState', + 'waitForDeviceStateSync', + 'getFeaturesForHwWalletCreate', + 'waitForDeviceStateSync', + ]); + expect(service.getFeaturesForHwWalletCreate).toHaveBeenCalledWith({ + dbDevice: expect.objectContaining({ deviceStateInfo: SEEDED_STATE }), + compatibleConnectId: 'CONNECT_ID_1', + }); + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ + connectProtocol: 'V1', + deviceState: SEEDED_STATE, + passphraseState: 'passphrase-state-1', + }), + ); + }); + + it('skips the live read when a snapshot is already persisted', async () => { + const callOrder: string[] = []; + const persistedState = { + ...SEEDED_STATE, + identity: { deviceId: 'DEVICE_ID_1', serialNo: 'SERIAL_PERSISTED' }, + } as unknown as IOneKeyDeviceState; + const dbDevice = buildDbDevice({ + connectProtocol: 'V1', + deviceStateInfo: persistedState, + }); + const { service, getDeviceStateMock } = buildService({ + dbDevice, + callOrder, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + expect(getDeviceStateMock).not.toHaveBeenCalled(); + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ deviceState: persistedState }), + ); + }); + + it('skips seeding for known Protocol V2 devices', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice({ connectProtocol: 'V2' }); + const { service, getDeviceStateMock } = buildService({ + dbDevice, + callOrder, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + expect(getDeviceStateMock).not.toHaveBeenCalled(); + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ + connectProtocol: 'V2', + deviceState: undefined, + }), + ); + }); + + it('backfills the connect protocol from the seeded state when unknown', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice(); + const { service, getDeviceStateMock } = buildService({ + dbDevice, + callOrder, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + expect(getDeviceStateMock).toHaveBeenCalledWith({ + connectId: 'CONNECT_ID_1', + params: { scope: 'runtime' }, + }); + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ + connectProtocol: 'V1', + deviceState: SEEDED_STATE, + }), + ); + }); + + it('rejects a seeded normal-mode state without a live device identity', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice({ connectProtocol: 'V1' }); + const anonymousState = { + protocol: 'V1', + identity: { deviceId: null, serialNo: 'SERIAL_1' }, + status: { mode: 'normal' }, + } as unknown as IOneKeyDeviceState; + const { service, getPassphraseStateMock } = buildService({ + dbDevice, + callOrder, + seededState: anonymousState, + }); + + await expect( + service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }), + ).rejects.toThrow('Unable to resolve live hardware device identity'); + + // Fail fast: the guard fires before the passphrase prompt or any creation. + expect(getPassphraseStateMock).not.toHaveBeenCalled(); + expect(service.createHWWalletBase).not.toHaveBeenCalled(); + }); + + it('prefers the persisted post-unlock snapshot over the pre-unlock seed', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice({ connectProtocol: 'V1' }); + const postUnlockState = { + ...SEEDED_STATE, + status: { mode: 'normal', unlocked: true, unlockedAttachPin: false }, + } as unknown as IOneKeyDeviceState; + const { service } = buildService({ + dbDevice, + callOrder, + latestDbDevice: { deviceStateInfo: postUnlockState }, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + // Downstream steps must consume the persisted post-unlock snapshot, not + // the pre-unlock seed captured before the passphrase prompt. + expect(service.getFeaturesForHwWalletCreate).toHaveBeenCalledWith({ + dbDevice: expect.objectContaining({ deviceStateInfo: postUnlockState }), + compatibleConnectId: 'CONNECT_ID_1', + }); + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ deviceState: postUnlockState }), + ); + }); + + it('never queries by an empty connectId for the post-unlock refresh', async () => { + const callOrder: string[] = []; + // Third-party USB records may legitimately have no connectId; an empty + // connectId lookup would degenerate to "first OneKey device" in the DB. + const dbDevice = buildDbDevice({ connectProtocol: 'V1', connectId: '' }); + const { service, getDeviceByConnectIdMock } = buildService({ + dbDevice, + callOrder, + latestDbDevice: { + deviceStateInfo: { + ...SEEDED_STATE, + identity: { deviceId: 'UNRELATED_DEVICE', serialNo: 'UNRELATED' }, + } as unknown as IOneKeyDeviceState, + }, + }); + + await service.createHWHiddenWallet({ walletId: 'hw-wallet-1' }); + + expect(getDeviceByConnectIdMock).not.toHaveBeenCalled(); + // The seeded state stays in place instead of an unrelated device's record. + expect(service.createHWWalletBase).toHaveBeenCalledWith( + expect.objectContaining({ deviceState: SEEDED_STATE }), + ); + }); + + it('reports isAttachPinMode from the freshest persisted post-unlock state', async () => { + const callOrder: string[] = []; + const dbDevice = buildDbDevice({ connectProtocol: 'V1' }); + const { service } = buildService({ + dbDevice, + callOrder, + // The seeded pre-unlock snapshot says no attach-PIN unlock, but the + // state persisted during the passphrase/derivation calls says yes. + latestDbDevice: { + deviceStateInfo: { + ...SEEDED_STATE, + status: { mode: 'normal', unlockedAttachPin: true }, + } as unknown as IOneKeyDeviceState, + }, + }); + + const result = (await service.createHWHiddenWallet({ + walletId: 'hw-wallet-1', + })) as { isAttachPinMode?: boolean }; + + expect(result.isAttachPinMode).toBe(true); + }); +}); 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..6eec2393a720 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/ServiceAccount.hwWalletCreateAddress.test.ts @@ -0,0 +1,311 @@ +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import localDb from '../../dbs/local/localDb'; + +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: { + createHwWallet: jest.fn(), + }, +})); + +type IHwWalletCreateAddressService = { + createHWWalletBase(params: unknown): Promise<{ wallet: { name: string } }>; + setWalletNameAndAvatar(params: unknown): Promise<{ name: string }>; + getWallet(params: unknown): Promise<{ name: string }>; + 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', () => { + const createHwWalletMock = jest.spyOn(localDb, 'createHwWallet'); + + beforeEach(() => { + createHwWalletMock.mockReset(); + }); + + it('persists the current Pro2 label after reading the stored wallet name', async () => { + createHwWalletMock.mockResolvedValue({ + wallet: { id: 'hw-wallet-1', name: 'Previous device name' }, + } as never); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn().mockResolvedValue('PRO2_USB'), + }, + }, + }) as unknown as IHwWalletCreateAddressService; + const setWalletNameAndAvatarMock = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + name: 'Current device name', + }); + service.setWalletNameAndAvatar = setWalletNameAndAvatarMock; + + await expect( + service.createHWWalletBase({ + device: { connectId: 'PRO2_USB', deviceId: 'PRO2_DEVICE_ID' }, + features: { deviceId: 'PRO2_DEVICE_ID' }, + deviceState: { + protocol: 'V2', + identity: { + deviceId: 'PRO2_DEVICE_ID', + label: 'Current device name', + }, + }, + isMockedStandardHwWallet: true, + }), + ).resolves.toMatchObject({ wallet: { name: 'Current device name' } }); + expect(setWalletNameAndAvatarMock).toHaveBeenCalledWith({ + walletId: 'hw-wallet-1', + name: 'Current device name', + shouldCheckDuplicate: false, + }); + }); + + it('keeps wallet creation successful when Pro2 label persistence fails', async () => { + createHwWalletMock.mockResolvedValue({ + wallet: { id: 'hw-wallet-1', name: 'Previous device name' }, + } as never); + const service = new ServiceAccount({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId: jest.fn().mockResolvedValue('PRO2_USB'), + }, + }, + }) as unknown as IHwWalletCreateAddressService; + service.setWalletNameAndAvatar = jest + .fn() + .mockRejectedValue(new Error('sync failed')); + const getWalletMock = jest.fn().mockResolvedValue({ + id: 'hw-wallet-1', + name: 'Previous device name', + }); + service.getWallet = getWalletMock; + + await expect( + service.createHWWalletBase({ + device: { connectId: 'PRO2_USB', deviceId: 'PRO2_DEVICE_ID' }, + features: { deviceId: 'PRO2_DEVICE_ID' }, + deviceState: { + protocol: 'V2', + identity: { + deviceId: 'PRO2_DEVICE_ID', + label: 'Current device name', + }, + }, + isMockedStandardHwWallet: true, + }), + ).resolves.toMatchObject({ wallet: { name: 'Previous device name' } }); + expect(getWalletMock).toHaveBeenCalledWith({ + walletId: 'hw-wallet-1', + }); + }); + + 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..e1f51e8634df 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,12 @@ import { isDefaultBotWalletName, resolveBotWalletSyncItemDataTime, } from './botWalletCreateUtils'; +import { buildBtcOnlyFirmwareCacheKey } from './btcOnlyFirmwareCacheUtils'; +import { + getStandardHwWalletLabelForNameSync, + refreshDeviceStateAfterStandardWalletUnlock, + resolveDeviceStateForHwWalletCreate, +} from './deviceStateForHwWalletCreate'; import { getHwHiddenWalletPassphraseState } from './hardwarePassphraseState'; import { type IKeylessWalletRemovalCapability, @@ -237,7 +247,9 @@ import type { IPrepareWatchingAccountsParams, IValidateGeneralInputParams, } from '../../vaults/types'; +import type { IOneKeyHardwareOperationLease } from '../ServiceHardwareUI/HardwareProcessingManager'; import type { IWithHardwareProcessingControlParams } from '../ServiceHardwareUI/ServiceHardwareUI'; +import type { SearchDevice } from '@onekeyfe/hd-core'; export type IAddHDOrHWAccountsParams = { walletId: string | undefined; @@ -252,6 +264,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 +1282,7 @@ class ServiceAccount extends ServiceBase { skipDeviceCancelAtFirst, hideCheckingDeviceLoading, skipWaitingAnimationAtFirst, + oneKeyOperationLease, } = params; const { prepareParams, deviceParams, networkId, walletId } = @@ -1304,6 +1318,7 @@ class ServiceAccount extends ServiceBase { hideCheckingDeviceLoading, debugMethodName: 'keyring.prepareAccounts', skipWaitingAnimationAtFirst, + oneKeyOperationLease, }, ); @@ -3447,6 +3462,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 @@ -3458,9 +3481,6 @@ class ServiceAccount extends ServiceBase { featuresDeviceId: dbDevice.deviceId, features: dbDevice.featuresInfo, hardwareCallContext, - // We hold the record here, so pass its vendor: lets the resolver find - // a third-party device and pick its transport-correct connectId - // (Trezor BLE session -> bleConnectId instead of the deviceId). vendor: dbDevice.vendor, }); } catch (error) { @@ -3470,6 +3490,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 +3516,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 +3544,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); } - return features || dbDevice.featuresInfo || ({} as IOneKeyDeviceFeatures); + if (features) { + return features; + } + if ( + (dbDevice.vendor ?? EHardwareVendor.onekey) === EHardwareVendor.onekey && + dbDevice.deviceStateInfo + ) { + return projectLegacyDeviceFeaturesFromState(dbDevice.deviceStateInfo); + } + return dbDevice.featuresInfo || ({} as IOneKeyDeviceFeatures); } private async getFirstEvmAddressForHwWalletCreate({ @@ -3527,15 +3585,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,15 +3607,22 @@ 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, featuresDeviceId: dbDevice.deviceId, - // Without the vendor the lookup defaults to OneKey and misses a - // third-party device row, so a Trezor main connectId (deviceId) leaks - // raw into the BLE session and hangs on the noble connect timeout. vendor: dbDevice.vendor, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); @@ -3574,10 +3630,61 @@ class ServiceAccount extends ServiceBase { // createHWHiddenWallet return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( async () => { + // Seed the canonical device state BEFORE opening the hidden-wallet + // passphrase session: a live state read on Protocol V1 restores the + // standard session, so reading it after getPassphraseState would + // clobber the hidden session and cost extra device round trips to + // re-establish it (see getFeaturesForHwWalletCreate). Known-V2 devices + // are excluded — their flow always reads live state by design. An + // unknown-protocol device that turns out V2 pays one redundant read, + // but that class is practically empty: V2 device records have always + // stored connectProtocol since the protocol field was introduced. + let seededDbDevice = dbDevice; + let seededConnectProtocol = connectProtocol; + const hiddenWalletVendorProfile = getVendorProfile( + dbDevice.vendor ?? EHardwareVendor.onekey, + ); + if ( + !seededDbDevice.deviceStateInfo && + !hiddenWalletVendorProfile.isThirdParty && + seededConnectProtocol !== 'V2' + ) { + const seededState = + await this.backgroundApi.serviceHardware.getDeviceState({ + connectId: compatibleConnectId, + params: { + scope: 'runtime', + ...(seededConnectProtocol + ? { connectProtocol: seededConnectProtocol } + : {}), + }, + }); + // Mirror the live-identity guard in resolveDeviceStateForHwWalletCreate: + // with a seeded snapshot its fast path returns early, so the guard + // must run here to keep the old no-snapshot fail-fast behavior. + if ( + seededState.status.mode === 'normal' && + !seededState.identity.deviceId + ) { + throw new OneKeyLocalError( + 'Unable to resolve live hardware device identity', + ); + } + // DEVICE.STATE persistence is async; carry the snapshot in memory so + // downstream steps take their fast paths deterministically. + seededDbDevice = { ...seededDbDevice, deviceStateInfo: seededState }; + if ( + !seededConnectProtocol && + (seededState.protocol === 'V1' || seededState.protocol === 'V2') + ) { + seededConnectProtocol = seededState.protocol; + } + } + const passphraseState = await getHwHiddenWalletPassphraseState({ vendor: dbDevice.vendor, connectId: compatibleConnectId, - dbDevice, + dbDevice: seededDbDevice, serviceHardware: this.backgroundApi.serviceHardware, serviceThirdPartyHardware: this.backgroundApi.serviceThirdPartyHardware, @@ -3596,14 +3703,46 @@ class ServiceAccount extends ServiceBase { throw deviceNotOpenedPassphraseError; } + // The passphrase call above emitted DEVICE.STATE events (including the + // unlock / attach-PIN status); wait for their persistence and prefer + // the persisted post-unlock snapshot over the pre-unlock seed for + // everything derived or stored below. Pure DB reads — no device I/O, + // so the hidden session established above is never touched. Gated like + // the seeding block: third-party vendors are excluded, and an empty + // connectId must not reach getDeviceByConnectId — getDeviceByQuery + // would degenerate to "first OneKey device" and overlay an unrelated + // device's state. + if (connectId && !hiddenWalletVendorProfile.isThirdParty) { + await this.backgroundApi.serviceHardware.waitForDeviceStateSync({ + connectIds: [ + compatibleConnectId, + dbDevice.connectId, + dbDevice.deviceId, + seededDbDevice.deviceStateInfo?.identity.serialNo, + ], + }); + const postUnlockDbDevice = + await this.backgroundApi.serviceHardware.getDeviceByConnectId({ + connectId, + }); + if (postUnlockDbDevice?.deviceStateInfo) { + seededDbDevice = { + ...seededDbDevice, + deviceStateInfo: postUnlockDbDevice.deviceStateInfo, + }; + } + } + // TODO save remember states const resolvedFeatures = await this.getFeaturesForHwWalletCreate({ - dbDevice, + dbDevice: seededDbDevice, compatibleConnectId, }); const dbWallet = await this.createHWWalletBase({ - device: deviceUtils.dbDeviceToSearchDevice(dbDevice), + device: deviceUtils.dbDeviceToSearchDevice(seededDbDevice), features: resolvedFeatures, + connectProtocol: seededConnectProtocol, + deviceState: seededDbDevice.deviceStateInfo, passphraseState, fillingXfpByCallingSdk: true, }); @@ -3632,9 +3771,40 @@ class ServiceAccount extends ServiceBase { await this.backgroundApi.serviceAccountProfile.isSoftwareWalletOnlyUser(), }); + // resolvedFeatures already reflects the post-unlock snapshot refreshed + // above, but the XFP / address derivation calls inside + // createHWWalletBase may have emitted newer DEVICE.STATE events; drain + // the persistence queue once more and prefer the latest stored status. + let isAttachPinMode = resolvedFeatures.unlockedAttachPin; + // Same gate as the post-unlock refresh above: attach-PIN is + // OneKey-specific, and an empty connectId must not reach the lookup. + if (connectId && !hiddenWalletVendorProfile.isThirdParty) { + try { + await this.backgroundApi.serviceHardware.waitForDeviceStateSync({ + connectIds: [ + compatibleConnectId, + dbDevice.connectId, + dbDevice.deviceId, + seededDbDevice.deviceStateInfo?.identity.serialNo, + ], + }); + const latestDbDevice = + await this.backgroundApi.serviceHardware.getDeviceByConnectId({ + connectId, + }); + const latestUnlockedAttachPin = + latestDbDevice?.deviceStateInfo?.status?.unlockedAttachPin; + if (typeof latestUnlockedAttachPin === 'boolean') { + isAttachPinMode = latestUnlockedAttachPin; + } + } catch { + // keep the resolved-features fallback + } + } + return { ...dbWallet, - isAttachPinMode: resolvedFeatures.unlocked_attach_pin, + isAttachPinMode, }; }, { @@ -3672,14 +3842,15 @@ class ServiceAccount extends ServiceBase { hardwareForceTransportAtomState.forceTransportType || (await this.backgroundApi.serviceSetting.getHardwareTransportType()); - // Don't trust the global transport flag alone — use the picked device's - // actual connectionType (carried on `device.raw` for third-party devices; - // absent for OneKey HD, so they're unaffected). + // Persist the endpoint selected from the fused scan. The global setting is + // only a fallback when the selected device has no transport metadata. const transportType = resolveHwWalletTransportType({ globalTransportType, deviceConnectionType: ( params.device as { raw?: { connectionType?: 'usb' | 'ble' } } ).raw?.connectionType, + deviceCommType: (params.device as { commType?: SearchDevice['commType'] }) + .commType, isNative: !!platformEnv.isNative, }); @@ -3749,29 +3920,48 @@ class ServiceAccount extends ServiceBase { : await this.backgroundApi.serviceHardware.getCompatibleConnectId({ connectId: params.device.connectId ?? '', featuresDeviceId: params.device.deviceId ?? '', - // Third-party rows are invisible to the default (OneKey) lookup; - // without this a Trezor arriving with its main connectId (e.g. the - // hidden-wallet path passes the DB record) keeps the raw deviceId. vendor, 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 +3979,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 || '', @@ -3816,6 +4037,30 @@ class ServiceAccount extends ServiceBase { : undefined, transportType, }); + const deviceLabel = getStandardHwWalletLabelForNameSync({ + currentWalletName: result.wallet.name, + deviceState, + explicitName: params.name, + isThirdParty: Boolean(vendorProfile?.isThirdParty), + passphraseState, + }); + if (deviceLabel) { + try { + result.wallet = await this.setWalletNameAndAvatar({ + walletId: result.wallet.id, + name: deviceLabel, + shouldCheckDuplicate: false, + }); + } catch (error) { + defaultLogger.hardware.sdkLog.log( + 'createHWWalletBase: unable to persist device label', + error instanceof Error ? error.message : 'Unknown error', + ); + result.wallet = await this.getWallet({ walletId: result.wallet.id }); + } + } else { + result.wallet = await this.getWallet({ walletId: result.wallet.id }); + } // Third-party chain fingerprints are generated lazily by the keyring via SDK. // Trezor: THP pairing credentials were minted while probing the device above @@ -5152,10 +5397,15 @@ class ServiceAccount extends ServiceBase { let wallet = await this.getWalletSafe({ walletId }); assertWalletCanUseGenericRemoval(wallet); - await this.backgroundApi.servicePassword.promptPasswordVerifyByWallet({ - walletId, - hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, - }); + const shouldSkipUnavailableHardwareCheck = + accountUtils.isHwWallet({ walletId }) && + accountUtils.isWalletDeprecatedOrMocked(wallet); + if (!shouldSkipUnavailableHardwareCheck) { + await this.backgroundApi.servicePassword.promptPasswordVerifyByWallet({ + walletId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + }); + } wallet = await this.getWalletSafe({ walletId }); assertWalletCanUseGenericRemoval(wallet); @@ -5752,7 +6002,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 +6018,7 @@ class ServiceAccount extends ServiceBase { indexes: prepareParams.indexes, showOnOneKey: true, isVerifyAddressAction: prepareParams.isVerifyAddressAction, + oneKeyOperationLease, }, ); const results: string[] = []; @@ -6665,7 +6916,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 +7041,7 @@ class ServiceAccount extends ServiceBase { deviceId: string | undefined; withUserInteraction: boolean; }) { - await this.generateHwWalletsMissingXfpDebounced({ + this.generateHwWalletsMissingXfpDebounced({ wallet, connectId, deviceId, @@ -8058,16 +8326,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/defaultNetworkAccountsConfig.test.ts b/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.test.ts index 131e9d939cbb..b93ae6df3d27 100644 --- a/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.test.ts +++ b/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.test.ts @@ -1,7 +1,12 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; -import { buildDefaultAddAccountNetworks } from './defaultNetworkAccountsConfig'; +import { + buildDefaultAddAccountNetworks, + buildDefaultAddAccountNetworksForQrWallet, +} from './defaultNetworkAccountsConfig'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; @@ -84,3 +89,41 @@ describe('buildDefaultAddAccountNetworks', () => { expect(networks).toEqual([]); }); }); + +describe('buildDefaultAddAccountNetworksForQrWallet', () => { + const backgroundApi = { + serviceNetwork: { + getGlobalDeriveTypeOfNetwork: jest.fn(async () => undefined), + }, + } as unknown as IBackgroundApi; + + it('excludes Solana from Pro2 QR wallet defaults', async () => { + const networkIdsMap = getNetworkIdsMap(); + const networks = await buildDefaultAddAccountNetworksForQrWallet({ + backgroundApi, + walletId: 'qr-pro2-wallet', + deviceType: EDeviceType.Pro2, + firmwareType: undefined, + includingNetworkWithGlobalDeriveType: true, + }); + + expect(new Set(networks.map((network) => network.networkId))).toEqual( + new Set([networkIdsMap.btc, networkIdsMap.eth]), + ); + }); + + it('keeps Solana in legacy Pro QR wallet defaults', async () => { + const networkIdsMap = getNetworkIdsMap(); + const networks = await buildDefaultAddAccountNetworksForQrWallet({ + backgroundApi, + walletId: 'qr-pro-wallet', + deviceType: EDeviceType.Pro, + firmwareType: undefined, + includingNetworkWithGlobalDeriveType: true, + }); + + expect(new Set(networks.map((network) => network.networkId))).toEqual( + new Set([networkIdsMap.btc, networkIdsMap.eth, networkIdsMap.sol]), + ); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.ts b/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.ts index 9e6da82bb5ad..55200e4af13b 100644 --- a/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.ts +++ b/packages/kit-bg/src/services/ServiceAccount/defaultNetworkAccountsConfig.ts @@ -1,4 +1,4 @@ -import { EFirmwareType } from '@onekeyfe/hd-shared'; +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import { uniqBy } from 'lodash'; import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; @@ -8,10 +8,12 @@ import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IAccountDeriveTypes } from '../../vaults/types'; +import type { IDeviceType } from '@onekeyfe/hd-core'; type IBuildDefaultAddAccountNetworksParams = { backgroundApi: IBackgroundApi; walletId: string; + deviceType?: IDeviceType; includingNetworkWithGlobalDeriveType?: boolean; firmwareType: EFirmwareType | undefined; /** true when called from wallet-creation flow; undefined/false means add-account flow */ @@ -254,7 +256,7 @@ export async function buildDefaultAddAccountNetworks( export async function buildDefaultAddAccountNetworksForQrWallet( params: IBuildDefaultAddAccountNetworksParams, ) { - const { firmwareType } = params; + const { deviceType, firmwareType } = params; if (firmwareType === EFirmwareType.BitcoinOnly) { return buildAddAccountsNetworks({ ...params, @@ -267,7 +269,7 @@ export async function buildDefaultAddAccountNetworksForQrWallet( ...params, btc: true, evm: true, - sol: true, + sol: deviceType !== EDeviceType.Pro2, }); return networks; } 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..33a5b0dd017e --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.test.ts @@ -0,0 +1,246 @@ +import { + getStandardHwWalletLabelForNameSync, + refreshDeviceStateAfterStandardWalletUnlock, + resolveDeviceStateForHwWalletCreate, +} from './deviceStateForHwWalletCreate'; + +describe('getStandardHwWalletLabelForNameSync', () => { + const deviceState = { + protocol: 'V2', + identity: { label: 'Current device name' }, + } as never; + + it('returns a changed Pro2 standard-wallet label for persisted name sync', () => { + expect( + getStandardHwWalletLabelForNameSync({ + currentWalletName: 'Previous device name', + deviceState, + isThirdParty: false, + }), + ).toBe('Current device name'); + }); + + it('skips sync when the wallet already has the current label', () => { + expect( + getStandardHwWalletLabelForNameSync({ + currentWalletName: 'Current device name', + deviceState, + isThirdParty: false, + }), + ).toBeUndefined(); + }); + + it.each([ + { explicitName: 'Custom name' }, + { passphraseState: 'hidden-session' }, + { isThirdParty: true }, + { deviceState: { protocol: 'V1', identity: { label: 'Classic' } } }, + ])('does not sync outside a Pro2 standard-wallet restore', (overrides) => { + expect( + getStandardHwWalletLabelForNameSync({ + currentWalletName: 'Previous device name', + deviceState, + isThirdParty: false, + ...overrides, + } as never), + ).toBeUndefined(); + }); +}); + +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..c500f45b776b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceAccount/deviceStateForHwWalletCreate.ts @@ -0,0 +1,109 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +type IGetDeviceStateForHwWalletCreate = ( + connectId: string, + params: { scope: 'runtime' }, +) => Promise; + +export function getStandardHwWalletLabelForNameSync({ + currentWalletName, + deviceState, + explicitName, + isThirdParty, + passphraseState, +}: { + currentWalletName: string; + deviceState?: IOneKeyDeviceState; + explicitName?: string; + isThirdParty: boolean; + passphraseState?: string; +}): string | undefined { + const label = deviceState?.identity.label; + if ( + deviceState?.protocol !== 'V2' || + isThirdParty || + passphraseState || + explicitName || + !label || + label === currentWalletName + ) { + return undefined; + } + return label; +} + +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/ServiceAccount/resolveHwWalletTransportType.test.ts b/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.test.ts index fcf2ffb911b5..54c179485b88 100644 --- a/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.test.ts +++ b/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.test.ts @@ -69,6 +69,34 @@ describe('resolveHwWalletTransportType', () => { ).toBe(EHardwareTransportType.WEBUSB); }); + it.each(['ble', 'webble', 'electron-ble'] as const)( + 'persists a desktop OneKey %s selection as DesktopWebBle under a USB default', + (deviceCommType) => { + expect( + resolveHwWalletTransportType({ + globalTransportType: EHardwareTransportType.WEBUSB, + deviceConnectionType: undefined, + deviceCommType, + isNative: false, + }), + ).toBe(EHardwareTransportType.DesktopWebBle); + }, + ); + + it.each(['usb', 'webusb', 'bridge'] as const)( + 'persists a desktop OneKey %s selection as WEBUSB under a BLE default', + (deviceCommType) => { + expect( + resolveHwWalletTransportType({ + globalTransportType: EHardwareTransportType.DesktopWebBle, + deviceConnectionType: undefined, + deviceCommType, + isNative: false, + }), + ).toBe(EHardwareTransportType.WEBUSB); + }, + ); + it('does not pull a USB device to WEBUSB on native (USB is not a native transport)', () => { expect( resolveHwWalletTransportType({ @@ -96,9 +124,7 @@ describe('resolveHwWalletTransportType', () => { ).toBe(EHardwareTransportType.DesktopWebBle); }); - it('is a no-op when connectionType is unknown (OneKey HD)', () => { - // OneKey HD devices carry no connectionType → global value is preserved. - // (Third-party Trezor/Ledger devices always carry one and are corrected above.) + it('keeps the global value when neither connectionType nor commType is known', () => { for (const global of [ EHardwareTransportType.WEBUSB, EHardwareTransportType.Bridge, diff --git a/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.ts b/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.ts index 1ccca65ea80e..e57fbd433204 100644 --- a/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.ts +++ b/packages/kit-bg/src/services/ServiceAccount/resolveHwWalletTransportType.ts @@ -1,43 +1,51 @@ import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { SearchDevice } from '@onekeyfe/hd-core'; + /** - * Decide which transport a hardware wallet record is stored under. - * - * The global transport flag (force atom / settings) is only a UI default. The - * picked device's ACTUAL connectionType is authoritative: the desktop fused - * USB+BLE scan can surface either transport while the global default points the - * other way, and trusting the global would file the handle under the wrong - * field (BLE handle into usbConnectId, or a USB serial into bleConnectId), - * leaving the device unreachable on its real transport. - * - * Both mismatches are corrected, in both directions: - * - BLE device under a USB-family default → BLE (native) / DesktopWebBle. - * - USB device under a BLE-family default → WEBUSB (desktop/web only; USB is - * not a native transport). WEBUSB is the desktop USB default; a Bridge user's - * global is already USB-family, so this branch never overrides Bridge. - * - * Only third-party devices (Trezor + Ledger) carry a connectionType. OneKey HD - * devices carry none → the global value is returned unchanged, so the OneKey HD - * flow is untouched. + * Resolve the transport from the device endpoint that was actually selected. + * The global transport is only a fallback: OneKey devices expose commType, + * while third-party fused scans expose raw.connectionType. */ export function resolveHwWalletTransportType(params: { globalTransportType: EHardwareTransportType; deviceConnectionType: 'usb' | 'ble' | undefined; + deviceCommType?: SearchDevice['commType']; isNative: boolean; }): EHardwareTransportType { - const { globalTransportType, deviceConnectionType, isNative } = params; + const { + globalTransportType, + deviceConnectionType, + deviceCommType, + isNative, + } = params; + let commTypeConnectionType: 'usb' | 'ble' | undefined; + if ( + deviceCommType === 'ble' || + deviceCommType === 'webble' || + deviceCommType === 'electron-ble' + ) { + commTypeConnectionType = 'ble'; + } else if ( + deviceCommType === 'usb' || + deviceCommType === 'webusb' || + deviceCommType === 'bridge' + ) { + commTypeConnectionType = 'usb'; + } + const actualConnectionType = deviceConnectionType ?? commTypeConnectionType; const globalIsUsb = globalTransportType === EHardwareTransportType.WEBUSB || globalTransportType === EHardwareTransportType.Bridge; const globalIsBle = globalTransportType === EHardwareTransportType.BLE || globalTransportType === EHardwareTransportType.DesktopWebBle; - if (deviceConnectionType === 'ble' && globalIsUsb) { + if (actualConnectionType === 'ble' && globalIsUsb) { return isNative ? EHardwareTransportType.BLE : EHardwareTransportType.DesktopWebBle; } - if (deviceConnectionType === 'usb' && globalIsBle && !isNative) { + if (actualConnectionType === 'usb' && globalIsBle && !isNative) { return EHardwareTransportType.WEBUSB; } return globalTransportType; diff --git a/packages/kit-bg/src/services/ServiceAppUpdate.pendingInstallTask.test.ts b/packages/kit-bg/src/services/ServiceAppUpdate.pendingInstallTask.test.ts index 2000d42ee0e7..0b5e272df61e 100644 --- a/packages/kit-bg/src/services/ServiceAppUpdate.pendingInstallTask.test.ts +++ b/packages/kit-bg/src/services/ServiceAppUpdate.pendingInstallTask.test.ts @@ -66,6 +66,7 @@ jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ default: { version: '1.0.0', bundleVersion: '1', + isDesktop: true, isExtension: false, isNativeAndroid: false, }, @@ -73,13 +74,16 @@ jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ jest.mock('@onekeyhq/shared/src/modules3rdParty/auto-update', () => ({ AppUpdate: { + checkPackageAvailability: jest.fn(async () => ({ + status: 'notApplicable', + })), downloadPackage: jest.fn(async () => ({ downloadedFile: '/tmp/app.pkg', })), verifyPackage: jest.fn(async () => undefined), verifyASC: jest.fn(async () => undefined), downloadASC: jest.fn(async () => undefined), - installPackage: jest.fn(async () => undefined), + installPackage: jest.fn(async () => true), clearPackage: jest.fn(async () => undefined), }, BundleUpdate: { @@ -366,6 +370,38 @@ describe('ServiceAppUpdate pendingInstallTask scheduling', () => { }); }); + test('readyToInstall publishes ready only after the pending task is durable', async () => { + let statusWhenPendingTaskWasWritten: EAppUpdateStatus | undefined; + appStorageMock.syncStorage.setObject.mockImplementationOnce( + async (_key: string, task: any) => { + statusWhenPendingTaskWasWritten = appUpdateState.status; + pendingTaskValue = task; + return pendingTaskValue; + }, + ); + setReadyState({ + latestVersion: '2.0.0', + jsBundleVersion: '1', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + isUpdaterRehydrated: true, + }, + }); + + await service.readyToInstall(); + + expect(statusWhenPendingTaskWasWritten).toBe( + EAppUpdateStatus.verifyPackage, + ); + expect(pendingTaskValue).toMatchObject({ + type: 'app-install', + status: 'pending', + }); + expect(appUpdateState.status).toBe(EAppUpdateStatus.ready); + }); + test('readyToInstall does not create pending task for non-seamless strategy', async () => { setReadyState({ updateStrategy: EUpdateStrategy.manual, @@ -820,9 +856,12 @@ describe('processPendingInstallTask', () => { }); }); - test('appshell APP_PACKAGE_MISSING when no downloadedFile triggers retry', async () => { + test('appshell APP_PACKAGE_MISSING triggers full-flow re-download', async () => { // Clear downloadedEvent from atom resetAppUpdateState({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, downloadedEvent: undefined, }); resetPendingTask({ @@ -847,11 +886,8 @@ describe('processPendingInstallTask', () => { await service.processPendingInstallTask(); - expect(pendingTaskValue).toMatchObject({ - status: 'pending', - retryCount: 1, - lastError: 'APP_PACKAGE_MISSING', - }); + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.fullFlowRetryByTarget?.['2.0.0:1']?.count).toBe(1); }); test('app-install in applied_waiting_verify within grace period is skipped', async () => { diff --git a/packages/kit-bg/src/services/ServiceAppUpdate.test.ts b/packages/kit-bg/src/services/ServiceAppUpdate.test.ts index 18c422b8246b..4c4e56355ae5 100644 --- a/packages/kit-bg/src/services/ServiceAppUpdate.test.ts +++ b/packages/kit-bg/src/services/ServiceAppUpdate.test.ts @@ -20,6 +20,7 @@ import { appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { EAppUpdatePackageAvailabilityStatus } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; @@ -127,11 +128,14 @@ jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ // --------------------------------------------------------------------------- jest.mock('@onekeyhq/shared/src/modules3rdParty/auto-update', () => ({ AppUpdate: { + checkPackageAvailability: jest.fn(async () => ({ + status: 'notApplicable', + })), downloadPackage: jest.fn(async () => ({})), verifyPackage: jest.fn(async () => undefined), verifyASC: jest.fn(async () => undefined), downloadASC: jest.fn(async () => undefined), - installPackage: jest.fn(async () => undefined), + installPackage: jest.fn(async () => true), manualInstallPackage: jest.fn(async () => undefined), clearPackage: jest.fn(async () => undefined), }, @@ -393,6 +397,366 @@ describe('ServiceAppUpdate state transitions', () => { }); }); + describe('app shell package reconciliation', () => { + test('first launch after app update preserves the completed package state', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + resetAtom({ + latestVersion: '1.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.manual, + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-1.0.0.zip', + downloadedFile: '/tmp/app-1.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.ready); + expect(result.downloadedEvent?.downloadedFile).toBe('/tmp/app-1.0.0.zip'); + expect(AppUpdate.checkPackageAvailability).not.toHaveBeenCalled(); + }); + + test('missing manual package invalidates ready state and clears matching pending task', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.manual, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + resetPendingTask({ + taskId: 'appShell:2.0.0:direct', + revision: 1, + action: EPendingInstallTaskAction.installApp, + type: EPendingInstallTaskType.appInstall, + targetAppVersion: '2.0.0', + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.updateIncomplete); + expect(result.downloadedEvent).toBeUndefined(); + expect(pendingInstallTaskValue).toBeUndefined(); + }); + + test('missing seamless package returns to notify for a full re-download', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.notify); + expect(result.downloadedEvent).toBeUndefined(); + expect(result.fullFlowRetryByTarget?.['recovery:2.0.0:1']?.count).toBe(1); + + const emitSpy = jest.spyOn(appEventBus, 'emit'); + await jest.runAllTimersAsync(); + expect(emitSpy).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }); + + test('expired full-flow retry count is pruned before reconciliation', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + fullFlowRetryByTarget: { + 'recovery:2.0.0:1': { + count: 2, + updatedAt: Date.now() - 8 * 24 * 60 * 60 * 1000, + }, + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.notify); + expect(result.fullFlowRetryByTarget?.['recovery:2.0.0:1']?.count).toBe(1); + }); + + test('repeated missing auto-update packages stop after the persisted retry budget', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + const emitSpy = jest.spyOn(appEventBus, 'emit'); + + for (let retry = 1; retry <= 3; retry += 1) { + const result = await service.reconcileAppShellPackage(); + expect(result.fullFlowRetryByTarget?.['recovery:2.0.0:1']?.count).toBe( + retry, + ); + if (retry < 3) { + expect(result.status).toBe(EAppUpdateStatus.notify); + await jest.runAllTimersAsync(); + atomValue = { + ...atomValue, + status: EAppUpdateStatus.ready, + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: `/tmp/app-2.0.0-${retry}.zip`, + }, + }; + } else { + expect(result.status).toBe(EAppUpdateStatus.updateIncomplete); + } + } + + await jest.runAllTimersAsync(); + expect(emitSpy).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenNthCalledWith( + 1, + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + expect(emitSpy).toHaveBeenNthCalledWith( + 2, + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }); + + test('unavailable manual package invalidates ready state', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'unavailable', + errorCode: 'EACCES', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.manual, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.updateIncomplete); + expect(result.downloadedEvent).toBeUndefined(); + }); + + test('macOS package not prepared in the current process invalidates ready state', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.notify); + expect(result.downloadedEvent).toBeUndefined(); + expect(result.fullFlowRetryByTarget).toEqual({}); + + const emitSpy = jest.spyOn(appEventBus, 'emit'); + await jest.runAllTimersAsync(); + expect(emitSpy).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }); + + test.each([EAppUpdateStatus.ready, EAppUpdateStatus.manualInstall])( + 'manual package in %s resumes updater cache preparation without an incomplete state', + async (status) => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: EAppUpdatePackageAvailabilityStatus.notPrepared, + }); + resetAtom({ + latestVersion: '2.0.0', + status, + updateStrategy: EUpdateStrategy.manual, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.downloadPackage); + expect(result.downloadedEvent).toBeUndefined(); + expect(result.fullFlowRetryByTarget).toEqual({}); + const emitSpy = jest.spyOn(appEventBus, 'emit'); + await jest.runAllTimersAsync(); + expect(emitSpy).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }, + ); + + test('missing package during verification enters the recovery flow', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.verifyPackage, + updateStrategy: EUpdateStrategy.manual, + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.updateIncomplete); + expect(result.downloadedEvent).toBeUndefined(); + }); + + test('concurrent strategy changes do not invalidate state using a stale recovery decision', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.manual, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + AppUpdate.checkPackageAvailability.mockImplementationOnce(async () => { + atomValue = { + ...atomValue, + updateStrategy: EUpdateStrategy.seamless, + }; + return { status: 'missing' }; + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.ready); + expect(result.updateStrategy).toBe(EUpdateStrategy.seamless); + expect(result.downloadedEvent?.downloadedFile).toBe('/tmp/app-2.0.0.zip'); + }); + + test('JS bundle ready state is not inspected by app shell reconciliation', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + resetAtom({ + latestVersion: '1.0.0', + jsBundleVersion: '2', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/bundle-2.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.ready); + expect(AppUpdate.checkPackageAvailability).not.toHaveBeenCalled(); + }); + + test('availability check failure preserves the ready package state', async () => { + const { + AppUpdate, + } = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + AppUpdate.checkPackageAvailability.mockRejectedValueOnce( + new Error('IPC unavailable'), + ); + resetAtom({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.manual, + downloadedEvent: { + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + downloadedFile: '/tmp/app-2.0.0.zip', + }, + }); + + const result = await service.reconcileAppShellPackage(); + + expect(result.status).toBe(EAppUpdateStatus.ready); + expect(result.downloadedEvent?.downloadedFile).toBe('/tmp/app-2.0.0.zip'); + }); + }); + describe('dev bundle switcher endpoint', () => { test('devFetchBundleVersions always uses test utility endpoint', async () => { await service.devFetchBundleVersions(); diff --git a/packages/kit-bg/src/services/ServiceAppUpdate.ts b/packages/kit-bg/src/services/ServiceAppUpdate.ts index 1bec34b1542a..28fc40602997 100644 --- a/packages/kit-bg/src/services/ServiceAppUpdate.ts +++ b/packages/kit-bg/src/services/ServiceAppUpdate.ts @@ -14,6 +14,7 @@ import { EPendingInstallTaskType, EUpdateFileType, EUpdateStrategy, + getUpdateFileType, isAutoUpdateStrategy, isFirstLaunchAfterUpdated, normalizeFeaturedChangelog, @@ -36,6 +37,10 @@ import { AppUpdate, BundleUpdate, } from '@onekeyhq/shared/src/modules3rdParty/auto-update'; +import { + EAppUpdatePackageAvailabilityStatus, + type IAppUpdatePackageAvailability, +} from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { getRequestHeaders } from '@onekeyhq/shared/src/request/Interceptor'; import appStorage from '@onekeyhq/shared/src/storage/appStorage'; @@ -50,7 +55,10 @@ import { devSettingsPersistAtom } from '../states/jotai/atoms/devSettings'; import ServiceBase from './ServiceBase'; import { + APP_SHELL_PACKAGE_RECOVERY_RETRY_KEY_PREFIX, + MAX_FULL_FLOW_RETRY, PLACEHOLDER_SIGNATURE, + clearPendingInstallTask, getPendingInstallTask, setPendingInstallTask, } from './servicePendingInstallTask'; @@ -77,6 +85,17 @@ const failedRecoveryRetryCount = new Map(); const MAX_FAILED_RECOVERY_RETRY = 3; const FAILED_RECOVERY_FREEZE_MS = 24 * 60 * 60 * 1000; // 24 h const FAILED_RECOVERY_IGNORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 d +const APP_SHELL_PACKAGE_RECONCILE_STATUSES: ReadonlySet = + new Set([ + EAppUpdateStatus.downloadASC, + EAppUpdateStatus.downloadASCFailed, + EAppUpdateStatus.verifyASC, + EAppUpdateStatus.verifyASCFailed, + EAppUpdateStatus.verifyPackage, + EAppUpdateStatus.verifyPackageFailed, + EAppUpdateStatus.ready, + EAppUpdateStatus.manualInstall, + ]); // --------------------------------------------------------------------------- // Download attempt budget @@ -395,7 +414,141 @@ class ServiceAppUpdate extends ServiceBase { @backgroundMethod() async processPendingInstallTask() { - await this.pendingInstallTaskService.processPendingInstallTask(); + return ( + (await this.pendingInstallTaskService.processPendingInstallTask()) === + true + ); + } + + @backgroundMethod() + async reconcileAppShellPackage() { + await this.cleanupUpdateControlState(); + const snapshot = await appUpdatePersistAtom.get(); + if ( + !APP_SHELL_PACKAGE_RECONCILE_STATUSES.has(snapshot.status) || + isFirstLaunchAfterUpdated(snapshot) || + snapshot.storeUrl || + getUpdateFileType(snapshot) !== EUpdateFileType.appShell + ) { + return snapshot; + } + + let availability: IAppUpdatePackageAvailability; + try { + availability = await AppUpdate.checkPackageAvailability(snapshot); + } catch { + defaultLogger.app.appUpdate.log( + 'reconcileAppShellPackage: package availability check failed', + ); + return snapshot; + } + if ( + availability.status !== EAppUpdatePackageAvailabilityStatus.missing && + availability.status !== EAppUpdatePackageAvailabilityStatus.unavailable && + availability.status !== EAppUpdatePackageAvailabilityStatus.notPrepared + ) { + return snapshot; + } + + const needsUpdaterRehydrate = + availability.status === EAppUpdatePackageAvailabilityStatus.notPrepared; + const isAutoStrategy = isAutoUpdateStrategy(snapshot.updateStrategy); + let nextStatus: EAppUpdateStatus; + if (needsUpdaterRehydrate && !isAutoStrategy) { + nextStatus = EAppUpdateStatus.downloadPackage; + } else if (isAutoStrategy) { + nextStatus = EAppUpdateStatus.notify; + } else { + nextStatus = EAppUpdateStatus.updateIncomplete; + } + const shouldConsumeRecoveryBudget = + nextStatus === EAppUpdateStatus.notify && !needsUpdaterRehydrate; + const updateTargetKey = shouldConsumeRecoveryBudget + ? this.computeUpdateTargetKey(snapshot) + : null; + const recoveryTargetKey = updateTargetKey + ? `${APP_SHELL_PACKAGE_RECOVERY_RETRY_KEY_PREFIX}${updateTargetKey}` + : null; + let invalidated = false; + await appUpdatePersistAtom.set((current) => { + const isSamePackageState = + current.status === snapshot.status && + current.latestVersion === snapshot.latestVersion && + current.jsBundleVersion === snapshot.jsBundleVersion && + current.updateStrategy === snapshot.updateStrategy && + current.storeUrl === snapshot.storeUrl && + current.downloadedEvent?.downloadedFile === + snapshot.downloadedEvent?.downloadedFile; + if (!isSamePackageState) { + return current; + } + invalidated = true; + let fullFlowRetryByTarget = current.fullFlowRetryByTarget; + if (recoveryTargetKey) { + const recoveryCount = + (current.fullFlowRetryByTarget?.[recoveryTargetKey]?.count || 0) + 1; + fullFlowRetryByTarget = { + ...current.fullFlowRetryByTarget, + [recoveryTargetKey]: { + count: recoveryCount, + updatedAt: Date.now(), + }, + }; + if (recoveryCount > MAX_FULL_FLOW_RETRY) { + nextStatus = EAppUpdateStatus.updateIncomplete; + } + } + return { + ...current, + status: nextStatus, + errorText: undefined, + downloadedEvent: undefined, + fullFlowRetryByTarget, + }; + }); + + if (invalidated) { + clearTimeout(failedRecoveryTimerId); + const latest = await appUpdatePersistAtom.get(); + if ( + latest.status === nextStatus && + latest.latestVersion === snapshot.latestVersion && + !latest.downloadedEvent + ) { + const pendingTask = await getPendingInstallTask(); + if ( + pendingTask?.type === EPendingInstallTaskType.appInstall && + pendingTask.targetAppVersion === snapshot.latestVersion + ) { + await clearPendingInstallTask(); + } + } + defaultLogger.app.appUpdate.log( + `reconcileAppShellPackage: ${availability.status} package invalidated ${snapshot.status} state (${nextStatus})`, + ); + if ( + nextStatus === EAppUpdateStatus.notify || + nextStatus === EAppUpdateStatus.downloadPackage + ) { + setTimeout(() => { + void (async () => { + const current = await appUpdatePersistAtom.get(); + if ( + current.status === nextStatus && + current.latestVersion === snapshot.latestVersion && + (nextStatus === EAppUpdateStatus.downloadPackage || + isAutoUpdateStrategy(current.updateStrategy)) && + !current.downloadedEvent + ) { + appEventBus.emit(EAppEventBusNames.StartAutoDownloadUpdate, { + decision: 'appShellPackageRecovery', + }); + } + })(); + }, 0); + } + } + return appUpdatePersistAtom.get(); } @backgroundMethod() @@ -1378,13 +1531,12 @@ class ServiceAppUpdate extends ServiceBase { } clearTimeout(downloadTimeoutId); clearTimeout(failedRecoveryTimerId); - await appUpdatePersistAtom.set((prev) => ({ - ...prev, - status: EAppUpdateStatus.ready, - })); - - const latest = await appUpdatePersistAtom.get(); + const latest = appInfo; if (!latest.latestVersion && !latest.jsBundleVersion) { + await appUpdatePersistAtom.set((prev) => ({ + ...prev, + status: EAppUpdateStatus.ready, + })); return; } const traceId = generateUUID(); @@ -1406,6 +1558,22 @@ class ServiceAppUpdate extends ServiceBase { stage: 'ready_to_install', appInfo: latest, }); + await appUpdatePersistAtom.set((prev) => { + const isSameVerifiedPackage = + (prev.status === EAppUpdateStatus.verifyPackage || + prev.status === EAppUpdateStatus.ready) && + prev.latestVersion === latest.latestVersion && + prev.jsBundleVersion === latest.jsBundleVersion && + prev.downloadedEvent?.downloadedFile === + latest.downloadedEvent?.downloadedFile; + if (!isSameVerifiedPackage) { + return prev; + } + return { + ...prev, + status: EAppUpdateStatus.ready, + }; + }); } @backgroundMethod() 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..9f86c5e7146e 100644 --- a/packages/kit-bg/src/services/ServiceBootstrap.ts +++ b/packages/kit-bg/src/services/ServiceBootstrap.ts @@ -24,6 +24,18 @@ class ServiceBootstrap extends ServiceBase { public async init() { await this.initCritical(); + if (platformEnv.isNative || platformEnv.isDesktop) { + void import('./ServiceFirmwareUpdate/FirmwareUpdateRuntime') + .then(({ firmwareArtifactAdapter }) => + firmwareArtifactAdapter.sweepOrphans(), + ) + .catch(() => { + defaultLogger.app.bootstrap.initCriticalStep( + 'firmwareArtifactOrphanSweep (FAILED)', + 0, + ); + }); + } if (platformEnv.isWeb || platformEnv.isDesktop) { setTimeout(() => { void this.initDeferred(); @@ -64,6 +76,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 +171,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..1ae331e1b599 100644 --- a/packages/kit-bg/src/services/ServiceDevSetting.ts +++ b/packages/kit-bg/src/services/ServiceDevSetting.ts @@ -18,16 +18,20 @@ import { EAppSyncStorageKeys, EDevSettingSyncStorageKeys, } from '@onekeyhq/shared/src/storage/syncStorageKeys'; +import type { IPro2FirmwareUpdateTarget } from '@onekeyhq/shared/types/device'; import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; +import { applyPro2FirmwareForceTargetChange } from '../states/jotai/atoms/applyPro2FirmwareForceTargetChange'; import { devSettingsPersistAtom, firmwareUpdateDevSettingsPersistAtom, getDevSettingsNetworkThrottleEnabled, + getGatedFirmwareUpdateDevSetting, } from '../states/jotai/atoms/devSettings'; import ServiceBase from './ServiceBase'; +import type { IPro2FirmwareForceTargetMode } from '../states/jotai/atoms/applyPro2FirmwareForceTargetChange'; import type { IDevSettings, IDevSettingsKeys, @@ -220,6 +224,10 @@ class ServiceDevSetting extends ServiceBase { enabled: false, settings: {}, })); + await firmwareUpdateDevSettingsPersistAtom.set((prev) => ({ + ...prev, + usePreReleaseConfig: false, + })); await this.saveDevModeToSyncStorage(); await this.syncCryptoSettings(); @@ -304,24 +312,76 @@ class ServiceDevSetting extends ServiceBase { public async getFirmwareUpdateDevSettings< T extends IFirmwareUpdateDevSettingsKeys, >(key: T): Promise { + return getGatedFirmwareUpdateDevSetting(key); + } + + @backgroundMethod() + public async getFirmwareUpdateDevSettingsSnapshot(): Promise< + IFirmwareUpdateDevSettings | undefined + > { const dev = await devSettingsPersistAtom.get(); if (!dev.enabled) { return undefined; } - const fwDev = await firmwareUpdateDevSettingsPersistAtom.get(); - return fwDev[key]; + return firmwareUpdateDevSettingsPersistAtom.get(); + } + + private firmwareUpdateDevSettingsWrite: Promise = Promise.resolve(); + + private enqueueFirmwareUpdateDevSettingsWrite( + updater: (prev: IFirmwareUpdateDevSettings) => IFirmwareUpdateDevSettings, + ) { + const run = this.firmwareUpdateDevSettingsWrite + .catch(() => undefined) + .then(() => firmwareUpdateDevSettingsPersistAtom.set(updater)); + this.firmwareUpdateDevSettingsWrite = run.then( + () => undefined, + () => undefined, + ); + return run; } @backgroundMethod() public async updateFirmwareUpdateDevSettings( values: Partial, ) { - await firmwareUpdateDevSettingsPersistAtom.set((prev) => ({ + await this.enqueueFirmwareUpdateDevSettingsWrite((prev) => ({ ...prev, ...values, })); } + @backgroundMethod() + public async togglePro2FirmwareForceTarget({ + enabled, + mode, + target, + }: { + enabled: boolean; + mode: IPro2FirmwareForceTargetMode; + target: IPro2FirmwareUpdateTarget; + }) { + await this.enqueueFirmwareUpdateDevSettingsWrite((prev) => ({ + ...prev, + ...applyPro2FirmwareForceTargetChange({ + enabled, + mode, + onceTargets: prev.pro2ForceUpdateOnceTargets ?? [], + target, + targets: prev.pro2ForceUpdateTargets ?? [], + }), + })); + } + + @backgroundMethod() + public async resetPro2FirmwareForceTargets() { + await this.enqueueFirmwareUpdateDevSettingsWrite((prev) => ({ + ...prev, + pro2ForceUpdateOnceTargets: [], + pro2ForceUpdateTargets: [], + })); + } + @backgroundMethod() public async isSkipBundleGPGVerificationAllowed(): Promise { // desktop keeps env-gated behavior in main process; native uses module API. diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts new file mode 100644 index 000000000000..9b329bdf977b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts @@ -0,0 +1,31 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const getDesktopAdapter = (): IFirmwareArtifactAdapter => { + const adapter = ( + globalThis.desktopApiProxy as unknown as { + firmwareArtifact?: IFirmwareArtifactAdapter; + } + ).firmwareArtifact; + if (!adapter) { + throw new OneKeyLocalError('Desktop firmware artifact API is unavailable'); + } + return adapter; +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities: () => getDesktopAdapter().getCapabilities(), + download: (input) => getDesktopAdapter().download(input), + cancelDownloads: (transactionId) => + getDesktopAdapter().cancelDownloads(transactionId), + materialize: (input) => getDesktopAdapter().materialize(input), + open: (artifactRef) => getDesktopAdapter().open(artifactRef), + read: (input) => getDesktopAdapter().read(input), + close: (readerId) => getDesktopAdapter().close(readerId), + createLease: (transactionId) => + getDesktopAdapter().createLease(transactionId), + retain: (input) => getDesktopAdapter().retain(input), + releaseLease: (input) => getDesktopAdapter().releaseLease(input), + sweepOrphans: () => getDesktopAdapter().sweepOrphans(), +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.test.ts new file mode 100644 index 000000000000..a36fcb07c61b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.test.ts @@ -0,0 +1,52 @@ +import { ReactNativeRangeDownloader } from '@onekeyfe/react-native-range-downloader'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter.native'; + +jest.mock('@onekeyfe/react-native-range-downloader', () => ({ + ReactNativeRangeDownloader: { + materializeFirmwareArchive: jest.fn(), + }, +})); + +describe('native firmware artifact adapter', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('materializes a manifest-free archive without an expected entry catalog', async () => { + const artifacts = [ + { + entryName: 'bundles/images-release.okpkg', + receipt: { + artifactRef: `fw:${'1'.repeat(64)}`, + size: 1024, + sha256: '1'.repeat(64), + expectedSha256Verified: false, + }, + }, + { + entryName: 'resource_hash.txt', + receipt: { + artifactRef: `fw:${'2'.repeat(64)}`, + size: 64, + sha256: '2'.repeat(64), + expectedSha256Verified: false, + }, + }, + ]; + const materializeFirmwareArchiveSpy = jest + .spyOn(ReactNativeRangeDownloader, 'materializeFirmwareArchive') + .mockResolvedValue({ artifacts }); + + await expect( + firmwareArtifactAdapter.materialize({ + leaseRef: 'fwlease:manifest-free-resc', + archiveArtifactRef: `fw:${'3'.repeat(64)}`, + }), + ).resolves.toEqual(artifacts); + expect(materializeFirmwareArchiveSpy).toHaveBeenCalledWith({ + leaseRef: 'fwlease:manifest-free-resc', + archiveArtifactRef: `fw:${'3'.repeat(64)}`, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts new file mode 100644 index 000000000000..66a315e9c28d --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts @@ -0,0 +1,94 @@ +import { ReactNativeRangeDownloader } from '@onekeyfe/react-native-range-downloader'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const assertBackgroundRuntime = () => { + if ( + !platformEnv.isJest && + (!platformEnv.isNative || platformEnv.isNativeMainThread) + ) { + throw new OneKeyLocalError( + 'Firmware artifacts are only available in the native background runtime', + ); + } +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities() { + assertBackgroundRuntime(); + const capabilities = + ReactNativeRangeDownloader.getFirmwareArtifactCapabilities(); + return { + ...capabilities, + supportedRouteTypes: ['domain'], + }; + }, + async download(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.downloadFirmwareArtifact({ + taskId: input.taskId, + transactionId: input.transactionId, + leaseRef: input.leaseRef, + artifactId: input.artifactId, + url: input.url, + routeType: 'domain', + resolvedIp: undefined, + ...(input.expectedSize !== undefined + ? { expectedSize: input.expectedSize } + : {}), + ...(input.expectedSha256 ? { expectedSha256: input.expectedSha256 } : {}), + maxBytes: input.maxBytes, + overallDeadlineSeconds: input.overallDeadlineSeconds, + }); + }, + cancelDownloads(transactionId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.cancelFirmwareArtifactDownloads({ + transactionId, + }); + }, + async materialize(input) { + assertBackgroundRuntime(); + const result = await ReactNativeRangeDownloader.materializeFirmwareArchive({ + leaseRef: input.leaseRef, + archiveArtifactRef: input.archiveArtifactRef, + ...(input.expectedEntries + ? { expectedEntries: [...input.expectedEntries] } + : {}), + }); + return result.artifacts; + }, + open(artifactRef) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.openFirmwareArtifact({ artifactRef }); + }, + read(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.readFirmwareArtifact(input); + }, + close(readerId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.closeFirmwareArtifact({ readerId }); + }, + createLease(transactionId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.createFirmwareArtifactLease({ + transactionId, + }); + }, + retain(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.retainFirmwareArtifact(input); + }, + releaseLease(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.releaseFirmwareArtifactLease(input); + }, + sweepOrphans() { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.sweepFirmwareArtifactOrphans(); + }, +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts new file mode 100644 index 000000000000..8594d3683c21 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts @@ -0,0 +1,23 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const unavailable = (): never => { + throw new OneKeyLocalError( + 'External firmware artifacts are not used on this platform', + ); +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities: unavailable, + download: unavailable, + cancelDownloads: unavailable, + materialize: unavailable, + open: unavailable, + read: unavailable, + close: unavailable, + createLease: unavailable, + retain: unavailable, + releaseLease: unavailable, + sweepOrphans: unavailable, +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts new file mode 100644 index 000000000000..b4110a19137b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts @@ -0,0 +1,75 @@ +export type IFirmwareArchiveExpectedEntry = { + artifactId: string; + entryName: string; + expectedSize: number; + expectedSha256: string; +}; + +export type IFirmwareArtifactRoute = { + routeType: 'domain'; +}; + +export type IFirmwareArtifactReceipt = { + artifactRef: string; + size: number; + sha256: string; + expectedSha256Verified: boolean; +}; + +export type IFirmwareArtifactLeaseDisposition = + | 'completed' + | 'safeCancelled' + | 'safeAbandoned'; + +export type IFirmwareMaterializedArtifact = { + entryName: string; + receipt: IFirmwareArtifactReceipt; +}; + +export type IFirmwareArtifactCapabilities = { + firmwareArtifactProtocolVersion: number; + supportedRouteTypes: string[]; + supportsArchiveMaterialization: boolean; + maxReadBytes: number; +}; + +export interface IFirmwareArtifactAdapter { + getCapabilities(): + | IFirmwareArtifactCapabilities + | Promise; + download(input: { + taskId: string; + transactionId: string; + leaseRef: string; + artifactId: string; + url: string; + route: IFirmwareArtifactRoute; + expectedSize?: number; + expectedSha256?: string; + maxBytes: number; + overallDeadlineSeconds: number; + // Set by bg only while developer mode + "Use pre-release config" are on; + // pre-release artifact hosts (developer buckets) are not pinned in advance. + allowPreReleaseHosts?: boolean; + }): Promise; + cancelDownloads(transactionId: string): Promise; + materialize(input: { + leaseRef: string; + archiveArtifactRef: string; + expectedEntries?: readonly IFirmwareArchiveExpectedEntry[]; + }): Promise; + open(artifactRef: string): Promise<{ readerId: string; size: number }>; + read(input: { + readerId: string; + offset: number; + length: number; + }): Promise; + close(readerId: string): Promise; + createLease(transactionId: string): Promise<{ leaseRef: string }>; + retain(input: { leaseRef: string; artifactRef: string }): Promise; + releaseLease(input: { + leaseRef: string; + disposition: IFirmwareArtifactLeaseDisposition; + }): Promise; + sweepOrphans(): Promise<{ deletedFiles: number; deletedBytes: number }>; +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts new file mode 100644 index 000000000000..832d4f8445c4 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts @@ -0,0 +1,686 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { getGatedFirmwareUpdateDevSetting } from '../../states/jotai/atoms/devSettings'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { IFirmwareArtifactReceipt } from './FirmwareArtifactAdapter.types'; +import type { + CoreApi, + FirmwareArtifactReader, + FirmwareArtifactReference, + FirmwareUpdateCapabilities, + FirmwareUpdatePlan, + FirmwareUpdatePlanTarget, + FirmwareUpdatePreparedPlan, + FirmwareUpdateV4Target, +} from '@onekeyfe/hd-core'; + +const MAX_READ_BYTES = 256 * 1024; +const MAX_BRIDGE_BINARY_BYTES = 4 * 1024 * 1024; +const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024; +const TOTAL_DEADLINE_MS = 30 * 60 * 1000; +const NATIVE_ARTIFACT_STAGE_TIMEOUT_MS = 15_000; +const activeArtifactDownloadCounts = new Map(); +const FIRMWARE_CAPABILITY_KEYS = [ + 'planSchemaVersion', + 'preparedPlanSchemaVersion', + 'hostBindingProtocolVersion', + 'manifestModes', + 'supportsArtifactReader', +] as const; + +export type IFirmwareDownloadArtifact = { + url: string; + role: FirmwareUpdatePlan['artifacts'][number]['role']; + logicalName?: string; + expectedSize?: number; + expectedSha256?: string; + container: FirmwareUpdatePlan['artifacts'][number]['container']; +}; + +export const withFirmwareArtifactStageTimeout = async ( + stage: string, + operation: Promise, +): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new OneKeyLocalError(`ARTIFACT_${stage}_TIMEOUT`)); + }, NATIVE_ARTIFACT_STAGE_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +}; + +export const isExternalFirmwareCapabilityReady = ( + value: unknown, +): value is FirmwareUpdateCapabilities => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length !== FIRMWARE_CAPABILITY_KEYS.length || + FIRMWARE_CAPABILITY_KEYS.some( + (key) => !Object.prototype.hasOwnProperty.call(value, key), + ) + ) { + return false; + } + const capabilities = value as Record; + if ( + capabilities.planSchemaVersion !== 2 || + capabilities.preparedPlanSchemaVersion !== 2 || + capabilities.hostBindingProtocolVersion !== 2 || + capabilities.supportsArtifactReader !== true || + !Array.isArray(capabilities.manifestModes) || + capabilities.manifestModes.length !== 2 + ) { + return false; + } + const modes = new Set(capabilities.manifestModes); + return ( + modes.size === 2 && modes.has('external-only') && modes.has('sdk-managed') + ); +}; + +export type IFirmwareArtifactReference = FirmwareArtifactReference; +type IFirmwareArtifactReader = FirmwareArtifactReader; + +export type IPreparedFirmwareArtifacts = { + transactionId: string; + leaseRef: string; + plan: FirmwareUpdatePlan; + preparedPlan: FirmwareUpdatePreparedPlan; + artifactsById: Readonly>; + selected: { + firmware?: IFirmwareArtifactReference; + ble?: IFirmwareArtifactReference; + bootloader?: IFirmwareArtifactReference; + resourceEntries?: { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + componentArtifacts: Partial< + Record< + Exclude, + IFirmwareArtifactReference + > + >; + }; + artifactReader: IFirmwareArtifactReader; +}; + +type IBridgeBinaryTarget = Exclude; + +export type IBridgeFirmwareBinaries = { + transactionId: string; + executor: FirmwareUpdatePlan['executor']; + planDigest: string; + targetBinaries: Partial>; +}; + +const assertReceipt = ( + receipt: IFirmwareArtifactReceipt, + artifact: IFirmwareDownloadArtifact, +): IFirmwareArtifactReference => { + if ( + !Number.isSafeInteger(receipt.size) || + receipt.size <= 0 || + receipt.size > (artifact.expectedSize ?? MAX_ARTIFACT_BYTES) || + !/^[a-f0-9]{64}$/iu.test(receipt.sha256) || + receipt.artifactRef !== `fw:${receipt.sha256.toLowerCase()}` || + receipt.expectedSha256Verified !== + (artifact.expectedSha256 !== undefined) || + (artifact.expectedSize !== undefined && + receipt.size !== artifact.expectedSize) || + (artifact.expectedSha256 !== undefined && + receipt.sha256.toLowerCase() !== artifact.expectedSha256.toLowerCase()) + ) { + throw new OneKeyLocalError( + 'Firmware artifact receipt does not match the update plan', + ); + } + return receipt; +}; + +const trackArtifactDownload = async ( + transactionId: string, + operation: () => Promise, +): Promise => { + activeArtifactDownloadCounts.set( + transactionId, + (activeArtifactDownloadCounts.get(transactionId) ?? 0) + 1, + ); + try { + return await operation(); + } finally { + const remaining = + (activeArtifactDownloadCounts.get(transactionId) ?? 1) - 1; + if (remaining > 0) { + activeArtifactDownloadCounts.set(transactionId, remaining); + } else { + activeArtifactDownloadCounts.delete(transactionId); + } + } +}; + +export const downloadFirmwareArtifact = async ({ + artifact, + artifactId, + taskId, + transactionId, + leaseRef, + deadlineAt, +}: { + artifact: IFirmwareDownloadArtifact; + artifactId: string; + taskId: string; + transactionId: string; + leaseRef: string; + deadlineAt: number; +}): Promise => + trackArtifactDownload(transactionId, async () => { + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + throw new OneKeyLocalError( + 'Firmware artifact preparation exceeded its deadline', + ); + } + const startedAt = Date.now(); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-start', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + }); + // Pre-release artifacts resolved from pre-config.json live in + // developer-owned buckets outside the reviewed host allowlist. + const allowPreReleaseHosts = + (await getGatedFirmwareUpdateDevSetting('usePreReleaseConfig')) === true; + try { + const receipt = await firmwareArtifactAdapter.download({ + taskId, + transactionId, + leaseRef, + artifactId, + url: artifact.url, + route: { routeType: 'domain' }, + ...(artifact.expectedSize !== undefined + ? { expectedSize: artifact.expectedSize } + : {}), + ...(artifact.expectedSha256 + ? { expectedSha256: artifact.expectedSha256 } + : {}), + ...(allowPreReleaseHosts ? { allowPreReleaseHosts } : {}), + maxBytes: artifact.expectedSize ?? MAX_ARTIFACT_BYTES, + overallDeadlineSeconds: remainingMs / 1000, + }); + const verifiedReceipt = assertReceipt(receipt, artifact); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-complete', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + durationMs: Date.now() - startedAt, + }); + return verifiedReceipt; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-failed', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + durationMs: Date.now() - startedAt, + errorCode: + errorMessage.match(/ARTIFACT_[A-Z0-9_]+/)?.[0] ?? + (error instanceof Error ? error.name : 'UnknownError'), + }); + throw error; + } + }); + +export const cancelFirmwareArtifactPreparations = async (): Promise => { + await Promise.all( + [...activeArtifactDownloadCounts.keys()].map((transactionId) => + firmwareArtifactAdapter.cancelDownloads(transactionId), + ), + ); +}; + +const createArtifactReader = (): IFirmwareArtifactReader => { + const sizesByReaderId = new Map(); + return { + async open({ artifactRef }) { + const openOperation = firmwareArtifactAdapter.open(artifactRef); + let opened: Awaited; + try { + opened = await withFirmwareArtifactStageTimeout( + 'READER_OPEN', + openOperation, + ); + } catch (error) { + void openOperation + .then(({ readerId }) => firmwareArtifactAdapter.close(readerId)) + .catch(() => undefined); + throw error; + } + sizesByReaderId.set(opened.readerId, opened.size); + return opened; + }, + async read({ readerId, offset, length }) { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length <= 0 || + length > MAX_READ_BYTES + ) { + throw new OneKeyLocalError('Invalid firmware artifact reader request'); + } + const size = sizesByReaderId.get(readerId); + if (size === undefined || offset + length > size) { + throw new OneKeyLocalError('Firmware artifact reader is out of bounds'); + } + const data = await withFirmwareArtifactStageTimeout( + 'READER_READ', + firmwareArtifactAdapter.read({ + readerId, + offset, + length, + }), + ); + return { + data, + bytesRead: data.byteLength, + eof: offset + data.byteLength === size, + }; + }, + async close({ readerId }) { + sizesByReaderId.delete(readerId); + await withFirmwareArtifactStageTimeout( + 'READER_CLOSE', + firmwareArtifactAdapter.close(readerId), + ); + }, + }; +}; + +export const resolveFirmwarePlanArtifact = ( + planArtifact: FirmwareUpdatePlan['artifacts'][number], +): IFirmwareDownloadArtifact => { + if (planArtifact.container !== 'raw' && planArtifact.container !== 'zip') { + throw new OneKeyLocalError( + 'Firmware update plan contains an unsupported artifact container', + ); + } + const expectedSize = + Number.isSafeInteger(planArtifact.expectedSize) && + (planArtifact.expectedSize ?? 0) > 0 + ? planArtifact.expectedSize + : undefined; + const expectedSha256 = + typeof planArtifact.expectedSha256 === 'string' && + /^[a-f0-9]{64}$/iu.test(planArtifact.expectedSha256) + ? planArtifact.expectedSha256.toLowerCase() + : undefined; + return { + url: planArtifact.url, + role: planArtifact.role, + ...(planArtifact.logicalName + ? { logicalName: planArtifact.logicalName } + : {}), + ...(expectedSize !== undefined ? { expectedSize } : {}), + ...(expectedSha256 ? { expectedSha256 } : {}), + container: planArtifact.container, + }; +}; + +const readFirmwareArtifact = async ( + artifact: IFirmwareArtifactReference, +): Promise => { + if ( + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 || + artifact.size > MAX_BRIDGE_BINARY_BYTES + ) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary exceeds the in-memory limit', + ); + } + const opened = await firmwareArtifactAdapter.open(artifact.artifactRef); + try { + if (opened.size !== artifact.size) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary size is invalid', + ); + } + const result = new Uint8Array(artifact.size); + let offset = 0; + while (offset < artifact.size) { + const length = Math.min(MAX_READ_BYTES, artifact.size - offset); + const chunk = await firmwareArtifactAdapter.read({ + readerId: opened.readerId, + offset, + length, + }); + if (chunk.byteLength !== length) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary read is incomplete', + ); + } + result.set(new Uint8Array(chunk), offset); + offset += length; + } + return result.buffer; + } finally { + await firmwareArtifactAdapter.close(opened.readerId); + } +}; + +const getBridgeBinaryPlanArtifacts = ( + plan: FirmwareUpdatePlan, +): FirmwareUpdatePlan['artifacts'] => { + if (plan.executor === 'v4') { + const components = plan.artifacts.filter( + (artifact) => artifact.role === 'component', + ); + return components.length > 0 && + components.every((artifact) => artifact.container === 'raw') + ? components + : []; + } + return plan.artifacts.filter( + (artifact) => + (artifact.role === 'firmware' || + artifact.role === 'ble' || + artifact.role === 'bootloader') && + artifact.container === 'raw', + ); +}; + +export const isFirmwareArtifactCapabilityReadyValue = ( + capabilities: unknown, +): boolean => { + if ( + !capabilities || + typeof capabilities !== 'object' || + Array.isArray(capabilities) + ) { + return false; + } + const value = capabilities as Record; + const routeTypes = value.supportedRouteTypes; + return ( + value.firmwareArtifactProtocolVersion === 4 && + value.maxReadBytes === MAX_READ_BYTES && + value.supportsArchiveMaterialization === true && + Array.isArray(routeTypes) && + routeTypes.includes('domain') + ); +}; + +export async function isFirmwareArtifactCapabilityReady(): Promise { + try { + return isFirmwareArtifactCapabilityReadyValue( + await firmwareArtifactAdapter.getCapabilities(), + ); + } catch { + return false; + } +} + +export async function prepareBridgeFirmwareBinaries( + plan: FirmwareUpdatePlan, + requestedTransactionId?: string, +): Promise { + if (plan.executor === 'v4' && plan.targetsToUpdate.includes('resource')) { + throw new OneKeyLocalError( + 'Desktop Bridge does not support Protocol V2 resource ZIP updates', + ); + } + if (!(await isFirmwareArtifactCapabilityReady())) { + throw new OneKeyLocalError( + 'Installed firmware artifact module is incompatible', + ); + } + const planArtifacts = getBridgeBinaryPlanArtifacts(plan); + if (!planArtifacts.length) return undefined; + + const transactionId = + requestedTransactionId ?? + `bridge:${plan.planDigest.slice(0, 32)}:${Date.now()}`; + const { leaseRef } = await firmwareArtifactAdapter.createLease(transactionId); + let completed = false; + try { + const targetBinaries: IBridgeFirmwareBinaries['targetBinaries'] = {}; + const deadlineAt = Date.now() + TOTAL_DEADLINE_MS; + for (const [index, planArtifact] of planArtifacts.entries()) { + if (planArtifact.target === 'resource') { + throw new OneKeyLocalError( + 'Desktop Bridge resource binaries are not supported', + ); + } + const artifact = resolveFirmwarePlanArtifact(planArtifact); + const receipt = await downloadFirmwareArtifact({ + artifact, + artifactId: planArtifact.artifactId, + taskId: `fw-${plan.planDigest.slice(0, 24)}-${index}`, + transactionId, + leaseRef, + deadlineAt, + }); + targetBinaries[planArtifact.target] = await readFirmwareArtifact(receipt); + } + completed = true; + return { + transactionId, + executor: plan.executor, + planDigest: plan.planDigest, + targetBinaries, + }; + } finally { + await firmwareArtifactAdapter.releaseLease({ + leaseRef, + disposition: completed ? 'completed' : 'safeCancelled', + }); + } +} + +export const getBridgeFirmwareV3BinaryParams = ( + binaries: IBridgeFirmwareBinaries | undefined, +) => ({ + ...(binaries?.targetBinaries.firmware + ? { firmwareBinary: binaries.targetBinaries.firmware } + : {}), + ...(binaries?.targetBinaries.ble + ? { bleBinary: binaries.targetBinaries.ble } + : {}), + ...(binaries?.targetBinaries.bootloader + ? { bootloaderBinary: binaries.targetBinaries.bootloader } + : {}), +}); + +export const getBridgeFirmwareV4BinaryParams = ( + binaries: IBridgeFirmwareBinaries | undefined, +) => ({ + ...(binaries?.targetBinaries.boot + ? { bootloaderBinary: binaries.targetBinaries.boot } + : {}), + ...(binaries?.targetBinaries.app_v1 + ? { applicationP1Binary: binaries.targetBinaries.app_v1 } + : {}), + ...(binaries?.targetBinaries.app_v2 + ? { applicationP2Binary: binaries.targetBinaries.app_v2 } + : {}), + ...(binaries?.targetBinaries.coprocessor + ? { coprocessorBinary: binaries.targetBinaries.coprocessor } + : {}), + ...(binaries?.targetBinaries.se01 + ? { se01Binary: binaries.targetBinaries.se01 } + : {}), + ...(binaries?.targetBinaries.se02 + ? { se02Binary: binaries.targetBinaries.se02 } + : {}), + ...(binaries?.targetBinaries.se03 + ? { se03Binary: binaries.targetBinaries.se03 } + : {}), + ...(binaries?.targetBinaries.se04 + ? { se04Binary: binaries.targetBinaries.se04 } + : {}), +}); + +export async function prepareFirmwareArtifacts( + plan: FirmwareUpdatePlan, + { + transactionId, + leaseRef: existingLeaseRef, + preparePlan, + }: { + transactionId: string; + leaseRef?: string; + preparePlan: CoreApi['prepareFirmwareUpdatePlan']; + }, +): Promise { + if (!(await isFirmwareArtifactCapabilityReady())) { + throw new OneKeyLocalError( + 'Installed firmware artifact module is incompatible', + ); + } + + const leaseRef = + existingLeaseRef ?? + (await firmwareArtifactAdapter.createLease(transactionId)).leaseRef; + const deadlineAt = Date.now() + TOTAL_DEADLINE_MS; + const artifactsById: Record = {}; + const resourceEntriesByArtifactId: Record< + string, + { + entryName: string; + artifact: IFirmwareArtifactReference; + }[] + > = {}; + + let cancellationRequested = false; + let preparationFailure: { reason: unknown } | undefined; + await Promise.allSettled( + plan.artifacts.map(async (planArtifact, index) => { + try { + const artifact = resolveFirmwarePlanArtifact(planArtifact); + const receipt = await downloadFirmwareArtifact({ + artifact, + artifactId: planArtifact.artifactId, + taskId: `fw-${plan.planDigest.slice(0, 24)}-${index}`, + transactionId, + leaseRef, + deadlineAt, + }); + artifactsById[planArtifact.artifactId] = receipt; + if (planArtifact.container === 'zip') { + const entries = await firmwareArtifactAdapter.materialize({ + leaseRef, + archiveArtifactRef: receipt.artifactRef, + }); + resourceEntriesByArtifactId[planArtifact.artifactId] = entries.map( + (entry) => ({ + entryName: entry.entryName, + artifact: entry.receipt, + }), + ); + } + } catch (error) { + if (!cancellationRequested) { + cancellationRequested = true; + preparationFailure = { reason: error }; + await firmwareArtifactAdapter + .cancelDownloads(transactionId) + .catch(() => undefined); + } + throw error; + } + }), + ); + if (preparationFailure) { + throw preparationFailure.reason; + } + + const componentArtifacts: IPreparedFirmwareArtifacts['selected']['componentArtifacts'] = + {}; + const resourceEntries: NonNullable< + IPreparedFirmwareArtifacts['selected']['resourceEntries'] + > = []; + for (const planArtifact of plan.artifacts) { + const artifact = artifactsById[planArtifact.artifactId]; + if (!artifact) { + throw new OneKeyLocalError('Firmware artifact preparation is incomplete'); + } + if (planArtifact.role === 'component') { + const target = planArtifact.target; + if ( + target === 'firmware' || + target === 'ble' || + target === 'bootloader' || + target === 'resource' + ) { + throw new OneKeyLocalError('Firmware component target is invalid'); + } + componentArtifacts[target] = artifact; + } + if ( + planArtifact.target === 'resource' && + planArtifact.container === 'zip' + ) { + resourceEntries.push( + ...(resourceEntriesByArtifactId[planArtifact.artifactId] ?? []), + ); + } + } + const preparedPlan = preparePlan({ + plan, + leaseRef, + artifacts: plan.artifacts.map((planArtifact) => { + const artifact = artifactsById[planArtifact.artifactId]; + if (!artifact) { + throw new OneKeyLocalError( + 'Firmware artifact preparation is incomplete', + ); + } + const materializedEntries = + resourceEntriesByArtifactId[planArtifact.artifactId]; + return { + artifactId: planArtifact.artifactId, + artifact, + ...(materializedEntries?.length ? { materializedEntries } : {}), + }; + }), + }); + + return { + transactionId, + leaseRef, + plan, + preparedPlan, + artifactsById, + selected: { + firmware: artifactsById.firmware, + ble: artifactsById.ble, + bootloader: artifactsById.bootloader, + resourceEntries: resourceEntries.length ? resourceEntries : undefined, + componentArtifacts, + }, + artifactReader: createArtifactReader(), + }; +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts new file mode 100644 index 000000000000..fed6aa36e8ec --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts @@ -0,0 +1,346 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, +} from './FirmwareArtifactSelfTest'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +import type { IPreparedFirmwareArtifacts } from './FirmwareArtifactPreflight'; +import type { + FirmwarePreparedArtifactController, + IFirmwarePreparedArtifactReleaseResult, +} from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePreparedPlan, + RemoteConfigResponse, +} from '@onekeyfe/hd-core'; + +jest.mock('./FirmwareManifestProvider', () => ({ + getFirmwareManifestSnapshot: jest.fn(), +})); + +const mockedGetFirmwareManifestSnapshot = jest.mocked( + getFirmwareManifestSnapshot, +); +const mockFirmwareSha256 = '1'.repeat(64); +const mockResourceSha256 = '2'.repeat(64); +const mockRemoteConfig = { + pro: { + firmware: [], + ble: [], + 'firmware-v8': [ + { + required: false, + version: [4, 21, 0], + url: 'https://firmware.example/pro.bin', + fingerprint: mockFirmwareSha256, + expectedSize: 1024 * 1024, + resource: 'https://firmware.example/pro-resources.zip', + resourceFingerprint: mockResourceSha256, + resourceExpectedSize: 512 * 1024, + fullResource: 'https://firmware.example/pro-full-resources.zip', + fullResourceFingerprint: '4'.repeat(64), + fullResourceExpectedSize: 2 * 1024 * 1024, + changelog: { 'en-US': '', 'zh-CN': '' }, + }, + ], + }, +} as unknown as RemoteConfigResponse; + +const createSdk = () => { + const prepareFirmwareUpdatePlan = jest.fn( + ({ plan }: Parameters[0]) => + ({ + preparedPlanDigest: 'd'.repeat(64), + planDigest: plan.planDigest, + }) as FirmwareUpdatePreparedPlan, + ); + const firmwareUpdateV3 = jest.fn(async () => ({ + success: false as const, + payload: { code: 'DeviceNotFound', error: 'Device not found' }, + })); + const sdk = { + getFirmwareUpdateCapabilities: () => ({ + planSchemaVersion: 2 as const, + preparedPlanSchemaVersion: 2 as const, + hostBindingProtocolVersion: 2 as const, + manifestModes: ['external-only', 'sdk-managed'] as const, + supportsArtifactReader: true as const, + }), + prepareFirmwareUpdatePlan, + validateFirmwareUpdatePreparedPlan: (plan: FirmwareUpdatePreparedPlan) => + plan, + registerFirmwareUpdateHostBinding: jest.fn(() => 7), + unregisterFirmwareUpdateHostBinding: jest.fn(() => true), + firmwareUpdateV3, + } as unknown as CoreApi; + return { sdk, firmwareUpdateV3, prepareFirmwareUpdatePlan }; +}; + +const createController = async ({ + scenario, + read, + getReleaseResult, +}: { + scenario: 'pro-firmware' | 'pro-resource'; + read?: IPreparedFirmwareArtifacts['artifactReader']['read']; + getReleaseResult?: ( + transactionId: string, + ) => IFirmwarePreparedArtifactReleaseResult; +}) => { + const { artifact, artifactId } = + await getFirmwareArtifactSelfTestArtifact(scenario); + const expectedSize = artifact.expectedSize; + const expectedSha256 = artifact.expectedSha256; + if (expectedSize === undefined || expectedSha256 === undefined) { + throw new OneKeyLocalError('Test fixture integrity metadata is missing'); + } + const open = jest.fn(async () => ({ + readerId: 'reader-1', + size: expectedSize, + })); + const readImpl: IPreparedFirmwareArtifacts['artifactReader']['read'] = + read ?? + (async ({ offset, length }) => ({ + data: new ArrayBuffer(length), + bytesRead: length, + eof: offset + length === artifact.expectedSize, + })); + const readMock = jest.fn(readImpl); + const close = jest.fn(async () => undefined); + const dispositions: ('completed' | 'safeCancelled')[] = []; + const sweepOrphanedArtifacts = jest.fn(async () => ({ + deletedFiles: 2, + deletedBytes: 4096, + })); + + const withPreparedPlanArtifacts: FirmwarePreparedArtifactController['withPreparedPlanArtifacts'] = + async ( + { + plan, + sdk, + transactionId = 'fwtx:test', + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }, + execute: (prepared: IPreparedFirmwareArtifacts) => Promise, + onReleased?: (result: IFirmwarePreparedArtifactReleaseResult) => void, + ): Promise => { + // cspell:disable-next-line + const leaseRef = `fwlease:${transactionId}`; + const receipt = { + artifactRef: `fw:${expectedSha256}`, + size: expectedSize, + sha256: expectedSha256, + }; + const preparedPlan = sdk.prepareFirmwareUpdatePlan({ + plan, + leaseRef, + artifacts: [ + { + artifactId, + artifact: receipt, + }, + ], + }); + const prepared = { + transactionId, + leaseRef, + plan, + preparedPlan, + artifactsById: { [artifactId]: receipt }, + selected: { + ...(artifactId === 'firmware' ? { firmware: receipt } : {}), + componentArtifacts: {}, + }, + artifactReader: { + open, + read: readMock, + close, + }, + } as IPreparedFirmwareArtifacts; + + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(prepared); + disposition = 'completed'; + return result; + } finally { + dispositions.push(disposition); + onReleased?.( + getReleaseResult?.(transactionId) ?? { + hostBindingReleased: true, + leaseReleased: true, + }, + ); + } + }; + + const controller = { + withPreparedPlanArtifacts: jest.fn(withPreparedPlanArtifacts), + getExecutionArtifacts: jest.fn( + (preparedArtifacts: IPreparedFirmwareArtifacts) => ({ + preparedArtifacts, + hostBindingGeneration: 7, + }), + ), + sweepOrphanedArtifacts, + } as unknown as Pick< + FirmwarePreparedArtifactController, + | 'getExecutionArtifacts' + | 'sweepOrphanedArtifacts' + | 'withPreparedPlanArtifacts' + >; + return { + artifact, + close, + controller, + dispositions, + open, + read: readMock, + sweepOrphanedArtifacts, + }; +}; + +describe('FirmwareArtifactSelfTest', () => { + beforeEach(() => { + mockedGetFirmwareManifestSnapshot.mockResolvedValue(mockRemoteConfig); + }); + + it('runs the production firmware handoff and 50 cached preflight cycles', async () => { + const fixture = await createController({ scenario: 'pro-firmware' }); + const sdkFixture = createSdk(); + const progress = jest.fn(); + + const result = await executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-firmware', + sdk: sdkFixture.sdk, + controller: fixture.controller, + onProgress: progress, + }); + + expect(result.bytesRead).toBe(fixture.artifact.expectedSize); + expect(result.chunkCount).toBeGreaterThan(1); + expect(result.preflightCompletedIterations).toBe(50); + expect(result).toEqual( + expect.objectContaining({ + preparedPlanValidated: true, + sdkHandoffValidated: true, + cleanupValidated: true, + failureCleanupValidated: true, + sdkBoundaryCode: 'DeviceNotFound', + deletedFiles: 2, + deletedBytes: 4096, + }), + ); + expect(fixture.controller.withPreparedPlanArtifacts).toHaveBeenCalledTimes( + 52, + ); + expect(fixture.open).toHaveBeenCalledTimes(51); + expect(fixture.close).toHaveBeenCalledTimes(51); + expect(fixture.dispositions).toEqual([ + ...Array.from({ length: 51 }, () => 'completed' as const), + 'safeCancelled', + ]); + expect(sdkFixture.firmwareUpdateV3).toHaveBeenCalledTimes(1); + expect(sdkFixture.firmwareUpdateV3).toHaveBeenCalledWith( + '__firmware_sdk_self_test_no_device__', + { + preparedPlan: expect.any(Object), + platform: expect.any(String), + firmwareType: expect.any(String), + hostBindingGeneration: 7, + artifacts: expect.objectContaining({ + firmware: expect.objectContaining({ + size: fixture.artifact.expectedSize, + }), + }), + }, + ); + expect(progress).toHaveBeenLastCalledWith( + expect.objectContaining({ phase: 'sweeping', progress: 99 }), + ); + }); + + it('does not require archive metadata that is absent from config.json', async () => { + const result = await getFirmwareArtifactSelfTestArtifact('pro-resource'); + + expect(result).toEqual( + expect.objectContaining({ + artifactId: 'resource', + artifact: expect.objectContaining({ + container: 'zip', + url: 'https://firmware.example/pro-resources.zip', + }), + }), + ); + expect(result.artifact).not.toHaveProperty('expectedEntries'); + }); + + it('closes the production reader and releases with safeCancelled on failure', async () => { + const fixture = await createController({ + scenario: 'pro-firmware', + read: async ({ length }) => ({ + data: new ArrayBuffer(length - 1), + bytesRead: length - 1, + eof: false, + }), + }); + const { sdk } = createSdk(); + + await expect( + executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-reader', + sdk, + controller: fixture.controller, + onProgress: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_READER_CHUNK_INVALID'); + + expect(fixture.close).toHaveBeenCalledWith({ readerId: 'reader-1' }); + expect(fixture.dispositions).toEqual(['safeCancelled']); + expect(fixture.sweepOrphanedArtifacts).not.toHaveBeenCalled(); + }); + + it('fails if the controlled production cleanup does not release the lease', async () => { + const fixture = await createController({ + scenario: 'pro-firmware', + getReleaseResult: (transactionId) => ({ + hostBindingReleased: true, + leaseReleased: !transactionId.endsWith(':failure-cleanup'), + }), + }); + const { sdk } = createSdk(); + + await expect( + executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-cleanup', + sdk, + controller: fixture.controller, + onProgress: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_FAILURE_CLEANUP_FAILED'); + }); + + it('exposes only stable error codes', () => { + expect( + getFirmwareArtifactSelfTestErrorCode( + new OneKeyLocalError('ARTIFACT_HTTP_503: unavailable'), + ), + ).toBe('ARTIFACT_HTTP_503'); + expect( + getFirmwareArtifactSelfTestErrorCode( + new Error('https://secret.example/path failed'), + ), + ).toBe('SELF_TEST_FAILED'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts new file mode 100644 index 000000000000..0293183bea04 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts @@ -0,0 +1,599 @@ +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; + +import appCrypto from '@onekeyhq/shared/src/appCrypto'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; +import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; + +import { + isExternalFirmwareCapabilityReady, + resolveFirmwarePlanArtifact, +} from './FirmwareArtifactPreflight'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; +import { executePreparedFirmwareUpdateV3 } from './FirmwarePreparedExecution'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { + IFirmwareDownloadArtifact, + IPreparedFirmwareArtifacts, +} from './FirmwareArtifactPreflight'; +import type { + FirmwarePreparedArtifactController, + IFirmwarePreparedArtifactReleaseResult, +} from './FirmwarePreparedArtifactController'; +import type { CoreApi, FirmwareUpdatePlan } from '@onekeyfe/hd-core'; + +export type IFirmwareArtifactSelfTestScenario = + | 'pro-firmware' + | 'pro-resource' + | 'pro-full-resource'; + +export type IFirmwareArtifactSelfTestPhase = + | 'starting' + | 'preflight' + | 'reading' + | 'sdk-handoff' + | 'device-boundary' + | 'cache-stress' + | 'failure-cleanup' + | 'sweeping' + | 'completed' + | 'failed' + | 'cancelled'; + +export type IFirmwareArtifactSelfTestStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export type IFirmwareArtifactSelfTestDescriptor = { + scenario: IFirmwareArtifactSelfTestScenario; + label: string; + version: string; + role: IFirmwareDownloadArtifact['role']; + container: IFirmwareDownloadArtifact['container']; + expectedSize?: number; +}; + +export type IFirmwareArtifactSelfTestProgress = { + phase: Exclude< + IFirmwareArtifactSelfTestPhase, + 'starting' | 'completed' | 'failed' | 'cancelled' + >; + progress: number; + bytesRead?: number; + chunkCount?: number; + materializedEntryCount?: number; + preflightCompletedIterations?: number; +}; + +export type IFirmwareArtifactSelfTestResult = { + bytesRead: number; + chunkCount: number; + materializedEntryCount: number; + preflightCompletedIterations: number; + preparedPlanValidated: boolean; + sdkHandoffValidated: boolean; + cleanupValidated: boolean; + failureCleanupValidated: boolean; + sdkBoundaryCode: string; + deletedFiles: number; + deletedBytes: number; +}; + +export type IFirmwareArtifactSelfTestState = { + runId: string; + descriptor: IFirmwareArtifactSelfTestDescriptor; + platform: 'ios' | 'android' | 'desktop'; + status: IFirmwareArtifactSelfTestStatus; + phase: IFirmwareArtifactSelfTestPhase; + progress: number; + startedAt: number; + updatedAt: number; + completedAt?: number; + bytesRead: number; + chunkCount: number; + materializedEntryCount: number; + preflightCompletedIterations: number; + preparedPlanValidated: boolean; + sdkHandoffValidated: boolean; + cleanupValidated: boolean; + failureCleanupValidated: boolean; + sdkBoundaryCode?: string; + deletedFiles: number; + deletedBytes: number; + errorCode?: string; +}; + +type IFirmwareArtifactSelfTestHost = Pick< + FirmwarePreparedArtifactController, + | 'getExecutionArtifacts' + | 'sweepOrphanedArtifacts' + | 'withPreparedPlanArtifacts' +>; + +const READER_CHUNK_BYTES = 256 * 1024; +const PREFLIGHT_STRESS_READ_BYTES = 4 * 1024; +const PREFLIGHT_STRESS_ITERATIONS = 50; +const SDK_SELF_TEST_CONNECT_ID = '__firmware_sdk_self_test_no_device__'; +const CONTROLLED_FAILURE_CODE = 'EXPECTED_FAILURE_CLEANUP_PROBE'; + +type IFirmwareArtifactSdkFailure = { + code?: string | number; + error: string; +}; + +const getProRelease = async () => { + const config = await getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }); + const release = config.pro['firmware-v8']?.[0]; + if (!release) { + throw new OneKeyLocalError('Remote Pro firmware is unavailable'); + } + return release; +}; + +export const getFirmwareArtifactSelfTestArtifact = async ( + scenario: IFirmwareArtifactSelfTestScenario, +): Promise<{ + descriptor: IFirmwareArtifactSelfTestDescriptor; + artifact: IFirmwareDownloadArtifact; + artifactId: 'firmware' | 'resource'; +}> => { + const release = await getProRelease(); + let url: string | undefined; + let expectedSize: number | undefined; + let expectedSha256: string | undefined; + let label: string; + let artifactId: 'firmware' | 'resource'; + if (scenario === 'pro-firmware') { + url = release.url; + expectedSize = release.expectedSize; + expectedSha256 = release.fingerprint; + label = 'Pro firmware'; + artifactId = 'firmware'; + } else if (scenario === 'pro-resource') { + url = release.resource; + expectedSize = release.resourceExpectedSize; + expectedSha256 = release.resourceFingerprint; + label = 'Pro incremental resource'; + artifactId = 'resource'; + } else { + url = release.fullResource; + expectedSize = release.fullResourceExpectedSize; + expectedSha256 = release.fullResourceFingerprint; + label = 'Pro full resource'; + artifactId = 'resource'; + } + if (!url) { + throw new OneKeyLocalError(`${label} URL is unavailable`); + } + if ( + typeof expectedSize !== 'number' || + !Number.isSafeInteger(expectedSize) || + expectedSize <= 0 || + typeof expectedSha256 !== 'string' || + !/^[a-f0-9]{64}$/iu.test(expectedSha256) + ) { + throw new OneKeyLocalError(`${label} integrity metadata is unavailable`); + } + const artifact = resolveFirmwarePlanArtifact({ + artifactId, + role: artifactId, + target: artifactId, + url, + container: artifactId === 'resource' ? 'zip' : 'raw', + expectedSize, + expectedSha256, + }); + const version = release.version.join('.'); + return { + artifact, + artifactId, + descriptor: { + scenario, + label, + version, + role: artifact.role, + container: artifact.container, + expectedSize: artifact.expectedSize, + }, + }; +}; + +export const getFirmwareArtifactSelfTestPlatform = (): + | 'ios' + | 'android' + | 'desktop' => { + if (platformEnv.isNativeIOS) return 'ios'; + if (platformEnv.isNativeAndroid) return 'android'; + if (platformEnv.isDesktop) return 'desktop'; + throw new OneKeyLocalError( + 'Firmware artifact self-test requires iOS, Android, or Desktop', + ); +}; + +export const getFirmwareArtifactSelfTestErrorCode = ( + error: unknown, +): string => { + const message = error instanceof Error ? error.message : String(error); + return /\b(ARTIFACT_[A-Z0-9_]+)\b/u.exec(message)?.[1] ?? 'SELF_TEST_FAILED'; +}; + +const getSdkPlanPlatform = (): FirmwareUpdatePlan['platform'] => + platformEnv.isDesktop ? 'desktop' : 'native'; + +const digestFirmwareSelfTestPlan = async ( + plan: Omit, +): Promise => + bufferUtils.bytesToHex( + await appCrypto.hash.sha256( + bufferUtils.toBuffer(stringUtils.stableStringify(plan), 'utf8'), + ), + ); + +const buildFirmwareSelfTestPlan = async ({ + artifact, + artifactId, + scenario, + version, +}: { + artifact: IFirmwareDownloadArtifact; + artifactId: 'firmware' | 'resource'; + scenario: IFirmwareArtifactSelfTestScenario; + version: string; +}): Promise => { + const target = scenario === 'pro-firmware' ? 'firmware' : 'resource'; + const planWithoutDigest = { + schemaVersion: 2, + executor: 'v3', + deviceIdentity: 'firmware-self-test-pro', + deviceModel: String(EDeviceType.Pro), + firmwareType: EFirmwareType.Universal, + platform: getSdkPlanPlatform(), + artifacts: [ + { + artifactId, + role: target, + target, + url: artifact.url, + container: artifact.container, + ...(artifact.logicalName ? { logicalName: artifact.logicalName } : {}), + ...(artifact.expectedSize !== undefined + ? { expectedSize: artifact.expectedSize } + : {}), + ...(artifact.expectedSha256 + ? { expectedSha256: artifact.expectedSha256.toLowerCase() } + : {}), + targetVersion: version, + }, + ], + targetsToUpdate: [target], + } as unknown as Omit; + return { + ...planWithoutDigest, + planDigest: await digestFirmwareSelfTestPlan(planWithoutDigest), + }; +}; + +const readPreparedArtifact = async ({ + prepared, + artifactId, + expectedSize, + maxBytes, + onProgress, +}: { + prepared: IPreparedFirmwareArtifacts; + artifactId: 'firmware' | 'resource'; + expectedSize?: number; + maxBytes?: number; + onProgress?: ( + bytesRead: number, + chunkCount: number, + totalBytes: number, + ) => void; +}): Promise<{ bytesRead: number; chunkCount: number }> => { + const artifact = prepared.artifactsById[artifactId]; + if (!artifact) { + throw new OneKeyLocalError('ARTIFACT_PREPARED_REFERENCE_MISSING'); + } + const reader = await prepared.artifactReader.open({ + artifactRef: artifact.artifactRef, + }); + if ( + reader.size !== artifact.size || + (expectedSize !== undefined && artifact.size !== expectedSize) + ) { + await prepared.artifactReader.close({ readerId: reader.readerId }); + throw new OneKeyLocalError('ARTIFACT_READER_SIZE_MISMATCH'); + } + + const totalBytes = Math.min(maxBytes ?? reader.size, reader.size); + let bytesRead = 0; + let chunkCount = 0; + try { + while (bytesRead < totalBytes) { + const length = Math.min(READER_CHUNK_BYTES, totalBytes - bytesRead); + const chunk = await prepared.artifactReader.read({ + readerId: reader.readerId, + offset: bytesRead, + length, + }); + if ( + chunk.bytesRead !== length || + chunk.data.byteLength !== length || + chunk.eof !== (bytesRead + length === reader.size) + ) { + throw new OneKeyLocalError('ARTIFACT_READER_CHUNK_INVALID'); + } + bytesRead += chunk.bytesRead; + chunkCount += 1; + onProgress?.(bytesRead, chunkCount, reader.size); + } + } finally { + await prepared.artifactReader.close({ readerId: reader.readerId }); + } + return { bytesRead, chunkCount }; +}; + +const getSdkFailure = async ( + operation: () => ReturnType, +): Promise => { + let result: Awaited>; + try { + result = await operation(); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + if (result.success) { + throw new OneKeyLocalError( + 'SDK self-test unexpectedly reached device execution', + ); + } + return { + code: result.payload.code, + error: result.payload.error, + }; +}; + +const executeSdkDeviceBoundary = async ({ + controller, + sdk, + prepared, +}: { + controller: IFirmwareArtifactSelfTestHost; + sdk: CoreApi; + prepared: IPreparedFirmwareArtifacts; +}): Promise => { + const executionArtifacts = controller.getExecutionArtifacts( + prepared, + 'firmwareUpdateV3', + ); + const targetVersion = prepared.plan.artifacts[0]?.targetVersion + ?.split('.') + .map((value) => Number.parseInt(value, 10)); + const failure = await getSdkFailure(() => + executePreparedFirmwareUpdateV3({ + sdk, + connectId: SDK_SELF_TEST_CONNECT_ID, + ...executionArtifacts, + platform: prepared.plan.platform, + firmwareType: prepared.plan.firmwareType, + bleVersion: undefined, + firmwareVersion: + prepared.plan.targetsToUpdate.includes('firmware') && + targetVersion?.every(Number.isSafeInteger) + ? targetVersion + : undefined, + bootloaderVersion: undefined, + }), + ); + if ( + /prepared plan|artifact binding|host binding|artifact reader/iu.test( + failure.error, + ) + ) { + throw new OneKeyLocalError( + `ARTIFACT_SDK_HANDOFF_REJECTED: ${failure.error}`, + ); + } + const boundaryCode = String(failure.code ?? 'DEVICE_BOUNDARY_REACHED'); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'device-boundary', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + boundaryCode, + }); + return boundaryCode; +}; + +const assertCleanup = ( + cleanup: IFirmwarePreparedArtifactReleaseResult | undefined, + code: string, +): void => { + if (!cleanup?.hostBindingReleased || !cleanup.leaseReleased) { + throw new OneKeyLocalError(`ARTIFACT_${code}_FAILED`); + } +}; + +export const executeFirmwareArtifactSelfTest = async ({ + scenario, + transactionId, + sdk, + controller, + onProgress, +}: { + scenario: IFirmwareArtifactSelfTestScenario; + transactionId: string; + sdk: CoreApi; + controller: IFirmwareArtifactSelfTestHost; + onProgress: (progress: IFirmwareArtifactSelfTestProgress) => void; +}): Promise => { + if (!isExternalFirmwareCapabilityReady(sdk.getFirmwareUpdateCapabilities())) { + throw new OneKeyLocalError('ARTIFACT_SDK_CAPABILITY_MISMATCH'); + } + const { artifact, artifactId, descriptor } = + await getFirmwareArtifactSelfTestArtifact(scenario); + const plan = await buildFirmwareSelfTestPlan({ + artifact, + artifactId, + scenario, + version: descriptor.version, + }); + + let mainCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + onProgress({ phase: 'preflight', progress: 5 }); + const mainResult = await controller.withPreparedPlanArtifacts( + { plan, sdk, transactionId }, + async (prepared) => { + onProgress({ phase: 'reading', progress: 65 }); + const readerResult = await readPreparedArtifact({ + prepared, + artifactId, + expectedSize: artifact.expectedSize, + onProgress: (bytesRead, chunkCount, totalBytes) => { + onProgress({ + phase: 'reading', + progress: 65 + Math.floor((bytesRead / totalBytes) * 20), + bytesRead, + chunkCount, + }); + }, + }); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'reader-complete', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + readerBytes: readerResult.bytesRead, + readerChunks: readerResult.chunkCount, + }); + onProgress({ + phase: 'sdk-handoff', + progress: 88, + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + }); + const sdkBoundaryCode = await executeSdkDeviceBoundary({ + controller, + sdk, + prepared, + }); + onProgress({ + phase: 'device-boundary', + progress: 90, + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + }); + return { + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + sdkBoundaryCode, + }; + }, + (cleanup) => { + mainCleanup = cleanup; + }, + ); + assertCleanup(mainCleanup, 'MAIN_CLEANUP'); + + let preflightCompletedIterations = 0; + if (scenario === 'pro-firmware') { + for (let index = 0; index < PREFLIGHT_STRESS_ITERATIONS; index += 1) { + let iterationCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + await controller.withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId: `${transactionId}:stress:${index + 1}`, + }, + async (prepared) => { + await readPreparedArtifact({ + prepared, + artifactId, + expectedSize: artifact.expectedSize, + maxBytes: PREFLIGHT_STRESS_READ_BYTES, + }); + }, + (cleanup) => { + iterationCleanup = cleanup; + }, + ); + assertCleanup(iterationCleanup, 'CACHE_STRESS_CLEANUP'); + preflightCompletedIterations += 1; + onProgress({ + phase: 'cache-stress', + progress: + 91 + + Math.floor( + (preflightCompletedIterations / PREFLIGHT_STRESS_ITERATIONS) * 5, + ), + preflightCompletedIterations, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + }); + } + } + + let failureCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + try { + await controller.withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId: `${transactionId}:failure-cleanup`, + }, + async () => { + throw new OneKeyLocalError(CONTROLLED_FAILURE_CODE); + }, + (cleanup) => { + failureCleanup = cleanup; + }, + ); + throw new OneKeyLocalError('ARTIFACT_FAILURE_CLEANUP_NOT_TRIGGERED'); + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== CONTROLLED_FAILURE_CODE + ) { + throw error; + } + } + assertCleanup(failureCleanup, 'FAILURE_CLEANUP'); + onProgress({ + phase: 'failure-cleanup', + progress: 97, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + preflightCompletedIterations, + }); + + onProgress({ + phase: 'sweeping', + progress: 99, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + preflightCompletedIterations, + }); + const sweep = await controller.sweepOrphanedArtifacts(); + return { + ...mainResult, + preflightCompletedIterations, + preparedPlanValidated: true, + sdkHandoffValidated: true, + cleanupValidated: true, + failureCleanupValidated: true, + deletedFiles: sweep.deletedFiles, + deletedBytes: sweep.deletedBytes, + }; +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts new file mode 100644 index 000000000000..14c60d15f710 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts @@ -0,0 +1,267 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import { importHardwareSDK } from '@onekeyhq/shared/src/hardware/sdk-loader'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; + +import { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, + getFirmwareArtifactSelfTestPlatform, +} from './FirmwareArtifactSelfTest'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +import type { + IFirmwareArtifactSelfTestPhase, + IFirmwareArtifactSelfTestProgress, + IFirmwareArtifactSelfTestResult, + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from './FirmwareArtifactSelfTest'; +import type { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; +import type { CoreApi } from '@onekeyfe/hd-core'; + +type IFirmwareArtifactSelfTestControllerDependencies = { + getHardwareTransportType: () => Promise; + getSDKInstance: () => Promise; +}; + +export class FirmwareArtifactSelfTestController { + private state?: IFirmwareArtifactSelfTestState; + + constructor( + private readonly dependencies: IFirmwareArtifactSelfTestControllerDependencies, + private readonly preparedArtifacts: FirmwarePreparedArtifactController, + ) {} + + private updateState({ + phase, + progress, + bytesRead, + chunkCount, + materializedEntryCount, + preflightCompletedIterations, + }: IFirmwareArtifactSelfTestProgress): void { + const current = this.state; + if (!current || current.status !== 'running') return; + const next = { + ...current, + phase, + progress, + updatedAt: Date.now(), + bytesRead: bytesRead ?? current.bytesRead, + chunkCount: chunkCount ?? current.chunkCount, + materializedEntryCount: + materializedEntryCount ?? current.materializedEntryCount, + preflightCompletedIterations: + preflightCompletedIterations ?? current.preflightCompletedIterations, + }; + this.state = next; + const enteredNewPhase = phase !== current.phase; + const enteredNewProgressBucket = + Math.floor(progress / 5) !== Math.floor(current.progress / 5); + if (enteredNewPhase || enteredNewProgressBucket) { + this.log({ state: next, outcome: 'progress' }); + } + } + + private log({ + state, + outcome, + }: { + state: IFirmwareArtifactSelfTestState; + outcome: 'started' | 'progress' | 'success' | 'failure' | 'cancelled'; + }): void { + defaultLogger.update.firmware.firmwareArtifactSelfTest({ + runId: state.runId, + runtime: 'bg', + platform: state.platform, + scenario: state.descriptor.scenario, + phase: state.phase, + outcome, + durationMs: Date.now() - state.startedAt, + bytes: state.bytesRead || undefined, + chunkCount: state.chunkCount || undefined, + materializedEntryCount: state.materializedEntryCount || undefined, + preflightCompletedIterations: + state.preflightCompletedIterations || undefined, + preparedPlanValidated: state.preparedPlanValidated || undefined, + sdkHandoffValidated: state.sdkHandoffValidated || undefined, + cleanupValidated: state.cleanupValidated || undefined, + failureCleanupValidated: state.failureCleanupValidated || undefined, + sdkBoundaryCode: state.sdkBoundaryCode, + errorCode: state.errorCode, + }); + } + + private async getSdk(): Promise<{ + sdk: CoreApi; + disposeAfterTest: boolean; + }> { + if (!platformEnv.isDesktop) { + return { + sdk: await this.dependencies.getSDKInstance(), + disposeAfterTest: false, + }; + } + const transportType = await this.dependencies.getHardwareTransportType(); + if (isDirectFirmwareHostBindingTransport(transportType)) { + return { + sdk: await this.dependencies.getSDKInstance(), + disposeAfterTest: false, + }; + } + const sdk = await importHardwareSDK({ + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + const initialized = await sdk.init({ + debug: false, + env: 'desktop-webusb', + fetchConfig: false, + firmwareManifestMode: 'external-only', + preloadedConfig: await getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }), + }); + if (!initialized) { + throw new OneKeyLocalError( + 'Firmware SDK self-test direct instance failed to initialize', + ); + } + return { sdk, disposeAfterTest: true }; + } + + private finish({ + phase, + status, + result, + errorCode, + }: { + phase: Extract< + IFirmwareArtifactSelfTestPhase, + 'completed' | 'failed' | 'cancelled' + >; + status: Extract< + IFirmwareArtifactSelfTestState['status'], + 'completed' | 'failed' | 'cancelled' + >; + result?: IFirmwareArtifactSelfTestResult; + errorCode?: string; + }): void { + const current = this.state; + if (!current) return; + const completedAt = Date.now(); + const next = { + ...current, + phase, + status, + progress: status === 'completed' ? 100 : current.progress, + updatedAt: completedAt, + completedAt, + bytesRead: result?.bytesRead ?? current.bytesRead, + chunkCount: result?.chunkCount ?? current.chunkCount, + materializedEntryCount: + result?.materializedEntryCount ?? current.materializedEntryCount, + preflightCompletedIterations: + result?.preflightCompletedIterations ?? + current.preflightCompletedIterations, + preparedPlanValidated: + result?.preparedPlanValidated ?? current.preparedPlanValidated, + sdkHandoffValidated: + result?.sdkHandoffValidated ?? current.sdkHandoffValidated, + cleanupValidated: result?.cleanupValidated ?? current.cleanupValidated, + failureCleanupValidated: + result?.failureCleanupValidated ?? current.failureCleanupValidated, + sdkBoundaryCode: result?.sdkBoundaryCode ?? current.sdkBoundaryCode, + deletedFiles: result?.deletedFiles ?? current.deletedFiles, + deletedBytes: result?.deletedBytes ?? current.deletedBytes, + errorCode, + }; + this.state = next; + let outcome: 'success' | 'failure' | 'cancelled' = 'failure'; + if (status === 'completed') { + outcome = 'success'; + } else if (status === 'cancelled') { + outcome = 'cancelled'; + } + this.log({ state: next, outcome }); + } + + async start( + scenario: IFirmwareArtifactSelfTestScenario, + ): Promise { + if (this.state?.status === 'running') { + throw new OneKeyLocalError( + 'Another firmware artifact self-test is already running', + ); + } + const runId = generateUUID(); + // cspell:disable-next-line + const transactionId = `fwtx:${runId}`; + const now = Date.now(); + const state: IFirmwareArtifactSelfTestState = { + runId, + descriptor: (await getFirmwareArtifactSelfTestArtifact(scenario)) + .descriptor, + platform: getFirmwareArtifactSelfTestPlatform(), + status: 'running', + phase: 'starting', + progress: 0, + startedAt: now, + updatedAt: now, + bytesRead: 0, + chunkCount: 0, + materializedEntryCount: 0, + preflightCompletedIterations: 0, + preparedPlanValidated: false, + sdkHandoffValidated: false, + cleanupValidated: false, + failureCleanupValidated: false, + deletedFiles: 0, + deletedBytes: 0, + }; + this.state = state; + this.log({ state, outcome: 'started' }); + void this.getSdk() + .then(async ({ sdk, disposeAfterTest }) => { + try { + return await executeFirmwareArtifactSelfTest({ + scenario, + transactionId, + sdk, + controller: this.preparedArtifacts, + onProgress: (next) => this.updateState(next), + }); + } finally { + if (disposeAfterTest) { + await sdk.dispose(); + } + } + }) + .then((result) => { + this.finish({ + phase: 'completed', + status: 'completed', + result, + }); + }) + .catch((error) => { + const errorCode = getFirmwareArtifactSelfTestErrorCode(error); + const cancelled = errorCode === 'ARTIFACT_CANCELLED'; + this.finish({ + phase: cancelled ? 'cancelled' : 'failed', + status: cancelled ? 'cancelled' : 'failed', + errorCode, + }); + }); + return state; + } + + getState(): IFirmwareArtifactSelfTestState | undefined { + return this.state; + } +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts new file mode 100644 index 000000000000..fbcddd8afc45 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts @@ -0,0 +1,139 @@ +import { fetchFirmwareConfig } from '@onekeyhq/shared/src/hardware/firmwareConfigProvider'; + +import { + FIRMWARE_MANIFEST_CACHE_TTL_MS, + clearFirmwareManifestSnapshotCache, + getFirmwareManifestSnapshot, +} from './FirmwareManifestProvider'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +jest.mock('@onekeyhq/shared/src/hardware/firmwareConfigProvider', () => ({ + fetchFirmwareConfig: jest.fn(), +})); + +jest.mock('@onekeyhq/shared/src/logger/logger', () => ({ + defaultLogger: { + ipTable: { + request: { + info: jest.fn(), + }, + }, + }, +})); + +const mockedFetchFirmwareConfig = fetchFirmwareConfig as jest.Mock; +const remoteConfig = { + bridge: { version: 'remote' }, +} as unknown as RemoteConfigResponse; +describe('FirmwareManifestProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearFirmwareManifestSnapshotCache(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the App-fetched remote snapshot', async () => { + mockedFetchFirmwareConfig.mockResolvedValue(remoteConfig); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + }); + + it.each([null, new Error('transport failed')])( + 'fails closed without a remote or cached snapshot for %s', + async (remoteResult) => { + if (remoteResult instanceof Error) { + mockedFetchFirmwareConfig.mockRejectedValue(remoteResult); + } else { + mockedFetchFirmwareConfig.mockResolvedValue(remoteResult); + } + + await expect( + getFirmwareManifestSnapshot({ preRelease: true }), + ).rejects.toThrow('Firmware manifest is unavailable'); + }, + ); + + it('accepts the existing config shape without optional integrity fields', async () => { + const config = { + ...remoteConfig, + pro: { + 'firmware-v8': [ + { + required: false, + version: [4, 21, 0], + url: 'https://firmware.example/pro.bin', + fingerprint: '', + changelog: {}, + }, + ], + }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig.mockResolvedValue(config); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(config); + }); + + it('reuses a snapshot only within the fixed refresh interval', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + const refreshedConfig = { + bridge: { version: 'refreshed' }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockResolvedValueOnce(refreshedConfig); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(1000 + FIRMWARE_MANIFEST_CACHE_TTL_MS); + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(refreshedConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(2); + }); + + it('forces a network refresh before an explicit firmware check', async () => { + const refreshedConfig = { + bridge: { version: 'refreshed' }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockResolvedValueOnce(refreshedConfig); + + await getFirmwareManifestSnapshot({ preRelease: false }); + await expect( + getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }), + ).resolves.toBe(refreshedConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(2); + }); + + it('does not use a stale snapshot for an explicit firmware check', async () => { + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockRejectedValueOnce(new Error('transport failed')); + + await getFirmwareManifestSnapshot({ preRelease: true }); + await expect( + getFirmwareManifestSnapshot({ + preRelease: true, + forceRefresh: true, + }), + ).rejects.toThrow('Firmware manifest is unavailable'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts new file mode 100644 index 000000000000..7241410e3951 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts @@ -0,0 +1,81 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { fetchFirmwareConfig } from '@onekeyhq/shared/src/hardware/firmwareConfigProvider'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +export const FIRMWARE_MANIFEST_CACHE_TTL_MS = 3 * 60 * 60 * 1000; + +type IFirmwareManifestCacheEntry = { + config: RemoteConfigResponse; + expiresAt: number; +}; + +const firmwareManifestCache = new Map(); +const firmwareManifestRefreshes = new Map< + boolean, + Promise +>(); + +export function clearFirmwareManifestSnapshotCache(): void { + firmwareManifestCache.clear(); + firmwareManifestRefreshes.clear(); +} + +async function refreshFirmwareManifestSnapshot({ + preRelease, + allowStaleFallback, +}: { + preRelease: boolean; + allowStaleFallback: boolean; +}): Promise { + const cached = firmwareManifestCache.get(preRelease); + try { + const remoteConfig = await fetchFirmwareConfig({ preRelease }); + if (remoteConfig) { + firmwareManifestCache.set(preRelease, { + config: remoteConfig, + expiresAt: Date.now() + FIRMWARE_MANIFEST_CACHE_TTL_MS, + }); + return remoteConfig; + } + } catch { + // Keep using the last known-good remote snapshot below. + } + + if (allowStaleFallback && cached) { + defaultLogger.ipTable.request.info({ + info: '[FirmwareManifest] config_fetch route=cache outcome=stale_fallback', + }); + return cached.config; + } + + throw new OneKeyLocalError('Firmware manifest is unavailable'); +} + +export async function getFirmwareManifestSnapshot({ + preRelease, + forceRefresh = false, +}: { + preRelease: boolean; + forceRefresh?: boolean; +}): Promise { + const cached = firmwareManifestCache.get(preRelease); + if (!forceRefresh && cached && cached.expiresAt > Date.now()) { + return cached.config; + } + + const pendingRefresh = firmwareManifestRefreshes.get(preRelease); + if (pendingRefresh) { + return pendingRefresh; + } + + const refresh = refreshFirmwareManifestSnapshot({ + preRelease, + allowStaleFallback: !forceRefresh, + }).finally(() => { + firmwareManifestRefreshes.delete(preRelease); + }); + firmwareManifestRefreshes.set(preRelease, refresh); + return refresh; +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts new file mode 100644 index 000000000000..5f4edbd9c3a5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts @@ -0,0 +1,674 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReady, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + withFirmwareArtifactStageTimeout, +} from './FirmwareArtifactPreflight'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { + IBridgeFirmwareBinaries, + IPreparedFirmwareArtifacts, +} from './FirmwareArtifactPreflight'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePlanTarget, +} from '@onekeyfe/hd-core'; + +export type IFirmwareWorkflowArtifacts = + | IPreparedFirmwareArtifacts + | IBridgeFirmwareBinaries; + +export type IFirmwareExecutionArtifacts = { + preparedArtifacts?: IPreparedFirmwareArtifacts; + bridgeBinaries?: IBridgeFirmwareBinaries; + hostBindingGeneration?: number; +}; + +export type IFirmwarePreparedArtifactReleaseResult = { + hostBindingReleased: boolean; + leaseReleased: boolean; +}; + +type IFirmwareHostBinding = { + sdk: CoreApi; + generation: number; +}; + +type IFirmwarePreparedArtifactControllerDependencies = { + getHardwareTransportType: () => Promise; + getSDKInstance: (connectId: string | undefined) => Promise; +}; + +const getPreparedArtifactTraceSummary = ( + prepared: IPreparedFirmwareArtifacts, +) => { + const artifactReferences = Object.values(prepared.artifactsById ?? {}); + const resourceEntries = prepared.selected.resourceEntries ?? []; + return { + count: artifactReferences.length, + bytes: artifactReferences.reduce( + (total, artifact) => total + artifact.size, + 0, + ), + firmwareBytes: prepared.selected.firmware?.size, + bleBytes: prepared.selected.ble?.size, + bootloaderBytes: prepared.selected.bootloader?.size, + resourceCount: resourceEntries.length, + resourceBytes: resourceEntries.reduce( + (total, entry) => total + entry.artifact.size, + 0, + ), + integrityVerified: + artifactReferences.length > 0 && + artifactReferences.every( + (artifact) => + artifact.size > 0 && + typeof artifact.sha256 === 'string' && + artifact.sha256.length > 0, + ), + }; +}; + +const getBridgeArtifactTraceSummary = (bridge: IBridgeFirmwareBinaries) => { + const binaries = Object.values(bridge.targetBinaries).filter( + (binary): binary is ArrayBuffer => binary !== undefined, + ); + const firmware = bridge.targetBinaries.firmware; + const ble = bridge.targetBinaries.ble; + const bootloader = bridge.targetBinaries.bootloader; + return { + count: binaries.length, + bytes: binaries.reduce((total, binary) => total + binary.byteLength, 0), + firmwareBytes: firmware?.byteLength, + bleBytes: ble?.byteLength, + bootloaderBytes: bootloader?.byteLength, + resourceCount: 0, + resourceBytes: 0, + integrityVerified: binaries.length > 0, + }; +}; + +const assertProtocolV2PlanTargetsMatch = ({ + plan, + expectedTargets, +}: { + plan: FirmwareUpdatePlan; + expectedTargets: readonly FirmwareUpdatePlanTarget[]; +}): void => { + if (plan.executor !== 'v4') return; + const planTargets = new Set(plan.targetsToUpdate); + const selectedTargets = new Set(expectedTargets); + if ( + planTargets.size !== selectedTargets.size || + [...selectedTargets].some((target) => !planTargets.has(target)) + ) { + throw new OneKeyLocalError( + 'Firmware update plan targets do not match the selected Protocol V2 targets', + ); + } +}; + +const assertFirmwareUpdatePlanCoverage = ({ + plan, + expectedTargets, +}: { + plan: FirmwareUpdatePlan; + expectedTargets: readonly FirmwareUpdatePlanTarget[]; +}): void => { + if ( + !Array.isArray(plan.artifacts) || + plan.artifacts.length === 0 || + !Array.isArray(plan.targetsToUpdate) || + plan.targetsToUpdate.length === 0 + ) { + throw new OneKeyLocalError( + 'Firmware update plan has no executable artifacts', + ); + } + + const artifactTargets = new Set( + plan.artifacts.map((artifact) => artifact.target), + ); + const planTargets = new Set(plan.targetsToUpdate); + assertProtocolV2PlanTargetsMatch({ plan, expectedTargets }); + if ( + [...artifactTargets].some((target) => !planTargets.has(target)) || + [...planTargets].some((target) => !artifactTargets.has(target)) + ) { + throw new OneKeyLocalError( + 'Firmware update plan target coverage is incomplete', + ); + } + const resourceArtifacts = plan.artifacts.filter( + (artifact) => artifact.target === 'resource', + ); + if ( + plan.executor === 'v4' && + planTargets.has('resource') && + (resourceArtifacts.length !== 1 || + resourceArtifacts[0]?.role !== 'resourceBundle' || + resourceArtifacts[0]?.container !== 'zip') + ) { + throw new OneKeyLocalError( + 'Protocol V2 resource target requires exactly one ZIP archive artifact', + ); + } + + const coversExpectedTarget = (expectedTarget: FirmwareUpdatePlanTarget) => { + switch (expectedTarget) { + case 'firmware': + return plan.artifacts.some( + (artifact) => + artifact.role === 'firmware' || + (artifact.role === 'component' && artifact.target !== 'resource'), + ); + case 'ble': + return plan.artifacts.some( + (artifact) => + artifact.role === 'ble' || artifact.target === 'coprocessor', + ); + case 'bootloader': + return plan.artifacts.some( + (artifact) => + artifact.role === 'bootloader' || + (artifact.role === 'component' && artifact.target === 'boot'), + ); + case 'resource': + return plan.artifacts.some( + (artifact) => + artifact.role === 'resource' || artifact.role === 'resourceBundle', + ); + default: + return planTargets.has(expectedTarget); + } + }; + + if ( + [...new Set(expectedTargets)].some( + (expectedTarget) => !coversExpectedTarget(expectedTarget), + ) + ) { + throw new OneKeyLocalError( + 'Firmware update plan does not cover every selected target', + ); + } +}; + +export class FirmwarePreparedArtifactController { + private plans = new Map(); + + private hostBindings = new Map(); + + private readonly dependencies: IFirmwarePreparedArtifactControllerDependencies; + + constructor(dependencies: IFirmwarePreparedArtifactControllerDependencies) { + this.dependencies = dependencies; + } + + private async getExternalSdk( + connectId: string | undefined, + ): Promise { + if (!(await isFirmwareArtifactCapabilityReady())) { + return undefined; + } + if (platformEnv.isDesktop) { + const transportType = await this.dependencies.getHardwareTransportType(); + if (!isDirectFirmwareHostBindingTransport(transportType)) { + return undefined; + } + } + const sdk = await this.dependencies.getSDKInstance(connectId); + try { + return isExternalFirmwareCapabilityReady( + sdk.getFirmwareUpdateCapabilities?.(), + ) && + typeof sdk.prepareFirmwareUpdatePlan === 'function' && + typeof sdk.validateFirmwareUpdatePreparedPlan === 'function' && + typeof sdk.registerFirmwareUpdateHostBinding === 'function' && + typeof sdk.unregisterFirmwareUpdateHostBinding === 'function' + ? sdk + : undefined; + } catch { + return undefined; + } + } + + async cachePlanIfPreparedSupported({ + plan, + connectId, + transportType, + expectedTargets = [], + }: { + plan: FirmwareUpdatePlan; + connectId: string | undefined; + transportType: EHardwareTransportType; + expectedTargets?: readonly FirmwareUpdatePlanTarget[]; + }): Promise { + assertFirmwareUpdatePlanCoverage({ plan, expectedTargets }); + this.plans.set(plan.planDigest, plan); + if (this.plans.size > 16) { + const oldestDigest = this.plans.keys().next().value; + if (oldestDigest) { + this.plans.delete(oldestDigest); + } + } + if (!platformEnv.isNative && !platformEnv.isDesktop) { + return true; + } + const externalSdk = await this.getExternalSdk(connectId); + const bridgeCapabilityReady = + platformEnv.isDesktop && + transportType === EHardwareTransportType.Bridge && + (await isFirmwareArtifactCapabilityReady()); + if (!bridgeCapabilityReady && !externalSdk) { + return false; + } + return true; + } + + async cachePlanDigestIfPreparedSupported({ + hasUpgrade, + plan, + connectId, + transportType, + expectedTargets = [], + requirePreparedPlan = false, + }: { + hasUpgrade: boolean | undefined; + plan: FirmwareUpdatePlan | undefined; + connectId: string | undefined; + transportType: EHardwareTransportType; + expectedTargets?: readonly FirmwareUpdatePlanTarget[]; + requirePreparedPlan?: boolean; + }): Promise { + if (!hasUpgrade) return undefined; + if (!plan) { + if (requirePreparedPlan) { + throw new OneKeyLocalError( + 'Firmware update plan is unavailable from the fresh firmware manifest', + ); + } + return undefined; + } + const preparedPlanSupported = await this.cachePlanIfPreparedSupported({ + plan, + connectId, + transportType, + expectedTargets, + }); + if (!preparedPlanSupported) { + if (requirePreparedPlan) { + throw new OneKeyLocalError( + 'Firmware prepared artifact capability is unavailable on this runtime', + ); + } + return undefined; + } + return plan.planDigest; + } + + getPlan(releaseResult: ICheckAllFirmwareReleaseResult): FirmwareUpdatePlan { + const planDigest = releaseResult.firmwareUpdatePlanDigest; + const plan = planDigest ? this.plans.get(planDigest) : undefined; + if (!plan) { + throw new OneKeyLocalError( + 'Firmware update plan is unavailable; check for updates again', + ); + } + if ( + plan.deviceIdentity !== (releaseResult.deviceUUID || 'unavailable') || + plan.deviceModel !== String(releaseResult.deviceType) || + plan.platform !== (platformEnv.symbol ?? 'web') + ) { + throw new OneKeyLocalError( + 'Firmware update plan does not match the selected device', + ); + } + assertProtocolV2PlanTargetsMatch({ + plan, + expectedTargets: releaseResult.pro2TargetsToUpdate ?? [], + }); + return plan; + } + + private bindHost(prepared: IPreparedFirmwareArtifacts, sdk: CoreApi): void { + const existing = this.hostBindings.get(prepared.transactionId); + if (existing) { + existing.sdk.unregisterFirmwareUpdateHostBinding(existing.generation); + } + const generation = sdk.registerFirmwareUpdateHostBinding({ + artifactReader: prepared.artifactReader, + preparedPlanDigest: prepared.preparedPlan.preparedPlanDigest, + }); + if (!Number.isSafeInteger(generation) || generation <= 0) { + throw new OneKeyLocalError( + 'Firmware SDK returned an invalid host binding generation', + ); + } + this.hostBindings.set(prepared.transactionId, { sdk, generation }); + } + + private releaseHost(transactionId: string): boolean { + const binding = this.hostBindings.get(transactionId); + if (!binding) return false; + try { + return binding.sdk.unregisterFirmwareUpdateHostBinding( + binding.generation, + ); + } finally { + this.hostBindings.delete(transactionId); + } + } + + getExecutionBindingParams(preparedArtifacts: IPreparedFirmwareArtifacts): { + hostBindingGeneration: number; + } { + const binding = this.hostBindings.get(preparedArtifacts.transactionId); + if (!binding) { + throw new OneKeyLocalError('Firmware host binding is unavailable'); + } + return { + hostBindingGeneration: binding.generation, + }; + } + + getExecutionArtifacts( + artifacts: IFirmwareWorkflowArtifacts | undefined, + sdkMethod?: string, + ): IFirmwareExecutionArtifacts { + const prepared = + artifacts && 'preparedPlan' in artifacts ? artifacts : undefined; + const bridge = + artifacts && 'targetBinaries' in artifacts ? artifacts : undefined; + const executionArtifacts = { + preparedArtifacts: prepared, + bridgeBinaries: bridge, + hostBindingGeneration: prepared + ? this.getExecutionBindingParams(prepared).hostBindingGeneration + : undefined, + }; + if (prepared) { + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'sdk-handoff', + executor: prepared.plan.executor, + sdkMethod, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + hostBindingProvided: Boolean(executionArtifacts.hostBindingGeneration), + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + } else if (bridge) { + firmwareUpdateTrace({ + transactionId: bridge.transactionId, + stage: 'sdk-handoff', + executor: bridge.executor, + sdkMethod, + inputMode: 'bridge-binary', + preparedPlanProvided: false, + hostBindingProvided: false, + artifacts: getBridgeArtifactTraceSummary(bridge), + }); + } + return executionArtifacts; + } + + async preparePlanArtifacts({ + plan, + sdk, + // cspell:disable-next-line + transactionId = `fwtx:${generateUUID().toLowerCase()}`, + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }): Promise { + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-start', + executor: plan.executor, + inputMode: 'artifact-reader', + expectedArtifactCount: plan.artifacts.length, + }); + const leaseOperation = firmwareArtifactAdapter.createLease(transactionId); + let leaseRef: string; + try { + ({ leaseRef } = await withFirmwareArtifactStageTimeout( + 'LEASE_CREATE', + leaseOperation, + )); + } catch (error) { + void leaseOperation + .then(({ leaseRef: lateLeaseRef }) => + firmwareArtifactAdapter.releaseLease({ + leaseRef: lateLeaseRef, + disposition: 'safeCancelled', + }), + ) + .catch(() => undefined); + throw error; + } + firmwareUpdateTrace({ + transactionId, + stage: 'lease-created', + executor: plan.executor, + inputMode: 'artifact-reader', + }); + try { + const prepared = await prepareFirmwareArtifacts(plan, { + transactionId, + leaseRef, + preparePlan: sdk.prepareFirmwareUpdatePlan, + }); + const validatedPreparedPlan = sdk.validateFirmwareUpdatePreparedPlan( + prepared.preparedPlan, + ); + if ( + validatedPreparedPlan.preparedPlanDigest !== + prepared.preparedPlan.preparedPlanDigest + ) { + throw new OneKeyLocalError( + 'Firmware prepared plan validation returned a different digest', + ); + } + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-ready', + executor: plan.executor, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + this.bindHost(prepared, sdk); + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-complete', + executor: plan.executor, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + hostBindingProvided: true, + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + return prepared; + } catch (error) { + this.releaseHost(transactionId); + await firmwareArtifactAdapter + .cancelDownloads(transactionId) + .catch(() => undefined); + await firmwareArtifactAdapter + .releaseLease({ leaseRef, disposition: 'safeCancelled' }) + .catch(() => undefined); + throw error; + } + } + + async withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId, + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }, + execute: (artifacts: IPreparedFirmwareArtifacts) => Promise, + onReleased?: (result: IFirmwarePreparedArtifactReleaseResult) => void, + ): Promise { + const prepared = await this.preparePlanArtifacts({ + plan, + sdk, + transactionId, + }); + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(prepared); + disposition = 'completed'; + return result; + } finally { + onReleased?.(await this.releasePreparedArtifacts(prepared, disposition)); + } + } + + private async prepareExternal( + releaseResult: ICheckAllFirmwareReleaseResult, + ): Promise { + if (!platformEnv.isNative && !platformEnv.isDesktop) { + return undefined; + } + if (!releaseResult.firmwareUpdatePlanDigest) { + return undefined; + } + const plan = this.getPlan(releaseResult); + const sdk = await this.getExternalSdk(releaseResult.updatingConnectId); + if (!sdk) { + throw new OneKeyLocalError( + 'Firmware external SDK capability is unavailable', + ); + } + return this.preparePlanArtifacts({ plan, sdk }); + } + + async prepareWorkflowArtifacts( + releaseResult: ICheckAllFirmwareReleaseResult, + ): Promise { + if (!releaseResult.firmwareUpdatePlanDigest) return undefined; + if (!platformEnv.isNative && !platformEnv.isDesktop) return undefined; + if (platformEnv.isDesktop) { + const transportType = await this.dependencies.getHardwareTransportType(); + if (transportType === EHardwareTransportType.Bridge) { + const plan = this.getPlan(releaseResult); + const transactionId = `bridge:${generateUUID().toLowerCase()}`; + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-start', + executor: plan.executor, + inputMode: 'bridge-binary', + expectedArtifactCount: plan.artifacts.length, + }); + const prepared = await prepareBridgeFirmwareBinaries( + plan, + transactionId, + ); + if (prepared) { + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-complete', + executor: plan.executor, + inputMode: 'bridge-binary', + preparedPlanProvided: false, + hostBindingProvided: false, + artifacts: getBridgeArtifactTraceSummary(prepared), + }); + } + return prepared; + } + if (!isDirectFirmwareHostBindingTransport(transportType)) { + throw new OneKeyLocalError( + 'Firmware prepared transport is unavailable', + ); + } + } + const prepared = await this.prepareExternal(releaseResult); + if (!prepared) { + throw new OneKeyLocalError('Firmware artifacts are not prepared'); + } + return prepared; + } + + async withWorkflowArtifacts( + releaseResult: ICheckAllFirmwareReleaseResult, + execute: (artifacts: IFirmwareWorkflowArtifacts | undefined) => Promise, + ): Promise { + const artifacts = await this.prepareWorkflowArtifacts(releaseResult); + const prepared = + artifacts && 'preparedPlan' in artifacts ? artifacts : undefined; + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(artifacts); + disposition = 'completed'; + return result; + } finally { + if (prepared) { + await this.releasePreparedArtifacts(prepared, disposition); + } + } + } + + async releasePreparedArtifacts( + prepared: IPreparedFirmwareArtifacts, + disposition: 'completed' | 'safeCancelled', + ): Promise { + let hostBindingReleased = false; + let leaseReleased = false; + try { + hostBindingReleased = this.releaseHost(prepared.transactionId); + } catch { + hostBindingReleased = false; + } + if (disposition === 'safeCancelled') { + await firmwareArtifactAdapter + .cancelDownloads(prepared.transactionId) + .catch(() => undefined); + } + await withFirmwareArtifactStageTimeout( + 'LEASE_RELEASE', + firmwareArtifactAdapter.releaseLease({ + leaseRef: prepared.leaseRef, + disposition, + }), + ) + .then(() => { + leaseReleased = true; + }) + .catch(() => undefined); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'release-complete', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + disposition, + hostBindingReleased, + leaseReleased, + }); + return { hostBindingReleased, leaseReleased }; + } + + sweepOrphanedArtifacts(): Promise<{ + deletedFiles: number; + deletedBytes: number; + }> { + return firmwareArtifactAdapter.sweepOrphans(); + } +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts new file mode 100644 index 000000000000..1afee01ff70a --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts @@ -0,0 +1,386 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; + +import { + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, +} from './FirmwareArtifactPreflight'; + +import type { IFirmwareArtifactReference } from './FirmwareArtifactPreflight'; +import type { IFirmwareExecutionArtifacts } from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePreparedPlan, + FirmwareUpdateV4Target, +} from '@onekeyfe/hd-core'; +import type { EFirmwareType } from '@onekeyfe/hd-shared'; + +type IFirmwarePlatform = 'native' | 'desktop' | 'ext' | 'web' | 'web-embed'; + +const FIRMWARE_UPDATE_V4_TARGETS = new Set([ + 'boot', + 'app_v1', + 'app_v2', + 'coprocessor', + 'resource', + 'se01', + 'se02', + 'se03', + 'se04', +]); + +export const getFirmwareUpdateV4Targets = ( + targets: readonly string[], +): FirmwareUpdateV4Target[] => { + const supported = targets.filter((target): target is FirmwareUpdateV4Target => + FIRMWARE_UPDATE_V4_TARGETS.has(target as FirmwareUpdateV4Target), + ); + if (supported.length !== targets.length) { + throw new OneKeyLocalError( + 'Protocol V2 firmware update plan contains an invalid target', + ); + } + return supported; +}; + +export const assertFirmwareUpdateV4Artifacts = ( + { preparedArtifacts }: IFirmwareExecutionArtifacts, + transportType?: EHardwareTransportType, +): void => { + const directDesktopExecution = + platformEnv.isDesktop && + transportType !== undefined && + isDirectFirmwareHostBindingTransport(transportType); + if ((platformEnv.isNative || directDesktopExecution) && !preparedArtifacts) { + throw new OneKeyLocalError( + 'Protocol V2 firmware artifacts are not prepared', + ); + } +}; + +const requireHostBindingGeneration = ( + preparedArtifacts: IFirmwareExecutionArtifacts['preparedArtifacts'], + hostBindingGeneration: number | undefined, +): number => { + if (!preparedArtifacts || !hostBindingGeneration) { + throw new OneKeyLocalError('Firmware host binding is unavailable'); + } + return hostBindingGeneration; +}; + +export const executePreparedFirmwareUpdateV2Bootloader = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; +} & IFirmwareExecutionArtifacts) => { + const artifact = preparedArtifacts?.selected.bootloader; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError('Prepared bootloader artifact is unavailable'); + } + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + updateType: 'firmware'; + platform: string; + isUpdateBootloader: true; + artifact: IFirmwareArtifactReference; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + updateType: 'firmware', + platform, + isUpdateBootloader: true, + artifact, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = bridgeBinaries?.targetBinaries.bootloader; + if (bridgeBinary) { + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + binary: ArrayBuffer; + updateType: 'firmware'; + platform: string; + isUpdateBootloader: true; + }, + ) => ReturnType + )(connectId, { + binary: bridgeBinary, + updateType: 'firmware', + platform, + isUpdateBootloader: true, + }); + } + return sdk.firmwareUpdateV2(connectId, { + updateType: 'firmware', + platform, + isUpdateBootloader: true, + }); +}; + +export const executePreparedDeviceUpdateBootloader = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, +}: { + sdk: CoreApi; + connectId: string; +} & IFirmwareExecutionArtifacts) => { + const artifact = preparedArtifacts?.selected.bootloader; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError('Prepared bootloader artifact is unavailable'); + } + return ( + sdk.deviceUpdateBootloader as unknown as ( + id: string, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + artifact: IFirmwareArtifactReference; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + artifact, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = bridgeBinaries?.targetBinaries.bootloader; + return sdk.deviceUpdateBootloader( + connectId, + bridgeBinary ? { binary: bridgeBinary } : {}, + ); +}; + +export const executePreparedFirmwareUpdateV2 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + updateType, + forcedUpdateRes, + platform, + firmwareType, + version, +}: { + sdk: CoreApi; + connectId: string | undefined; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + version: number[]; +} & IFirmwareExecutionArtifacts) => { + const artifact = + updateType === 'ble' + ? preparedArtifacts?.selected.ble + : preparedArtifacts?.selected.firmware; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError( + `Prepared ${updateType} artifact is unavailable`, + ); + } + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: string; + firmwareType: EFirmwareType | undefined; + artifact: IFirmwareArtifactReference; + resourceEntries?: readonly { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + updateType, + forcedUpdateRes: + preparedArtifacts.plan.targetsToUpdate.includes('resource'), + platform, + firmwareType, + artifact, + ...(updateType === 'firmware' && + preparedArtifacts.selected.resourceEntries + ? { resourceEntries: preparedArtifacts.selected.resourceEntries } + : {}), + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = + bridgeBinaries?.targetBinaries[updateType === 'ble' ? 'ble' : 'firmware']; + if (bridgeBinary) { + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + binary: ArrayBuffer; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: string; + firmwareType: EFirmwareType | undefined; + }, + ) => ReturnType + )(connectId, { + binary: bridgeBinary, + updateType, + forcedUpdateRes, + platform, + firmwareType, + }); + } + return sdk.firmwareUpdateV2(connectId, { + updateType, + forcedUpdateRes, + version, + platform, + firmwareType, + }); +}; + +export const executePreparedFirmwareUpdateV4 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, + firmwareType, + targetsToUpdate, + forcedUpdateRes, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + targetsToUpdate: FirmwareUpdateV4Target[]; + forcedUpdateRes: boolean; +} & IFirmwareExecutionArtifacts) => { + if (preparedArtifacts) { + return sdk.firmwareUpdateV4(connectId, { + platform, + preparedPlan: preparedArtifacts.preparedPlan, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + + return sdk.firmwareUpdateV4(connectId, { + platform, + firmwareType, + targetsToUpdate, + ...getBridgeFirmwareV4BinaryParams(bridgeBinaries), + forcedUpdateRes, + }); +}; + +export const executePreparedFirmwareUpdateV3 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, + firmwareType, + bleVersion, + firmwareVersion, + bootloaderVersion, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + bleVersion: number[] | undefined; + firmwareVersion: number[] | undefined; + bootloaderVersion: number[] | undefined; +} & IFirmwareExecutionArtifacts) => { + if (preparedArtifacts) { + return ( + sdk.firmwareUpdateV3 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + platform: string; + firmwareType: EFirmwareType | undefined; + hostBindingGeneration: number; + artifacts: { + ble?: IFirmwareArtifactReference; + firmware?: IFirmwareArtifactReference; + bootloader?: IFirmwareArtifactReference; + resourceEntries?: readonly { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + }; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + platform, + firmwareType, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + artifacts: { + ...(preparedArtifacts.selected.ble + ? { ble: preparedArtifacts.selected.ble } + : {}), + ...(preparedArtifacts.selected.firmware + ? { firmware: preparedArtifacts.selected.firmware } + : {}), + ...(preparedArtifacts.selected.bootloader + ? { bootloader: preparedArtifacts.selected.bootloader } + : {}), + ...(preparedArtifacts.selected.resourceEntries + ? { resourceEntries: preparedArtifacts.selected.resourceEntries } + : {}), + }, + }); + } + return sdk.firmwareUpdateV3(connectId, { + platform, + bleVersion, + firmwareVersion, + bootloaderVersion, + firmwareType, + ...getBridgeFirmwareV3BinaryParams(bridgeBinaries), + }); +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts new file mode 100644 index 000000000000..fd51cb4c12c5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts @@ -0,0 +1,1631 @@ +import { EFirmwareType } from '@onekeyfe/hd-shared'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import loggerUtils from '@onekeyhq/shared/src/logger/utils'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; + +import { getGatedFirmwareUpdateDevSetting } from '../../states/jotai/atoms/devSettings'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { + cancelFirmwareArtifactPreparations, + downloadFirmwareArtifact, + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReadyValue, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + withFirmwareArtifactStageTimeout, +} from './FirmwareArtifactPreflight'; +import { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; +import { + executePreparedFirmwareUpdateV2, + executePreparedFirmwareUpdateV3, + executePreparedFirmwareUpdateV4, +} from './FirmwarePreparedExecution'; + +import type { IPreparedFirmwareArtifacts } from './FirmwareArtifactPreflight'; +import type { IFirmwarePreparedArtifactReleaseResult } from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePreparedPlan, +} from '@onekeyfe/hd-core'; + +jest.mock('../../states/jotai/atoms/devSettings', () => ({ + getGatedFirmwareUpdateDevSetting: jest.fn(async () => undefined), +})); + +const ready = { + planSchemaVersion: 2, + preparedPlanSchemaVersion: 2, + hostBindingProtocolVersion: 2, + manifestModes: ['external-only', 'sdk-managed'], + supportsArtifactReader: true, +}; + +const testFirmwareArtifact = { + url: 'https://firmware.example/bootloader.bin', + role: 'bootloader' as const, + expectedSize: 1024, + expectedSha256: '1'.repeat(64), + container: 'raw' as const, +}; + +describe('isExternalFirmwareCapabilityReady', () => { + test('requires the exact cross-repo capability contract', () => { + expect(isExternalFirmwareCapabilityReady(ready)).toBe(true); + expect( + isExternalFirmwareCapabilityReady({ + ...ready, + planSchemaVersion: 1, + }), + ).toBe(false); + expect( + isExternalFirmwareCapabilityReady({ + ...ready, + unexpected: true, + }), + ).toBe(false); + }); + + test('requires the exact native artifact protocol and bounded reader contract', () => { + const nativeReady = { + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }; + expect(isFirmwareArtifactCapabilityReadyValue(nativeReady)).toBe(true); + expect( + isFirmwareArtifactCapabilityReadyValue({ + ...nativeReady, + maxReadBytes: 512 * 1024, + }), + ).toBe(false); + expect( + isFirmwareArtifactCapabilityReadyValue({ + ...nativeReady, + supportedRouteTypes: [], + }), + ).toBe(false); + }); +}); + +describe('firmware artifact stage watchdog', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('fails a hung synchronous native bridge stage without retrying it', async () => { + jest.useFakeTimers(); + const operation = jest.fn(() => new Promise(() => undefined)); + const pending = withFirmwareArtifactStageTimeout( + 'LEASE_CREATE', + operation(), + ); + + jest.advanceTimersByTime(15_000); + + await expect(pending).rejects.toThrow('ARTIFACT_LEASE_CREATE_TIMEOUT'); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); + +describe('prepared firmware execution', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createController = () => + new FirmwarePreparedArtifactController({ + getHardwareTransportType: async () => EHardwareTransportType.Bridge, + getSDKInstance: async () => ({}) as CoreApi, + }); + + const createPreparedArtifacts = ( + selected: Partial = {}, + ) => + ({ + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + preparedPlan: {}, + plan: { + deviceIdentity: 'device', + artifacts: [], + targetsToUpdate: [], + }, + selected: { + componentArtifacts: {}, + ...selected, + }, + }) as unknown as IPreparedFirmwareArtifacts; + + test('releases prepared artifacts with the exact workflow disposition', async () => { + const controller = createController(); + const prepared = createPreparedArtifacts({}); + jest + .spyOn(controller, 'prepareWorkflowArtifacts') + .mockResolvedValue(prepared); + const release = jest + .spyOn(controller, 'releasePreparedArtifacts') + .mockResolvedValue({ + hostBindingReleased: true, + leaseReleased: true, + }); + const releaseResult = {} as ICheckAllFirmwareReleaseResult; + + await controller.withWorkflowArtifacts(releaseResult, async () => 'done'); + await expect( + controller.withWorkflowArtifacts(releaseResult, async () => { + throw new OneKeyLocalError('install failed'); + }), + ).rejects.toThrow('install failed'); + + expect(release).toHaveBeenNthCalledWith(1, prepared, 'completed'); + expect(release).toHaveBeenNthCalledWith(2, prepared, 'safeCancelled'); + }); + + test('never falls back to SDK network for malformed prepared artifacts', () => { + const firmwareUpdateV2 = jest.fn(); + const sdk = { firmwareUpdateV2 } as unknown as CoreApi; + const baseParams = { + sdk, + connectId: 'device', + forcedUpdateRes: false, + platform: 'native' as const, + firmwareType: EFirmwareType.Universal, + version: [1, 0, 0], + }; + + expect(() => + executePreparedFirmwareUpdateV2({ + ...baseParams, + updateType: 'firmware', + preparedArtifacts: createPreparedArtifacts({}), + hostBindingGeneration: 1, + }), + ).toThrow('Prepared firmware artifact is unavailable'); + expect(() => + executePreparedFirmwareUpdateV2({ + ...baseParams, + updateType: 'firmware', + preparedArtifacts: createPreparedArtifacts({ + firmware: {} as NonNullable< + IPreparedFirmwareArtifacts['selected']['firmware'] + >, + }), + }), + ).toThrow('Firmware host binding is unavailable'); + expect(firmwareUpdateV2).not.toHaveBeenCalled(); + }); + + test('derives prepared V2 resource mode from the immutable Plan', async () => { + const firmwareUpdateV2 = jest.fn(); + const sdk = { firmwareUpdateV2 } as unknown as CoreApi; + const firmware = { + artifactRef: 'fw:firmware', + size: 4, + sha256: 'a'.repeat(64), + }; + const resource = { + artifactRef: 'fw:resource', + size: 6, + sha256: 'b'.repeat(64), + }; + const baseParams = { + sdk, + connectId: 'device', + updateType: 'firmware' as const, + platform: 'native' as const, + firmwareType: EFirmwareType.Universal, + version: [1, 0, 0], + hostBindingGeneration: 1, + }; + const preparedWithResource = createPreparedArtifacts({ + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }); + preparedWithResource.plan.targetsToUpdate = ['firmware', 'resource']; + + await executePreparedFirmwareUpdateV2({ + ...baseParams, + preparedArtifacts: preparedWithResource, + forcedUpdateRes: false, + }); + + const preparedWithoutResource = createPreparedArtifacts({ firmware }); + preparedWithoutResource.plan.targetsToUpdate = ['firmware']; + await executePreparedFirmwareUpdateV2({ + ...baseParams, + preparedArtifacts: preparedWithoutResource, + forcedUpdateRes: true, + }); + await executePreparedFirmwareUpdateV2({ + ...baseParams, + forcedUpdateRes: true, + }); + + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 1, + 'device', + expect.objectContaining({ forcedUpdateRes: true }), + ); + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 2, + 'device', + expect.objectContaining({ forcedUpdateRes: false }), + ); + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 3, + 'device', + expect.objectContaining({ forcedUpdateRes: true }), + ); + }); + + test('passes prepared V3 inputs without legacy version fields', async () => { + const firmwareUpdateV3 = jest.fn(); + const sdk = { firmwareUpdateV3 } as unknown as CoreApi; + const firmware = { + artifactRef: 'fw:firmware', + size: 4, + sha256: 'a'.repeat(64), + }; + const resource = { + artifactRef: 'fw:resource', + size: 6, + sha256: 'b'.repeat(64), + }; + const prepared = createPreparedArtifacts({ + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }); + prepared.artifactsById = { firmware, resource }; + prepared.plan.executor = 'v3'; + const controller = createController(); + jest + .spyOn(controller, 'getExecutionBindingParams') + .mockReturnValue({ hostBindingGeneration: 105 }); + const localLog = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const executionArtifacts = controller.getExecutionArtifacts( + prepared, + 'firmwareUpdateV3', + ); + + await executePreparedFirmwareUpdateV3({ + sdk, + connectId: 'device', + ...executionArtifacts, + platform: 'native', + firmwareType: EFirmwareType.Universal, + bleVersion: [2, 3, 7], + firmwareVersion: [4, 21, 0], + bootloaderVersion: [2, 8, 4], + }); + + expect(firmwareUpdateV3).toHaveBeenCalledWith('device', { + preparedPlan: prepared.preparedPlan, + platform: 'native', + firmwareType: EFirmwareType.Universal, + hostBindingGeneration: 105, + artifacts: { + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }, + }); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"stage":"sdk-handoff","executor":"v3","sdkMethod":"firmwareUpdateV3"', + ), + ); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"artifacts":{"count":2,"bytes":10,"firmwareBytes":4,"resourceCount":1,"resourceBytes":6,"integrityVerified":true}', + ), + ); + }); + + test('uses the prepared plan as the sole V4 execution source', async () => { + const firmwareUpdateV4 = jest.fn(); + const sdk = { firmwareUpdateV4 } as unknown as CoreApi; + const prepared = createPreparedArtifacts({}); + prepared.plan.executor = 'v4'; + prepared.plan.targetsToUpdate = ['resource']; + + await executePreparedFirmwareUpdateV4({ + sdk, + connectId: 'device', + preparedArtifacts: prepared, + hostBindingGeneration: 7, + platform: 'native', + firmwareType: EFirmwareType.Universal, + targetsToUpdate: ['resource'], + forcedUpdateRes: true, + }); + + expect(firmwareUpdateV4).toHaveBeenCalledWith('device', { + platform: 'native', + preparedPlan: prepared.preparedPlan, + hostBindingGeneration: 7, + }); + }); + + test('keeps Extension V2, V3 and V4 firmware handoffs JSON-safe', async () => { + const firmwareUpdateV2 = jest.fn(); + const firmwareUpdateV3 = jest.fn(); + const firmwareUpdateV4 = jest.fn(); + const sdk = { + firmwareUpdateV2, + firmwareUpdateV3, + firmwareUpdateV4, + } as unknown as CoreApi; + + await executePreparedFirmwareUpdateV2({ + sdk, + connectId: 'device', + updateType: 'firmware', + forcedUpdateRes: false, + platform: 'ext', + firmwareType: EFirmwareType.Universal, + version: [1, 2, 3], + }); + await executePreparedFirmwareUpdateV3({ + sdk, + connectId: 'device', + platform: 'ext', + firmwareType: EFirmwareType.Universal, + bleVersion: [1, 0, 0], + firmwareVersion: [2, 0, 0], + bootloaderVersion: [3, 0, 0], + }); + await executePreparedFirmwareUpdateV4({ + sdk, + connectId: 'device', + platform: 'ext', + firmwareType: EFirmwareType.Universal, + targetsToUpdate: ['resource'], + forcedUpdateRes: false, + }); + + const handoffs = [ + firmwareUpdateV2.mock.calls[0][1], + firmwareUpdateV3.mock.calls[0][1], + firmwareUpdateV4.mock.calls[0][1], + ]; + for (const handoff of handoffs) { + expect(JSON.parse(JSON.stringify(handoff))).toEqual(handoff); + expect(Object.keys(handoff).some((key) => /binary/i.test(key))).toBe( + false, + ); + } + }); + + test('passes Desktop Bridge binaries with legacy version fields', async () => { + const firmwareUpdateV3 = jest.fn(); + const sdk = { firmwareUpdateV3 } as unknown as CoreApi; + const firmware = new ArrayBuffer(4); + const controller = createController(); + const localLog = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const executionArtifacts = controller.getExecutionArtifacts( + { + transactionId: 'bridge:test', + executor: 'v3', + planDigest: 'a'.repeat(64), + targetBinaries: { firmware }, + }, + 'firmwareUpdateV3', + ); + + await executePreparedFirmwareUpdateV3({ + sdk, + connectId: 'device', + ...executionArtifacts, + platform: 'desktop', + firmwareType: EFirmwareType.Universal, + bleVersion: undefined, + firmwareVersion: [4, 21, 0], + bootloaderVersion: undefined, + }); + + expect(firmwareUpdateV3).toHaveBeenCalledWith('device', { + platform: 'desktop', + bleVersion: undefined, + firmwareVersion: [4, 21, 0], + bootloaderVersion: undefined, + firmwareType: EFirmwareType.Universal, + firmwareBinary: firmware, + }); + expect(firmwareUpdateV3.mock.calls[0][1]).not.toHaveProperty( + 'resourceBinary', + ); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"artifacts":{"count":1,"bytes":4,"firmwareBytes":4,"resourceCount":0,"resourceBytes":0,"integrityVerified":true}', + ), + ); + }); + + test('uses artifact capability to enable non-empty Desktop Bridge plan caching', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const controller = createController(); + const plan = { + planDigest: 'a'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: testFirmwareArtifact.url, + container: 'raw', + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).resolves.toBe(true); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('keeps a config Plan App-managed when integrity fields are absent', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const controller = createController(); + const plan = { + planDigest: 'c'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: 'https://firmware.example/pro.bin', + container: 'raw', + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).resolves.toBe(plan.planDigest); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('fails a required prepared flow when the fresh manifest has no Plan', async () => { + const controller = createController(); + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan: undefined, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + requirePreparedPlan: true, + }), + ).rejects.toThrow( + 'Firmware update plan is unavailable from the fresh firmware manifest', + ); + }); + + test('fails a required prepared flow when the host capability is unavailable', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest + .spyOn(firmwareArtifactAdapter, 'getCapabilities') + .mockRejectedValue(new Error('artifact module unavailable')); + const controller = createController(); + const plan = { + planDigest: 'd'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro2', + platform: 'desktop', + artifacts: [ + { + artifactId: 'component:app_v1', + role: 'component', + target: 'app_v1', + url: 'https://firmware.example/application-p1.okpkg', + container: 'raw', + expectedSize: 1024, + expectedSha256: '1'.repeat(64), + }, + ], + targetsToUpdate: ['app_v1'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['app_v1'], + requirePreparedPlan: true, + }), + ).rejects.toThrow( + 'Firmware prepared artifact capability is unavailable on this runtime', + ); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('keeps Protocol V2 plans SDK-managed on Extension', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + isNative: platformEnv.isNative, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: false, + isNative: false, + appPlatform: 'ext', + symbol: 'ext', + }); + try { + const getCapabilities = jest + .spyOn(firmwareArtifactAdapter, 'getCapabilities') + .mockRejectedValue(new Error('artifact module unavailable')); + const controller = createController(); + const plan = { + executor: 'v4', + planDigest: 'e'.repeat(64), + deviceIdentity: 'unavailable', + deviceModel: 'pro2', + platform: 'ext', + artifacts: [ + { + artifactId: 'component:app_v1', + role: 'component', + target: 'app_v1', + url: 'https://firmware.example/application-p1.okpkg', + container: 'raw', + expectedSize: 1024, + expectedSha256: '1'.repeat(64), + }, + ], + targetsToUpdate: ['app_v1'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan, + connectId: 'device', + transportType: EHardwareTransportType.WEBUSB, + expectedTargets: ['app_v1'], + requirePreparedPlan: true, + }), + ).resolves.toBe(plan.planDigest); + await expect( + controller.prepareWorkflowArtifacts({ + deviceType: 'pro2', + firmwareUpdatePlanDigest: plan.planDigest, + } as ICheckAllFirmwareReleaseResult), + ).resolves.toBeUndefined(); + expect(getCapabilities).not.toHaveBeenCalled(); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('rejects empty and partial Desktop Bridge plans before preparation', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const download = jest.spyOn(firmwareArtifactAdapter, 'download'); + const controller = createController(); + const emptyPlan = { + planDigest: 'a'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [], + targetsToUpdate: [], + } as unknown as FirmwareUpdatePlan; + const firmwareOnlyPlan = { + ...emptyPlan, + planDigest: 'b'.repeat(64), + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: 'https://common.onekey-asset.com/firmware.bin', + container: 'raw', + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan: emptyPlan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).rejects.toThrow('Firmware update plan has no executable artifacts'); + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan: firmwareOnlyPlan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware', 'ble'], + }), + ).rejects.toThrow( + 'Firmware update plan does not cover every selected target', + ); + expect(download).not.toHaveBeenCalled(); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('accepts exact Protocol V2 component targets', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const controller = createController(); + const plan = { + schemaVersion: 2, + planDigest: 'c'.repeat(64), + executor: 'v4', + deviceIdentity: 'device', + deviceModel: 'pro2', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'component:coprocessor', + role: 'component', + target: 'coprocessor', + url: 'https://firmware.example/coprocessor.okpkg', + container: 'raw', + }, + ], + targetsToUpdate: ['coprocessor'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['coprocessor'], + }), + ).resolves.toBe(true); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('rejects Protocol V2 target mismatches before artifact preparation', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + const controller = createController(); + const plan = { + schemaVersion: 2, + planDigest: 'd'.repeat(64), + executor: 'v4', + deviceIdentity: 'device', + deviceModel: 'pro2', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'component:coprocessor', + role: 'component', + target: 'coprocessor', + url: 'https://firmware.example/coprocessor.okpkg', + container: 'raw', + }, + ], + targetsToUpdate: ['coprocessor'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['ble'], + }), + ).rejects.toThrow( + 'Firmware update plan targets do not match the selected Protocol V2 targets', + ); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('requires the Protocol V2 resource target to use one ZIP bundle', async () => { + const controller = createController(); + const plan = { + schemaVersion: 2, + planDigest: 'f'.repeat(64), + executor: 'v4', + deviceIdentity: 'device', + deviceModel: 'pro2', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'resource:archive', + role: 'resource', + target: 'resource', + url: 'https://firmware.example/resource.zip', + container: 'zip', + }, + ], + targetsToUpdate: ['resource'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['resource'], + }), + ).rejects.toThrow( + 'Protocol V2 resource target requires exactly one ZIP archive artifact', + ); + }); + + test('keeps legacy target aliases bound to matching artifact roles', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + const controller = createController(); + const plan = { + schemaVersion: 2, + planDigest: 'e'.repeat(64), + executor: 'v3', + deviceIdentity: 'device', + deviceModel: 'pro', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'malformed-firmware', + role: 'resource', + target: 'firmware', + url: 'https://firmware.example/resource.bin', + container: 'raw', + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).rejects.toThrow( + 'Firmware update plan does not cover every selected target', + ); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); +}); + +describe('downloadFirmwareArtifact', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('uses the canonical domain route with the remaining deadline', async () => { + const trace = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: true, + }); + const deadlineAt = Date.now() + 20 * 60 * 1000; + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:single-route-deadline', + leaseRef: 'fwlease:single-route-deadline', + deadlineAt, + }), + ).resolves.toMatchObject({ + artifactRef: `fw:${artifact.expectedSha256}`, + }); + + expect(download).toHaveBeenCalledTimes(1); + expect(download.mock.calls[0][0].route).toEqual({ + routeType: 'domain', + }); + expect(download.mock.calls[0][0].overallDeadlineSeconds).toBeGreaterThan( + 15 * 60, + ); + expect( + download.mock.calls[0][0].overallDeadlineSeconds, + ).toBeLessThanOrEqual(20 * 60); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"stage":"artifact-download-start"'), + ); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"stage":"artifact-download-complete"'), + ); + expect(download.mock.calls[0][0].allowPreReleaseHosts).toBeUndefined(); + }); + + test('forwards the pre-release host admission only while the dev setting is on', async () => { + jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); + jest + .mocked(getGatedFirmwareUpdateDevSetting) + .mockResolvedValueOnce(true as never); + const artifact = testFirmwareArtifact; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: true, + }); + + await downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:pre-release-hosts', + leaseRef: 'fwlease:pre-release-hosts', + deadlineAt: Date.now() + 20 * 60 * 1000, + }); + + expect(getGatedFirmwareUpdateDevSetting).toHaveBeenCalledWith( + 'usePreReleaseConfig', + ); + expect(download.mock.calls[0][0].allowPreReleaseHosts).toBe(true); + }); + + test('records a bounded error code when the native download fails', async () => { + const trace = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockRejectedValue(new Error('ARTIFACT_NETWORK_FAILED: request failed')); + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:trace-failure', + leaseRef: 'fwlease:trace-failure', + deadlineAt: Date.now() + 20 * 60 * 1000, + }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + + expect(trace).toHaveBeenCalledWith( + expect.stringContaining( + '"stage":"artifact-download-failed","artifactId":"bootloader"', + ), + ); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"errorCode":"ARTIFACT_NETWORK_FAILED"'), + ); + }); + + test('rejects a receipt that did not verify the expected SHA-256', async () => { + jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: false, + }); + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:unverified-receipt', + leaseRef: 'fwlease:unverified-receipt', + deadlineAt: Date.now() + 20 * 60 * 1000, + }), + ).rejects.toThrow( + 'Firmware artifact receipt does not match the update plan', + ); + }); + + test('does not start a download after the preparation deadline', async () => { + const artifact = testFirmwareArtifact; + const download = jest.spyOn(firmwareArtifactAdapter, 'download'); + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + deadlineAt: Date.now() - 1, + }), + ).rejects.toThrow('Firmware artifact preparation exceeded its deadline'); + expect(download).not.toHaveBeenCalled(); + }); +}); + +describe('Desktop Bridge firmware binaries', () => { + const createBridgePlan = () => { + const artifact = testFirmwareArtifact; + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'bootloader', + role: 'bootloader', + target: 'bootloader', + url: artifact.url, + container: 'raw', + expectedSize: artifact.expectedSize, + expectedSha256: artifact.expectedSha256, + }, + ], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + return { artifact, plan }; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('rejects Protocol V2 resource ZIP plans before downloading', async () => { + const download = jest.spyOn(firmwareArtifactAdapter, 'download'); + const plan = { + schemaVersion: 2, + planDigest: 'f'.repeat(64), + executor: 'v4', + deviceIdentity: 'device', + deviceModel: 'pro2', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'resource:archive', + role: 'resourceBundle', + target: 'resource', + url: 'https://firmware.example/resource.zip', + container: 'zip', + }, + ], + targetsToUpdate: ['resource'], + } as unknown as FirmwareUpdatePlan; + + await expect(prepareBridgeFirmwareBinaries(plan)).rejects.toThrow( + 'Desktop Bridge does not support Protocol V2 resource ZIP updates', + ); + expect(download).not.toHaveBeenCalled(); + }); + + test('downloads and reads a small admitted artifact before releasing its lease', async () => { + const { artifact, plan } = createBridgePlan(); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:test' }); + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: true, + }); + jest.spyOn(firmwareArtifactAdapter, 'open').mockResolvedValue({ + readerId: 'reader', + size: artifact.expectedSize, + }); + const read = jest + .spyOn(firmwareArtifactAdapter, 'read') + .mockImplementation(async ({ length }) => new ArrayBuffer(length)); + jest.spyOn(firmwareArtifactAdapter, 'close').mockResolvedValue(); + const release = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + + const result = await prepareBridgeFirmwareBinaries(plan); + + expect(result?.targetBinaries.bootloader?.byteLength).toBe( + artifact.expectedSize, + ); + expect(read.mock.calls.every(([input]) => input.length <= 256 * 1024)).toBe( + true, + ); + expect(release).toHaveBeenCalledWith({ + leaseRef: 'fwlease:test', + disposition: 'completed', + }); + }); + + test('downloads a verified remote plan URL without requiring an App-bundled catalog', async () => { + const { plan } = createBridgePlan(); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:catalog' }); + const actualSha256 = plan.artifacts[0].expectedSha256; + if (!actualSha256) { + throw new OneKeyLocalError( + 'Expected the bridge plan to include an artifact digest', + ); + } + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${actualSha256}`, + size: testFirmwareArtifact.expectedSize, + sha256: actualSha256, + expectedSha256Verified: true, + }); + jest.spyOn(firmwareArtifactAdapter, 'open').mockResolvedValue({ + readerId: 'reader', + size: testFirmwareArtifact.expectedSize, + }); + jest + .spyOn(firmwareArtifactAdapter, 'read') + .mockImplementation(async ({ length }) => new ArrayBuffer(length)); + jest.spyOn(firmwareArtifactAdapter, 'close').mockResolvedValue(); + jest.spyOn(firmwareArtifactAdapter, 'releaseLease').mockResolvedValue(); + + await expect(prepareBridgeFirmwareBinaries(plan)).resolves.toMatchObject({ + targetBinaries: { + bootloader: expect.any(ArrayBuffer), + }, + }); + expect(download).toHaveBeenCalledWith( + expect.objectContaining({ + artifactId: 'bootloader', + url: plan.artifacts[0].url, + expectedSize: plan.artifacts[0].expectedSize, + expectedSha256: plan.artifacts[0].expectedSha256, + maxBytes: plan.artifacts[0].expectedSize, + }), + ); + }); + + test('cancels an active preparation after a download failure', async () => { + const { plan } = createBridgePlan(); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:test' }); + let rejectDownload: (reason?: unknown) => void = () => undefined; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectDownload = reject; + }), + ); + const cancel = jest + .spyOn(firmwareArtifactAdapter, 'cancelDownloads') + .mockImplementation(async () => { + rejectDownload(new Error('ARTIFACT_CANCELLED')); + }); + const release = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + + const preparing = prepareBridgeFirmwareBinaries(plan); + for ( + let attempt = 0; + attempt < 10 && !download.mock.calls.length; + attempt += 1 + ) { + await Promise.resolve(); + } + await cancelFirmwareArtifactPreparations(); + + await expect(preparing).rejects.toThrow('ARTIFACT_CANCELLED'); + expect(download).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith(expect.stringMatching(/^bridge:/u)); + expect(release).toHaveBeenCalledWith({ + leaseRef: 'fwlease:test', + disposition: 'safeCancelled', + }); + }); + + test('maps prefetched binaries onto existing V3 and V4 SDK fields', () => { + const firmware = new ArrayBuffer(1); + const ble = new ArrayBuffer(2); + const boot = new ArrayBuffer(3); + expect( + getBridgeFirmwareV3BinaryParams({ + transactionId: 'bridge:v3', + executor: 'v3', + planDigest: 'a', + targetBinaries: { firmware, ble, bootloader: boot }, + }), + ).toEqual({ + firmwareBinary: firmware, + bleBinary: ble, + bootloaderBinary: boot, + }); + expect( + getBridgeFirmwareV4BinaryParams({ + transactionId: 'bridge:v4', + executor: 'v4', + planDigest: 'b', + targetBinaries: { + boot, + app_v1: firmware, + coprocessor: ble, + }, + }), + ).toEqual({ + bootloaderBinary: boot, + applicationP1Binary: firmware, + coprocessorBinary: ble, + }); + }); +}); + +describe('external firmware artifact preparation', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('cancels sibling downloads before surfacing a preparation failure', async () => { + const artifact = { + artifactId: 'bootloader', + role: 'bootloader' as const, + target: 'bootloader' as const, + url: testFirmwareArtifact.url, + container: 'raw' as const, + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }; + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [artifact, { ...artifact, artifactId: 'bootloader-copy' }], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + let rejectSibling: (reason?: unknown) => void = () => undefined; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSibling = reject; + }), + ) + .mockRejectedValueOnce( + new Error('ARTIFACT_TLS_FAILED: firmware TLS validation failed'), + ); + const cancel = jest + .spyOn(firmwareArtifactAdapter, 'cancelDownloads') + .mockImplementation(async () => { + rejectSibling(new Error('ARTIFACT_CANCELLED')); + }); + + await expect( + prepareFirmwareArtifacts(plan, { + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + preparePlan: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_TLS_FAILED'); + expect(download).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('fwtx:test'); + }); + + test('materializes a manifest-free Pro2 RESC ZIP and passes actual receipts to the SDK', async () => { + const plan = { + schemaVersion: 2, + planDigest: 'd'.repeat(64), + executor: 'v4', + deviceIdentity: 'device', + deviceModel: 'pro2', + firmwareType: EFirmwareType.Universal, + platform: 'native', + artifacts: [ + { + artifactId: 'resource:archive', + role: 'resourceBundle', + target: 'resource', + url: 'https://firmware.example/resources.zip', + container: 'zip', + }, + ], + targetsToUpdate: ['resource'], + } as unknown as FirmwareUpdatePlan; + const archiveSha256 = '3'.repeat(64); + const imagesSha256 = '4'.repeat(64); + const bootResourceSha256 = '5'.repeat(64); + const hashReportSha256 = '6'.repeat(64); + const preparePlan = jest.fn(() => ({ + preparedPlanDigest: 'e'.repeat(64), + planDigest: plan.planDigest, + })) as unknown as CoreApi['prepareFirmwareUpdatePlan']; + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${archiveSha256}`, + size: 2048, + sha256: archiveSha256, + expectedSha256Verified: false, + }); + const materialize = jest + .spyOn(firmwareArtifactAdapter, 'materialize') + .mockResolvedValue([ + { + entryName: 'bundles/images/images-release.okpkg', + receipt: { + artifactRef: `fw:${imagesSha256}`, + size: 1024, + sha256: imagesSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'loaders/bootloader/boot_resource-release.okpkg', + receipt: { + artifactRef: `fw:${bootResourceSha256}`, + size: 512, + sha256: bootResourceSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'resource_hash.txt', + receipt: { + artifactRef: `fw:${hashReportSha256}`, + size: 64, + sha256: hashReportSha256, + expectedSha256Verified: false, + }, + }, + ]); + + await expect( + prepareFirmwareArtifacts(plan, { + transactionId: 'fwtx:zip-without-catalog', + leaseRef: 'fwlease:zip-without-catalog', + preparePlan, + }), + ).resolves.toMatchObject({ + selected: { + resourceEntries: [ + { + entryName: 'bundles/images/images-release.okpkg', + artifact: { sha256: imagesSha256 }, + }, + { + entryName: 'loaders/bootloader/boot_resource-release.okpkg', + artifact: { sha256: bootResourceSha256 }, + }, + { + entryName: 'resource_hash.txt', + artifact: { sha256: hashReportSha256 }, + }, + ], + }, + }); + expect(download.mock.calls[0][0]).toMatchObject({ + maxBytes: 512 * 1024 * 1024, + }); + expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSize'); + expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSha256'); + expect(materialize).toHaveBeenCalledWith({ + leaseRef: 'fwlease:zip-without-catalog', + archiveArtifactRef: `fw:${archiveSha256}`, + }); + expect(preparePlan).toHaveBeenCalledWith( + expect.objectContaining({ + artifacts: [ + expect.objectContaining({ + artifactId: 'resource:archive', + materializedEntries: [ + { + entryName: 'bundles/images/images-release.okpkg', + artifact: { + artifactRef: `fw:${imagesSha256}`, + size: 1024, + sha256: imagesSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'loaders/bootloader/boot_resource-release.okpkg', + artifact: { + artifactRef: `fw:${bootResourceSha256}`, + size: 512, + sha256: bootResourceSha256, + expectedSha256Verified: false, + }, + }, + { + entryName: 'resource_hash.txt', + artifact: { + artifactRef: `fw:${hashReportSha256}`, + size: 64, + sha256: hashReportSha256, + expectedSha256Verified: false, + }, + }, + ], + }), + ], + }), + ); + }); + + test('uses one production session for plan validation, host binding, and release', async () => { + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'native', + artifacts: [ + { + artifactId: 'bootloader', + role: 'bootloader', + target: 'bootloader', + url: testFirmwareArtifact.url, + container: 'raw', + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }, + ], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + const preparedPlan = { + preparedPlanDigest: 'b'.repeat(64), + planDigest: plan.planDigest, + } as FirmwareUpdatePreparedPlan; + const validateFirmwareUpdatePreparedPlan = jest.fn(() => preparedPlan); + const registerFirmwareUpdateHostBinding = jest.fn(() => 9); + const unregisterFirmwareUpdateHostBinding = jest.fn(() => true); + const sdk = { + prepareFirmwareUpdatePlan: jest.fn(() => preparedPlan), + validateFirmwareUpdatePreparedPlan, + registerFirmwareUpdateHostBinding, + unregisterFirmwareUpdateHostBinding, + } as unknown as CoreApi; + jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:session' }); + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${testFirmwareArtifact.expectedSha256}`, + size: testFirmwareArtifact.expectedSize, + sha256: testFirmwareArtifact.expectedSha256, + expectedSha256Verified: true, + }); + const releaseLease = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + const controller = new FirmwarePreparedArtifactController({ + getHardwareTransportType: async () => EHardwareTransportType.Bridge, + getSDKInstance: async () => sdk, + }); + let cleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + + await controller.withPreparedPlanArtifacts( + { plan, sdk, transactionId: 'fwtx:session' }, + async (prepared) => { + expect(prepared.selected.bootloader).toMatchObject({ + size: testFirmwareArtifact.expectedSize, + }); + }, + (result) => { + cleanup = result; + }, + ); + + expect(validateFirmwareUpdatePreparedPlan).toHaveBeenCalledWith( + preparedPlan, + ); + expect(registerFirmwareUpdateHostBinding).toHaveBeenCalledWith( + expect.objectContaining({ + preparedPlanDigest: preparedPlan.preparedPlanDigest, + }), + ); + expect(unregisterFirmwareUpdateHostBinding).toHaveBeenCalledWith(9); + expect(releaseLease).toHaveBeenCalledWith({ + leaseRef: 'fwlease:session', + disposition: 'completed', + }); + expect(cleanup).toEqual({ + hostBindingReleased: true, + leaseReleased: true, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateDetectMap.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateDetectMap.ts index 6014c72d51e7..dac348106f6c 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateDetectMap.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateDetectMap.ts @@ -26,6 +26,18 @@ export class FirmwareUpdateDetectMap { firstDetectTimeSpan = timerUtils.getTimeDurationMs({ minute: 1 }); + getNextDetectDelay({ connectId }: { connectId: string }) { + const now = Date.now(); + const firstDetectDelay = + this.firstDetectTimeSpan - (now - this.firstDetectAt); + const lastDetectAt = this.detectMapCache[connectId]?.lastDetectAt; + const repeatedDetectDelay = lastDetectAt + ? this.detectTimeSpan - (now - lastDetectAt) + : 0; + + return Math.max(0, firstDetectDelay, repeatedDetectDelay); + } + shouldDetect({ connectId }: { connectId: string }) { const now = Date.now(); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts new file mode 100644 index 000000000000..8eda83d9e667 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts @@ -0,0 +1,49 @@ +import { FirmwareArtifactSelfTestController } from './FirmwareArtifactSelfTestController'; +import { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; + +export { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +export { FirmwareArtifactSelfTestController }; +export { FirmwarePreparedArtifactController }; +export { + assertFirmwareUpdateV4Artifacts, + executePreparedDeviceUpdateBootloader, + executePreparedFirmwareUpdateV2, + executePreparedFirmwareUpdateV2Bootloader, + executePreparedFirmwareUpdateV3, + executePreparedFirmwareUpdateV4, + getFirmwareUpdateV4Targets, +} from './FirmwarePreparedExecution'; +export { + cancelFirmwareArtifactPreparations, + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReady, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + resolveFirmwarePlanArtifact, +} from './FirmwareArtifactPreflight'; +export { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, + getFirmwareArtifactSelfTestPlatform, +} from './FirmwareArtifactSelfTest'; +export { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +export const createFirmwareUpdateRuntimeHost = ( + dependencies: ConstructorParameters< + typeof FirmwareArtifactSelfTestController + >[0] & + ConstructorParameters[0], +) => { + const artifacts = new FirmwarePreparedArtifactController(dependencies); + return { + selfTest: new FirmwareArtifactSelfTestController(dependencies, artifacts), + artifacts, + }; +}; + +export type IFirmwareUpdateRuntimeHost = ReturnType< + typeof createFirmwareUpdateRuntimeHost +>; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts new file mode 100644 index 000000000000..df9e50c905bd --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts @@ -0,0 +1,84 @@ +import loggerUtils from '@onekeyhq/shared/src/logger/utils'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +export type IFirmwareUpdateTraceInputMode = + | 'artifact-reader' + | 'bridge-binary' + | 'sdk-managed'; + +export type IFirmwareUpdateTraceStage = + | 'preflight-start' + | 'lease-created' + | 'artifact-download-start' + | 'artifact-download-complete' + | 'artifact-download-failed' + | 'artifact-ready' + | 'preflight-complete' + | 'reader-complete' + | 'sdk-handoff' + | 'device-boundary' + | 'release-complete' + | 'sdk-tip'; + +export type IFirmwareUpdateArtifactTraceSummary = { + count: number; + bytes: number; + firmwareBytes?: number; + bleBytes?: number; + bootloaderBytes?: number; + resourceCount: number; + resourceBytes: number; + integrityVerified: boolean; +}; + +export type IFirmwareUpdateTraceParams = { + transactionId: string; + stage: IFirmwareUpdateTraceStage; + executor?: 'v2' | 'v3' | 'v4'; + sdkMethod?: string; + inputMode?: IFirmwareUpdateTraceInputMode; + expectedArtifactCount?: number; + artifactId?: string; + artifactRole?: string; + expectedBytes?: number; + durationMs?: number; + errorCode?: string; + artifacts?: IFirmwareUpdateArtifactTraceSummary; + preparedPlanProvided?: boolean; + hostBindingProvided?: boolean; + readerBytes?: number; + readerChunks?: number; + boundaryCode?: string; + disposition?: 'completed' | 'safeCancelled'; + hostBindingReleased?: boolean; + leaseReleased?: boolean; + tipMessage?: string; +}; + +const getFirmwareUpdateTracePlatform = () => { + if (platformEnv.isNativeIOS) return 'ios'; + if (platformEnv.isNativeAndroid) return 'android'; + return platformEnv.symbol ?? platformEnv.appPlatform ?? 'web'; +}; + +const stringifyTrace = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ + stringifyError: error instanceof Error ? error.message : String(error), + }); + } +}; + +export const firmwareUpdateTrace = ( + params: IFirmwareUpdateTraceParams, +): void => { + loggerUtils.consoleFunc( + `[FirmwareUpdateTrace] ${stringifyTrace({ + runtime: 'bg', + platform: getFirmwareUpdateTracePlatform(), + ...params, + })}`, + ); +}; 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..23b2533c0814 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts @@ -1,18 +1,36 @@ +import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; + import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { + EHardwareCallContext, EHardwareVendor, + type IBleFirmwareReleasePayload, + type IBootloaderReleasePayload, type ICheckAllFirmwareReleaseResult, + type IFirmwareUpdateInfo, + type IOneKeyDeviceFeatures, } from '@onekeyhq/shared/types/device'; import localDb from '../../dbs/local/localDb'; import { firmwareUpdateRetryAtom, + firmwareUpdateStepInfoAtom, firmwareUpdateWorkflowRunningAtom, + hardwareUiStateCompletedAtom, } from '../../states/jotai/atoms'; -import ServiceFirmwareUpdate from './ServiceFirmwareUpdate'; +import ServiceFirmwareUpdate, { + buildPro2TargetsToUpdate, + buildProtocolV2FirmwareVersionInfo, + buildProtocolV2PlanForceTargets, + shouldForceProtocolV2ResourceUpdate, + supportsFirmwareUpdateWorkflowV2, +} from './ServiceFirmwareUpdate'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IDBDevice } from '../../dbs/local/types'; @@ -40,6 +58,21 @@ jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ }, })); +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { + isDesktop: true, + isJest: true, + isNative: false, + isSupportDesktopBle: true, + symbol: 'web', + }, +})); + +jest.mock('@onekeyhq/shared/src/hardware/instance', () => ({ + CoreSDKLoader: jest.fn(), +})); + jest.mock('../../dbs/local/localDb', () => ({ __esModule: true, default: { @@ -50,6 +83,8 @@ jest.mock('../../dbs/local/localDb', () => ({ jest.mock('../../states/jotai/atoms', () => ({ EFirmwareUpdateSteps: { init: 'init', + installing: 'installing', + updateStart: 'updateStart', }, EHardwareUiStateAction: {}, firmwareUpdateResultVerifyAtom: { @@ -60,6 +95,10 @@ jest.mock('../../states/jotai/atoms', () => ({ set: jest.fn(), }, firmwareUpdateStepInfoAtom: { + get: jest.fn().mockResolvedValue({ + step: 'updateStart', + payload: { startAtTime: 1 }, + }), set: jest.fn(), }, firmwareUpdateWorkflowRunningAtom: { @@ -72,6 +111,9 @@ jest.mock('../../states/jotai/atoms', () => ({ hardwareUiStateAtom: { set: jest.fn(), }, + hardwareUiStateCompletedAtom: { + set: jest.fn(), + }, })); jest.mock('../ServiceHardware/serviceHardwareUtils', () => ({ @@ -83,6 +125,49 @@ jest.mock('../ServiceHardware/serviceHardwareUtils', () => ({ const mockedLocalDb = jest.mocked(localDb); +describe('ServiceFirmwareUpdate firmware manifest refresh', () => { + it('forces an App-managed manifest refresh before a release check', async () => { + const checkAllFirmwareRelease = jest.fn().mockResolvedValue({ + success: true, + payload: { features: {} }, + }); + const getSDKInstance = jest.fn().mockResolvedValue({ + checkAllFirmwareRelease, + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance, + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + } as unknown as IBackgroundApi, + }); + + await service.baseCheckAllFirmwareRelease({ + connectId: 'device-1', + firmwareType: undefined, + skipChangeTransportType: true, + protocolV2ForceUpdateTargets: ['app_v1', 'coprocessor'], + }); + + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: 'device-1', + forceFirmwareManifestRefresh: true, + }); + expect(checkAllFirmwareRelease).toHaveBeenCalledTimes(1); + expect(checkAllFirmwareRelease).toHaveBeenCalledWith( + 'device-1', + expect.objectContaining({ + protocolV2ForceUpdateTargets: ['app_v1', 'coprocessor'], + }), + ); + }); +}); + describe('ServiceFirmwareUpdate.detectActiveAccountFirmwareUpdates', () => { beforeEach(() => { jest.clearAllMocks(); @@ -120,6 +205,835 @@ describe('ServiceFirmwareUpdate.detectActiveAccountFirmwareUpdates', () => { expect(getCompatibleConnectId).not.toHaveBeenCalled(); }, ); + + it('skips OneKey update detection while the hardware channel is busy', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'ONEKEY_BLE_ID', + vendor: EHardwareVendor.onekey, + } as IDBDevice); + const tryRunExclusiveOneKeyOperation = jest + .fn() + .mockResolvedValue({ acquired: false }); + const getCompatibleConnectId = jest.fn(); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId, + }, + serviceHardwareUI: { + tryRunExclusiveOneKeyOperation, + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.detectActiveAccountFirmwareUpdates({ + connectId: 'ONEKEY_BLE_ID', + }), + ).resolves.toEqual({ + status: 'busy', + retryAfterMs: 5000, + }); + + expect(tryRunExclusiveOneKeyOperation).toHaveBeenCalledWith( + expect.any(Function), + { deviceKey: 'db-device-1' }, + ); + expect(getCompatibleConnectId).not.toHaveBeenCalled(); + }); + + it('returns the remaining throttle delay after the hardware channel becomes idle', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'ONEKEY_BLE_ID', + vendor: EHardwareVendor.onekey, + } as IDBDevice); + const getCompatibleConnectId = jest.fn(); + const tryRunExclusiveOneKeyOperation = jest.fn( + async (operation: () => Promise) => ({ + acquired: true as const, + result: await operation(), + }), + ); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId, + }, + serviceHardwareUI: { + tryRunExclusiveOneKeyOperation, + }, + serviceFirmwareUpdate: { + showAutoUpdateCheckDebugToast: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + + const result = await service.detectActiveAccountFirmwareUpdates({ + connectId: 'ONEKEY_BLE_ID', + }); + + expect(result.status).toBe('throttled'); + if (result.status === 'throttled') { + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(60_000); + } + expect(tryRunExclusiveOneKeyOperation).toHaveBeenCalledWith( + expect.any(Function), + { deviceKey: 'db-device-1' }, + ); + expect(getCompatibleConnectId).not.toHaveBeenCalled(); + }); + + it('runs OneKey SDK detection only while holding the hardware lease', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'ONEKEY_BLE_ID', + vendor: EHardwareVendor.onekey, + } as IDBDevice); + let leaseActive = false; + const getCompatibleConnectId = jest.fn(async () => { + expect(leaseActive).toBe(true); + return 'ONEKEY_COMPATIBLE_ID'; + }); + const tryRunExclusiveOneKeyOperation = jest.fn( + async (operation: () => Promise) => { + leaseActive = true; + try { + return { + acquired: true as const, + result: await operation(), + }; + } finally { + leaseActive = false; + } + }, + ); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getCompatibleConnectId, + }, + serviceHardwareUI: { + tryRunExclusiveOneKeyOperation, + }, + serviceFirmwareUpdate: { + showAutoUpdateCheckDebugToast: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.detectMap.firstDetectAt = + Date.now() - timerUtils.getTimeDurationMs({ minute: 2 }); + jest + .spyOn(service, 'checkDeviceIsBootloaderMode') + .mockImplementation(async () => { + expect(leaseActive).toBe(true); + return { + isBootloaderMode: false, + features: undefined, + error: undefined, + }; + }); + + await expect( + service.detectActiveAccountFirmwareUpdates({ + connectId: 'ONEKEY_BLE_ID', + }), + ).resolves.toEqual({ status: 'finished' }); + + expect(getCompatibleConnectId).toHaveBeenCalledWith({ + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + connectId: 'ONEKEY_BLE_ID', + }); + expect(leaseActive).toBe(false); + }); +}); + +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('merges and deduplicates developer force targets after SDK targets', () => { + expect( + buildPro2TargetsToUpdate({ + sdkTargets: ['app_v1', 'resource'], + forceTargets: ['resource', 'se01'], + }), + ).toEqual(['app_v1', 'resource', 'se01']); + }); +}); + +describe('buildProtocolV2PlanForceTargets', () => { + it('does not synthesize a resource update when no developer target is selected', () => { + expect(buildProtocolV2PlanForceTargets({})).toEqual([]); + }); + + it('merges developer targets without forcing a full resource reinstall', () => { + expect( + buildProtocolV2PlanForceTargets({ + forceTargets: ['app_v1', 'resource'], + forceOnceTargets: ['coprocessor'], + }), + ).toEqual(['app_v1', 'resource', 'coprocessor']); + }); +}); + +describe('shouldForceProtocolV2ResourceUpdate', () => { + it('does not force an automatically selected resource', () => { + expect( + shouldForceProtocolV2ResourceUpdate({ + targetsToUpdate: ['resource'], + }), + ).toBe(false); + }); + + it.each([ + { forceTargets: ['resource'] as const }, + { forceOnceTargets: ['resource'] as const }, + { legacyForceResource: true }, + ])('forces resource reinstall for $#. configured override', (overrides) => { + expect( + shouldForceProtocolV2ResourceUpdate({ + targetsToUpdate: ['resource'], + ...overrides, + }), + ).toBe(true); + }); + + it('does not force a skipped resource even when an override remains set', () => { + expect( + shouldForceProtocolV2ResourceUpdate({ + targetsToUpdate: ['app_v1'], + forceTargets: ['resource'], + }), + ).toBe(false); + }); +}); + +describe('buildProtocolV2FirmwareVersionInfo', () => { + const releaseInfo = { + currentVersions: { + firmware: '1.0.0', + applicationP1: '1.0.0', + applicationP2: '1.0.0', + bootloader: '1.0.0', + board: '1.0.0', + ble: '1.0.20', + }, + components: [ + { + configKey: 'application_p1', + componentTarget: 'APPLICATION_P1', + updateTarget: 'app_v1', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + status: 'outdated', + required: false, + }, + { + configKey: 'coprocessor', + componentTarget: 'COPROCESSOR', + updateTarget: 'coprocessor', + currentVersion: '1.0.20', + targetVersion: '1.0.21', + status: 'outdated', + required: false, + }, + ], + release: { + version: [1, 1, 0], + }, + } as unknown as Parameters< + typeof buildProtocolV2FirmwareVersionInfo + >[0]['releaseInfo']; + + it('keeps SafeOS first-level versions and selected component versions', () => { + expect( + buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate: ['app_v1', 'coprocessor', 'resource'], + }), + ).toEqual({ + safeOS: { + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + components: [ + { + target: 'app_v1', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + { + target: 'coprocessor', + currentVersion: '1.0.20', + targetVersion: '1.0.21', + }, + ], + }); + }); + + it('shows the current SafeOS version without an update transition for resources', () => { + expect( + buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate: ['resource'], + }), + ).toEqual({ + safeOS: { + currentVersion: '1.0.0', + targetVersion: null, + }, + components: [], + }); + }); + + it.each(['se01', 'se02', 'se03', 'se04'] as const)( + 'does not report an %s-only update as a SafeOS transition', + (target) => { + expect( + buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate: [target], + }), + ).toMatchObject({ + safeOS: { + currentVersion: '1.0.0', + targetVersion: null, + }, + }); + }, + ); +}); + +describe('ServiceFirmwareUpdate Protocol V2 version mapping', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('prefers the SDK device-state BLE version over legacy features', async () => { + jest.spyOn(deviceUtils, 'getDeviceVersion').mockResolvedValue({ + bleVersion: '1.0.0', + firmwareVersion: '', + bootloaderVersion: '', + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getConnectIdFromFeatures: jest.fn().mockResolvedValue(undefined), + }, + } as unknown as IBackgroundApi, + }); + + const result = await service.checkBLEFirmwareRelease({ + connectId: undefined, + features: {} as IOneKeyDeviceFeatures, + bleReleasePayload: { + status: 'outdated', + shouldUpdate: true, + release: { version: [2, 0, 0] }, + } as unknown as IBleFirmwareReleasePayload, + forceUpdate: false, + currentVersion: '1.5.0', + }); + + expect(result).toEqual( + expect.objectContaining({ + hasUpgrade: true, + fromVersion: '1.5.0', + toVersion: '2.0.0', + }), + ); + }); + + it('reads a Protocol V2 bootloader component version directly', async () => { + jest.spyOn(deviceUtils, 'getDeviceVersion').mockResolvedValue({ + bleVersion: '', + firmwareVersion: '', + bootloaderVersion: '1.0.0', + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: {} as IBackgroundApi, + }); + + const result = await service.checkBootloaderRelease({ + connectId: undefined, + features: {} as IOneKeyDeviceFeatures, + firmwareUpdateInfo: { + releasePayload: { release: undefined }, + } as unknown as IFirmwareUpdateInfo, + bootloaderReleasePayload: { + status: 'outdated', + shouldUpdate: true, + release: { version: [2, 0, 0] }, + } as unknown as IBootloaderReleasePayload, + forceUpdate: false, + currentVersion: '1.5.0', + }); + + expect(result).toEqual( + expect.objectContaining({ + hasUpgrade: true, + fromVersion: '1.5.0', + toVersion: '2.0.0', + }), + ); + }); +}); + +describe('supportsFirmwareUpdateWorkflowV2', () => { + it.each([ + ['Pro', 'pro'], + ['Pro2', 'pro2'], + ['Neo', 'neo'], + ])('allows %s devices', (_name, deviceType) => { + expect(supportsFirmwareUpdateWorkflowV2(deviceType)).toBe(true); + }); + + it.each([ + ['Classic', 'classic'], + ['Touch', 'touch'], + ['unknown', undefined], + ])('rejects %s devices', (_name, deviceType) => { + expect(supportsFirmwareUpdateWorkflowV2(deviceType)).toBe(false); + }); +}); + +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', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not pass Protocol V2 resource binaries outside PreparedPlan', 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.updatingFirmwareV4({ + connectId: 'PRO2_CONNECT_ID', + bleVersion: undefined, + firmwareVersion: undefined, + bootloaderVersion: undefined, + firmwareType: undefined, + isPro2Device: true, + pro2TargetsToUpdate: ['resource'], + requirePreparedArtifacts: false, + targetsToUpdate: ['resource'], + }); + + expect(firmwareUpdateV4).toHaveBeenCalledTimes(1); + expect(firmwareUpdateV4.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + forcedUpdateRes: false, + targetsToUpdate: ['resource'], + }), + ); + expect(firmwareUpdateStepInfoAtom.set).toHaveBeenCalledWith({ + step: 'installing', + payload: { installingTarget: {} }, + }); + }); + + it.each([ + { + target: 'app_v1' as const, + expectedVersions: { + firmwareVersion: '3.0.0', + bootloaderVersion: undefined, + bleVersion: undefined, + }, + actualVersions: { + firmwareVersion: '2.0.0', + bootloaderVersion: '1.0.0', + bleVersion: '1.0.0', + }, + }, + { + target: 'boot' as const, + expectedVersions: { + firmwareVersion: undefined, + bootloaderVersion: '3.0.0', + bleVersion: undefined, + }, + actualVersions: { + firmwareVersion: '1.0.0', + bootloaderVersion: '2.0.0', + bleVersion: '1.0.0', + }, + }, + { + target: 'coprocessor' as const, + expectedVersions: { + firmwareVersion: undefined, + bootloaderVersion: undefined, + bleVersion: '3.0.0', + }, + actualVersions: { + firmwareVersion: '1.0.0', + bootloaderVersion: '1.0.0', + bleVersion: '2.0.0', + }, + }, + ])( + 'rejects a Protocol V2 $target final version mismatch', + async ({ target, expectedVersions, actualVersions }) => { + const firmwareUpdateV4 = jest.fn().mockResolvedValue({ + success: true, + payload: actualVersions, + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance: jest.fn().mockResolvedValue({ + firmwareUpdateV4, + on: jest.fn(), + off: jest.fn(), + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.updatingFirmwareV4({ + connectId: 'PRO2_CONNECT_ID', + ...expectedVersions, + firmwareType: undefined, + isPro2Device: true, + pro2TargetsToUpdate: [target], + requirePreparedArtifacts: false, + targetsToUpdate: [target], + }), + ).rejects.toMatchObject({ + code: HardwareErrorCode.FirmwareVerificationFailed, + message: 'FirmwareUpdateVersionMismatch', + }); + }, + ); + + it('accepts matching Protocol V2 final versions', async () => { + const firmwareUpdateV4 = jest.fn().mockResolvedValue({ + success: true, + payload: { + firmwareVersion: '3.0.0', + bootloaderVersion: '2.0.0', + bleVersion: '1.0.0', + }, + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance: jest.fn().mockResolvedValue({ + firmwareUpdateV4, + on: jest.fn(), + off: jest.fn(), + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.updatingFirmwareV4({ + connectId: 'PRO2_CONNECT_ID', + firmwareVersion: '3.0.0', + bootloaderVersion: '2.0.0', + bleVersion: '1.0.0', + firmwareType: undefined, + isPro2Device: true, + pro2TargetsToUpdate: ['app_v1', 'boot', 'coprocessor'], + requirePreparedArtifacts: false, + targetsToUpdate: ['app_v1', 'boot', 'coprocessor'], + }), + ).resolves.toMatchObject({ + message: 'success', + firmwareVersion: '3.0.0', + bootloaderVersion: '2.0.0', + bleVersion: '1.0.0', + }); + }); + + it('keeps the BLE peripheral ID when the active transport is desktop BLE', async () => { + const firmwareUpdateV4 = jest.fn().mockResolvedValue({ + success: true, + payload: {}, + }); + const hardwareSDK = { + firmwareUpdateV4, + on: jest.fn(), + off: jest.fn(), + }; + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const getSDKInstance = jest.fn().mockResolvedValue(hardwareSDK); + jest.spyOn(timerUtils, 'wait').mockResolvedValue(undefined); + + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance, + getCurrentTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + // The persisted value may be stale; the active connection is authoritative. + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + + await service.updatingFirmwareV4({ + connectId: bleConnectId, + bleVersion: undefined, + firmwareVersion: undefined, + bootloaderVersion: undefined, + firmwareType: undefined, + isPro2Device: true, + pro2TargetsToUpdate: ['app_v1'], + requirePreparedArtifacts: false, + targetsToUpdate: ['app_v1'], + }); + + expect(firmwareUpdateV4).toHaveBeenCalledWith( + bleConnectId, + expect.objectContaining({ targetsToUpdate: ['app_v1'] }), + ); + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: bleConnectId, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); +}); + +describe('ServiceFirmwareUpdate legacy workflow running state', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('sets the background guard before entering hardware processing', async () => { + jest.clearAllMocks(); + mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + const withHardwareProcessing = jest + .fn() + .mockRejectedValue(new Error('hardware processing unavailable')); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardwareUI: { + withHardwareProcessing, + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.startUpdateWorkflow({ + releaseResult: { + updateInfos: {}, + }, + } as never), + ).rejects.toThrow('hardware processing unavailable'); + + expect(firmwareUpdateWorkflowRunningAtom.set).toHaveBeenCalledWith(true); + expect( + jest.mocked(firmwareUpdateWorkflowRunningAtom.set).mock + .invocationCallOrder[0], + ).toBeLessThan(withHardwareProcessing.mock.invocationCallOrder[0]); + expect(firmwareUpdateWorkflowRunningAtom.set).toHaveBeenLastCalledWith( + false, + ); + }); + + it('clears the background guard when transport initialization fails', async () => { + jest.clearAllMocks(); + mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + const waitSpy = jest.spyOn(timerUtils, 'wait').mockResolvedValue(undefined); + const clearForceTransportType = jest.fn().mockResolvedValue(undefined); + const withHardwareProcessing = jest.fn( + async (callback: () => Promise) => callback(), + ); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardwareUI: { + withHardwareProcessing, + }, + serviceHardware: { + getCurrentTransportType: jest + .fn() + .mockRejectedValue(new Error('transport unavailable')), + clearForceTransportType, + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.startUpdateWorkflow({ + releaseResult: { + updateInfos: {}, + }, + } as never), + ).rejects.toThrow('transport unavailable'); + + expect(clearForceTransportType).toHaveBeenCalledTimes(1); + expect(firmwareUpdateWorkflowRunningAtom.set).toHaveBeenNthCalledWith( + 1, + true, + ); + expect(firmwareUpdateWorkflowRunningAtom.set).toHaveBeenLastCalledWith( + false, + ); + expect(waitSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('ServiceFirmwareUpdate Protocol V2 desktop transport', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves Desktop BLE to USB before locking the firmware transport', async () => { + const setForceTransportType = jest.fn().mockResolvedValue(undefined); + const clearForceTransportType = jest.fn().mockResolvedValue(undefined); + const resolveHardwareTransport = jest.fn().mockResolvedValue({ + connectId: 'PRO2_USB_ID', + transportType: EHardwareTransportType.WEBUSB, + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (callback: () => Promise) => callback(), + ), + }, + serviceHardware: { + getCurrentTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + resolveHardwareTransport, + setForceTransportType, + clearForceTransportType, + }, + } as unknown as IBackgroundApi, + }); + jest.spyOn(timerUtils, 'wait').mockResolvedValue(undefined); + jest + .spyOn(service, 'validateMnemonicBackuped') + .mockRejectedValue(new Error('stop after transport lock')); + const releaseResult = { + originalConnectId: 'PRO2_BLE_ID', + updatingConnectId: 'PRO2_BLE_ID', + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult; + + await expect( + service.runUpdateWorkflowV2({ + backuped: true, + usbConnected: true, + releaseResult, + }), + ).rejects.toThrow('stop after transport lock'); + + expect(resolveHardwareTransport).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, + }); + expect(setForceTransportType).toHaveBeenCalledWith({ + forceTransportType: EHardwareTransportType.WEBUSB, + }); + expect(releaseResult.updatingConnectId).toBeUndefined(); + expect(clearForceTransportType).toHaveBeenCalledTimes(1); + }); }); describe('ServiceFirmwareUpdate workflow tracking', () => { @@ -361,6 +1275,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 () => { @@ -391,8 +1306,223 @@ describe('ServiceFirmwareUpdate workflow tracking', () => { await service.runUpdateTask({ id: 1 }); + expect(firmwareUpdateStepInfoAtom.set).toHaveBeenCalledWith({ + step: 'installing', + payload: {}, + }); expect(firmwareUpdateRetryAtom.set).toHaveBeenCalledWith( expect.objectContaining({ id: 1 }), ); }); }); + +describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { + const createService = () => + new ServiceFirmwareUpdate({ + backgroundApi: {} as IBackgroundApi, + }); + + test('keeps Pro on the existing V3 path when no prepared Plan exists', async () => { + const service = createService(); + const updatingFirmwareV3 = jest + .spyOn(service, 'updatingFirmwareV3') + .mockResolvedValue({ message: 'ok' }); + const runtimeHost = jest.spyOn( + service as unknown as { + getFirmwareUpdateRuntimeHost: () => Promise; + }, + 'getFirmwareUpdateRuntimeHost', + ); + jest + .spyOn(service, 'createRunTaskWithRetry') + .mockImplementation(async ({ fn }) => fn({ id: 1 })); + + await service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro, + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }); + + expect(updatingFirmwareV3).toHaveBeenCalledTimes(1); + expect(runtimeHost).not.toHaveBeenCalled(); + }); + + test('rejects a resource-only Protocol V2 update without PreparedPlan', async () => { + const service = createService(); + const updatingFirmwareV4 = jest.spyOn(service, 'updatingFirmwareV4'); + + await expect( + service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + pro2TargetsToUpdate: ['resource'], + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }), + ).rejects.toThrow( + 'Firmware update plan is required for Protocol V2 updates', + ); + expect(updatingFirmwareV4).not.toHaveBeenCalled(); + }); + + test('rejects Protocol V2 component targets when no prepared Plan exists', async () => { + const service = createService(); + const updatingFirmwareV4 = jest.spyOn(service, 'updatingFirmwareV4'); + const createRunTaskWithRetry = jest.spyOn( + service, + 'createRunTaskWithRetry', + ); + + await expect( + service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + pro2TargetsToUpdate: ['boot', 'app_v1', 'resource'], + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }), + ).rejects.toThrow( + 'Firmware update plan is required for Protocol V2 updates', + ); + + expect(createRunTaskWithRetry).not.toHaveBeenCalled(); + expect(updatingFirmwareV4).not.toHaveBeenCalled(); + }); + + test('fails closed before inspecting Protocol V2 targets when no Plan exists', async () => { + const service = createService(); + const updatingFirmwareV4 = jest.spyOn(service, 'updatingFirmwareV4'); + const createRunTaskWithRetry = jest.spyOn( + service, + 'createRunTaskWithRetry', + ); + + await expect( + service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + pro2TargetsToUpdate: ['app_v1', 'invalid-target'], + updateInfos: {}, + } as unknown as ICheckAllFirmwareReleaseResult, + }), + ).rejects.toThrow( + 'Firmware update plan is required for Protocol V2 updates', + ); + + expect(createRunTaskWithRetry).not.toHaveBeenCalled(); + expect(updatingFirmwareV4).not.toHaveBeenCalled(); + }); + + test('requires App-prepared artifacts whenever the Pro2 Plan is present', async () => { + const service = createService(); + const plan = { + executor: 'v4', + targetsToUpdate: ['boot', 'app_v1', 'resource'], + }; + jest + .spyOn( + service as unknown as { + getFirmwareUpdateRuntimeHost: () => Promise; + }, + 'getFirmwareUpdateRuntimeHost', + ) + .mockResolvedValue({ + artifacts: { + getPlan: jest.fn(() => plan), + }, + }); + jest + .spyOn(service, 'createRunTaskWithRetry') + .mockImplementation(async ({ fn }) => fn({ id: 1 })); + const updatingFirmwareV4 = jest + .spyOn(service, 'updatingFirmwareV4') + .mockResolvedValue({ message: 'ok' }); + + await service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + firmwareUpdatePlanDigest: 'c'.repeat(64), + pro2TargetsToUpdate: ['boot', 'app_v1', 'resource'], + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }); + + expect(updatingFirmwareV4).toHaveBeenCalledWith( + expect.objectContaining({ + requirePreparedArtifacts: true, + targetsToUpdate: ['boot', 'app_v1', 'resource'], + }), + undefined, + ); + }); + + test('lets Extension execute a Protocol V2 Plan through SDK-managed V4', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + isNative: platformEnv.isNative, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: false, + isNative: false, + symbol: 'ext', + }); + try { + const service = createService(); + const plan = { + executor: 'v4', + targetsToUpdate: ['app_v1'], + }; + jest + .spyOn( + service as unknown as { + getFirmwareUpdateRuntimeHost: () => Promise; + }, + 'getFirmwareUpdateRuntimeHost', + ) + .mockResolvedValue({ + artifacts: { + getPlan: jest.fn(() => plan), + }, + }); + jest + .spyOn(service, 'createRunTaskWithRetry') + .mockImplementation(async ({ fn }) => fn({ id: 1 })); + const updatingFirmwareV4 = jest + .spyOn(service, 'updatingFirmwareV4') + .mockResolvedValue({ message: 'ok' }); + + await service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + firmwareUpdatePlanDigest: 'd'.repeat(64), + pro2TargetsToUpdate: ['app_v1'], + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }); + + expect(updatingFirmwareV4).toHaveBeenCalledWith( + expect.objectContaining({ + requirePreparedArtifacts: false, + targetsToUpdate: ['app_v1'], + }), + undefined, + ); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts index 1019e3ed15ea..5448fe8e1cd4 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts @@ -1,10 +1,16 @@ -import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; +import { + EDeviceType, + type EFirmwareType, + HardwareErrorCode, +} from '@onekeyfe/hd-shared'; import { get, isArray, isNil } from 'lodash'; import semver from 'semver'; import { + type IBackgroundMethodWithDevOnlyPassword, backgroundClass, backgroundMethod, + backgroundMethodForDev, toastIfError, } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { makeTimeoutPromise } from '@onekeyhq/shared/src/background/backgroundUtils'; @@ -28,16 +34,19 @@ import { } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import errorToastUtils from '@onekeyhq/shared/src/errors/utils/errorToastUtils'; import { toPlainErrorObject } from '@onekeyhq/shared/src/errors/utils/errorUtils'; +import { toUserFacingFirmwareUpdateError } from '@onekeyhq/shared/src/errors/utils/firmwareUpdateErrorUtils'; import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS } from '@onekeyhq/shared/src/hardware/connectionTimeouts'; import { CoreSDKLoader } from '@onekeyhq/shared/src/hardware/instance'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { parseFirmwareVersions } from '@onekeyhq/shared/src/logger/scopes/update/scenes/firmware'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { equalsIgnoreCase } from '@onekeyhq/shared/src/utils/stringUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; @@ -55,6 +64,8 @@ import type { IFirmwareUpdateV3VersionParams, IHardwareBridgeReleasePayload, IOneKeyDeviceFeatures, + IPro2FirmwareUpdateTarget, + IProtocolV2FirmwareVersionInfo, IResourceUpdateInfo, } from '@onekeyhq/shared/types/device'; import { @@ -71,6 +82,7 @@ import { firmwareUpdateStepInfoAtom, firmwareUpdateWorkflowRunningAtom, hardwareUiStateAtom, + hardwareUiStateCompletedAtom, } from '../../states/jotai/atoms'; import ServiceBase from '../ServiceBase'; import serviceHardwareUtils from '../ServiceHardware/serviceHardwareUtils'; @@ -81,7 +93,18 @@ import { FIRMWARE_UPDATE_MIN_VERSION_ALLOWED, } from './firmwareUpdateConsts'; import { FirmwareUpdateDetectMap } from './FirmwareUpdateDetectMap'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; +import type { + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from './FirmwareArtifactSelfTest'; +import type { + IFirmwareExecutionArtifacts, + IFirmwareWorkflowArtifacts, +} from './FirmwarePreparedArtifactController'; +import type { IFirmwareUpdateRuntimeHost } from './FirmwareUpdateRuntime'; +import type { IFirmwareUpdateTraceInputMode } from './FirmwareUpdateTrace'; import type { IDBDevice } from '../../dbs/local/types'; import type { IPromiseContainerCallbackCreate, @@ -92,12 +115,21 @@ import type { AllFirmwareRelease, CoreApi, Success as CoreSuccess, + DeviceSuccess, DeviceUploadResourceParams, + FirmwareUpdatePlanForceTarget, + FirmwareUpdateV4Target, IDeviceType, IVersionArray, } from '@onekeyfe/hd-core'; -import type { EFirmwareType } from '@onekeyfe/hd-shared'; -import type { Features, Success } from '@onekeyfe/hd-transport'; +import type { Success } from '@onekeyfe/hd-transport'; + +let firmwareUpdateRuntimeModulePromise: + | Promise + | undefined; + +const loadFirmwareUpdateRuntime = () => + (firmwareUpdateRuntimeModulePromise ??= import('./FirmwareUpdateRuntime')); export type IAutoUpdateFirmwareParams = { connectId: string | undefined; @@ -116,11 +148,141 @@ export type IStartUpdateWorkflowV2Result = { backgroundTaskStarted: true; }; +export type IDetectActiveAccountFirmwareUpdatesResult = + | { + status: 'busy' | 'throttled'; + retryAfterMs: number; + } + | { + status: 'finished' | 'skipped'; + }; + +const FIRMWARE_UPDATE_DETECT_BUSY_RETRY_DELAY = timerUtils.getTimeDurationMs({ + seconds: 5, +}); + +const PRO2_APP_FIRMWARE_UPDATE_TARGETS = new Set([ + 'app_v1', + 'app_v2', +]); + +const PROTOCOL_V2_SAFE_OS_TARGETS = new Set([ + 'app_v1', + 'app_v2', +]); + +export function buildPro2TargetsToUpdate({ + sdkTargets, + forceTargets = [], +}: { + sdkTargets: readonly FirmwareUpdateV4Target[] | undefined; + forceTargets?: readonly IPro2FirmwareUpdateTarget[]; +}) { + return Array.from( + new Set([ + ...(sdkTargets ?? []).map((target) => + target === 'boot_resources' ? 'resource' : target, + ), + ...forceTargets, + ]), + ); +} + +export function buildProtocolV2PlanForceTargets({ + forceTargets = [], + forceOnceTargets = [], +}: { + forceTargets?: readonly IPro2FirmwareUpdateTarget[]; + forceOnceTargets?: readonly IPro2FirmwareUpdateTarget[]; +}) { + return buildPro2TargetsToUpdate({ + sdkTargets: [], + forceTargets: [...forceTargets, ...forceOnceTargets], + }); +} + +export function shouldForceProtocolV2ResourceUpdate({ + targetsToUpdate, + legacyForceResource, + forceTargets = [], + forceOnceTargets = [], +}: { + targetsToUpdate: readonly FirmwareUpdateV4Target[]; + legacyForceResource?: boolean; + forceTargets?: readonly IPro2FirmwareUpdateTarget[]; + forceOnceTargets?: readonly IPro2FirmwareUpdateTarget[]; +}) { + return ( + targetsToUpdate.some( + (target) => target === 'resource' || target === 'boot_resources', + ) && + (legacyForceResource === true || + forceTargets.includes('resource') || + forceOnceTargets.includes('resource')) + ); +} + +export function buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate, +}: { + releaseInfo: Pick< + AllFirmwareRelease, + 'components' | 'currentVersions' | 'release' + >; + targetsToUpdate: readonly IPro2FirmwareUpdateTarget[]; +}): IProtocolV2FirmwareVersionInfo { + const selectedComponentTargets = targetsToUpdate.filter( + (target): target is Exclude => + target !== 'resource', + ); + const components = selectedComponentTargets.map((target) => { + const component = releaseInfo.components?.find( + (item) => item.updateTarget === target, + ); + return { + target, + currentVersion: component?.currentVersion ?? null, + targetVersion: component?.targetVersion ?? null, + }; + }); + const hasSafeOSUpdate = targetsToUpdate.some((target) => + PROTOCOL_V2_SAFE_OS_TARGETS.has(target), + ); + const safeOSComponentTargetVersion = components.find( + (component) => + component.target === 'app_v1' || component.target === 'app_v2', + )?.targetVersion; + + return { + safeOS: { + currentVersion: + releaseInfo.currentVersions?.firmware ?? + releaseInfo.currentVersions?.applicationP1 ?? + null, + targetVersion: hasSafeOSUpdate + ? (releaseInfo.release?.version?.join('.') ?? + safeOSComponentTargetVersion ?? + null) + : null, + }, + components, + }; +} + +export function supportsFirmwareUpdateWorkflowV2( + deviceType: IDeviceType | string | null | undefined, +): boolean { + // Workflow V2 is the app's second-generation update flow, not the device's Protocol V2. + // Pro uses this flow, while Protocol V2 devices such as Pro2 and Neo enter through it too. + return deviceType === EDeviceType.Pro || isProtocolV2ProductType(deviceType); +} + 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; @@ -133,31 +295,85 @@ interface IFirmwareUpdateResult { bootloaderVersion?: string; } +type IFirmwareUpdateV4AppParams = IFirmwareUpdateV3VersionParams & { + requirePreparedArtifacts: boolean; + targetsToUpdate: FirmwareUpdateV4Target[]; +}; + @backgroundClass() class ServiceFirmwareUpdate extends ServiceBase { + private firmwareUpdateRuntimeHost?: Promise; + constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); } - async getSDKInstance({ + private async getActiveTransportType(): Promise { + const serviceHardware = this.backgroundApi.serviceHardware; + if (typeof serviceHardware?.getCurrentTransportType === 'function') { + return serviceHardware.getCurrentTransportType(); + } + return this.backgroundApi.serviceSetting.getHardwareTransportType(); + } + + private getFirmwareUpdateRuntimeHost(): Promise { + return (this.firmwareUpdateRuntimeHost ??= loadFirmwareUpdateRuntime().then( + ({ createFirmwareUpdateRuntimeHost }) => + createFirmwareUpdateRuntimeHost({ + getHardwareTransportType: () => this.getActiveTransportType(), + getSDKInstance: (connectId?: string) => + this.getSDKInstance({ connectId }), + }), + )); + } + + @backgroundMethodForDev() + startFirmwareArtifactSelfTest({ + scenario, + }: IBackgroundMethodWithDevOnlyPassword & { + scenario: IFirmwareArtifactSelfTestScenario; + }): Promise { + return this.getFirmwareUpdateRuntimeHost().then((runtime) => + runtime.selfTest.start(scenario), + ); + } + + @backgroundMethodForDev() + getFirmwareArtifactSelfTestState( + _params: IBackgroundMethodWithDevOnlyPassword, + ): Promise { + return Promise.resolve(this.firmwareUpdateRuntimeHost).then((runtime) => + runtime?.selfTest.getState(), + ); + } + + getSDKInstance({ connectId, + hardwareTransportType, + forceFirmwareManifestRefresh, }: { connectId: string | undefined; + hardwareTransportType?: EHardwareTransportType; + forceFirmwareManifestRefresh?: boolean; }): Promise { - const hardwareSDK = await this.backgroundApi.serviceHardware.getSDKInstance( + return this.backgroundApi.serviceHardware.getSDKInstance({ + connectId, + hardwareTransportType, + ...(forceFirmwareManifestRefresh + ? { forceFirmwareManifestRefresh: true } + : {}), + }); + } + + clearOnceUpdateDevSettings(): Promise { + return this.backgroundApi.serviceDevSetting.updateFirmwareUpdateDevSettings( { - connectId, + forceUpdateOnceFirmware: false, + forceUpdateOnceBle: false, + forceUpdateOnceBootloader: false, + pro2ForceUpdateOnceTargets: [], }, ); - return hardwareSDK; - } - - async clearOnceUpdateDevSettings() { - await this.backgroundApi.serviceDevSetting.updateFirmwareUpdateDevSettings({ - forceUpdateOnceFirmware: false, - forceUpdateOnceBle: false, - forceUpdateOnceBootloader: false, - }); } @backgroundMethod() @@ -171,7 +387,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } @backgroundMethod() - async rebootToBoardloader(connectId: string): Promise { + async rebootToBoardloader(connectId: string): Promise { const hardwareSDK = await this.getSDKInstance({ connectId, }); @@ -185,10 +401,14 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId, allowEmptyConnectId, featuresCache, + forceProtocolDetection, + hardwareTransportType, }: { connectId: string | undefined; allowEmptyConnectId?: boolean | undefined; featuresCache?: IOneKeyDeviceFeatures; + forceProtocolDetection?: boolean; + hardwareTransportType?: EHardwareTransportType; }) { let features: IOneKeyDeviceFeatures | undefined; let error: IOneKeyError | undefined; @@ -208,8 +428,13 @@ class ServiceFirmwareUpdate extends ServiceBase { // do not prompt web device permission skipWebDevicePrompt: true, allowEmptyConnectId, + forceProtocolDetection, + ...(forceProtocolDetection + ? { timeout: DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS } + : {}), }, silentMode: true, + hardwareTransportType, }); } isBootloaderMode = await deviceUtils.isBootloaderModeByFeatures({ @@ -310,59 +535,88 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId, }: { connectId: string; - }) { + }): Promise { // detect certain account device firmware update, so connectId is required if (!connectId) { - return; + return { status: 'skipped' }; } const dbDevice = await localDb.getDeviceByQuery({ connectId }); const vendorProfile = dbDevice?.vendor ? getVendorProfile(dbDevice.vendor) : undefined; if (vendorProfile?.isThirdParty) { - return; - } - const showBootloaderUpdateModal = () => { - appEventBus.emit(EAppEventBusNames.ShowFirmwareUpdateFromBootloaderMode, { - connectId, - }); - }; - if (!this.detectMap.shouldDetect({ connectId })) { - return; - } - this.detectMap.updateLastDetectAt({ - connectId, - }); + return { status: 'skipped' }; + } + const exclusiveResult = + await this.backgroundApi.serviceHardwareUI.tryRunExclusiveOneKeyOperation( + async (): Promise => { + const showBootloaderUpdateModal = () => { + appEventBus.emit( + EAppEventBusNames.ShowFirmwareUpdateFromBootloaderMode, + { + connectId, + }, + ); + }; + if (!this.detectMap.shouldDetect({ connectId })) { + return { + status: 'throttled', + retryAfterMs: Math.max( + 1, + this.detectMap.getNextDetectDelay({ connectId }), + ), + }; + } + this.detectMap.updateLastDetectAt({ + connectId, + }); - const compatibleConnectId = - await this.backgroundApi.serviceHardware.getCompatibleConnectId({ - hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, - connectId, - }); + const compatibleConnectId = + await this.backgroundApi.serviceHardware.getCompatibleConnectId({ + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + connectId, + }); - const { isBootloaderMode, features, error } = - await this.checkDeviceIsBootloaderMode({ - connectId: compatibleConnectId || connectId, - }); + const { isBootloaderMode, features, error } = + await this.checkDeviceIsBootloaderMode({ + connectId: compatibleConnectId || connectId, + }); - serviceHardwareUtils.hardwareLog('checkFirmwareUpdateStatus', features); + serviceHardwareUtils.hardwareLog( + 'checkFirmwareUpdateStatus', + features, + ); - if (error) { - if ( - isHardwareErrorByCode({ - error, - code: [HardwareErrorCode.DeviceNotFound], - }) - ) { - // ignore - return; - } - throw error; - } + if (error) { + if ( + isHardwareErrorByCode({ + error, + code: [HardwareErrorCode.DeviceNotFound], + }) + ) { + // ignore + return { status: 'finished' }; + } + throw error; + } - if (isBootloaderMode) { - showBootloaderUpdateModal(); + if (isBootloaderMode) { + showBootloaderUpdateModal(); + } + return { status: 'finished' }; + }, + { + deviceKey: + dbDevice?.id || dbDevice?.deviceId || dbDevice?.uuid || connectId, + }, + ); + if (!exclusiveResult.acquired) { + return { + status: 'busy', + retryAfterMs: FIRMWARE_UPDATE_DETECT_BUSY_RETRY_DELAY, + }; } + return exclusiveResult.result; } private _checkCacheMeetExpectations({ @@ -381,6 +635,30 @@ class ServiceFirmwareUpdate extends ServiceBase { return undefined; } + private async getFirmwareUpdateDevForceTargets(): Promise< + FirmwareUpdatePlanForceTarget[] + > { + const settings = + await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettingsSnapshot(); + const targets: FirmwareUpdatePlanForceTarget[] = []; + if (settings?.forceUpdateFirmware || settings?.forceUpdateOnceFirmware) { + targets.push('firmware'); + if (settings.forceUpdateResEvenSameVersion) { + targets.push('resource'); + } + } + if (settings?.forceUpdateBle || settings?.forceUpdateOnceBle) { + targets.push('ble'); + } + if ( + settings?.forceUpdateBootloader || + settings?.forceUpdateOnceBootloader + ) { + targets.push('bootloader'); + } + return targets; + } + @backgroundMethod() @toastIfError() async checkAllFirmwareRelease({ @@ -388,19 +666,47 @@ class ServiceFirmwareUpdate extends ServiceBase { firmwareType, skipCancel, baseReleaseInfoCache, + checkFirmwareHash, + resolvedTransportType, }: { connectId: string | undefined; firmwareType: EFirmwareType | undefined; skipCancel?: boolean; baseReleaseInfoCache?: AllFirmwareRelease; + checkFirmwareHash?: boolean; + resolvedTransportType?: EHardwareTransportType; }): Promise { - const { getDeviceUUID } = await CoreSDKLoader(); - - const releaseInfoCache = this._checkCacheMeetExpectations({ - baseReleaseInfo: baseReleaseInfoCache, - }); - - const originalConnectId = connectId; + const hardwareSdk = await CoreSDKLoader(); + const getDeviceSerialNo = + ( + hardwareSdk as typeof hardwareSdk & { + getDeviceSerialNo?: typeof hardwareSdk.getDeviceUUID; + } + ).getDeviceSerialNo ?? hardwareSdk.getDeviceUUID; + const forceUpdateTargets = await this.getFirmwareUpdateDevForceTargets(); + + const releaseInfoCache = + !checkFirmwareHash && forceUpdateTargets.length === 0 + ? this._checkCacheMeetExpectations({ + baseReleaseInfo: baseReleaseInfoCache, + }) + : undefined; + + let resolvedTransport: + | { + connectId: string; + transportType: EHardwareTransportType; + } + | undefined; + if (connectId) { + resolvedTransport = resolvedTransportType + ? { connectId, transportType: resolvedTransportType } + : await this.backgroundApi.serviceHardware.resolveHardwareTransport({ + connectId, + hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, + }); + } + const originalConnectId = resolvedTransport?.connectId ?? connectId; // Skip cancel when using cached data since device state was already verified const needSkipCancel = skipCancel || !!releaseInfoCache; @@ -417,8 +723,14 @@ class ServiceFirmwareUpdate extends ServiceBase { await firmwareUpdateRetryAtom.set(undefined); serviceHardwareUtils.hardwareLog('checkAllFirmwareRelease'); + // transport 与 connectId 已在上方成对解析;这里不能再从持久化设置推导, + // 因为持久化值可能仍描述上一轮操作,而本轮已经选择了 BLE。 + const currentTransportType = + resolvedTransport?.transportType ?? (await this.getActiveTransportType()); const sdk = await this.getSDKInstance({ connectId: originalConnectId, + hardwareTransportType: currentTransportType, + forceFirmwareManifestRefresh: true, }); try { if (!needSkipCancel) { @@ -432,8 +744,6 @@ class ServiceFirmwareUpdate extends ServiceBase { await timerUtils.wait(1000); } - const currentTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); const updatingConnectId = deviceUtils.getUpdatingConnectId({ connectId: originalConnectId, currentTransportType, @@ -451,9 +761,15 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.checkDeviceIsBootloaderMode({ connectId: originalConnectId, allowEmptyConnectId: true, - featuresCache: releaseInfoCache?.features, + forceProtocolDetection: + currentTransportType === EHardwareTransportType.DesktopWebBle, + hardwareTransportType: currentTransportType, + 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) { @@ -462,20 +778,56 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId: isBootloaderMode ? updatingConnectId : originalConnectId, params: { allowEmptyConnectId: true, + forceProtocolDetection: + currentTransportType === EHardwareTransportType.DesktopWebBle, + ...(currentTransportType === EHardwareTransportType.DesktopWebBle + ? { timeout: DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS } + : {}), }, + hardwareTransportType: currentTransportType, }); } + const deviceType = await deviceUtils.getDeviceTypeFromFeatures({ + features, + }); + const protocolV2DevSettings = isProtocolV2ProductType(deviceType) + ? await Promise.all([ + this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateTargets', + ), + this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateOnceTargets', + ), + ]) + : undefined; + const pro2ForceTargets = protocolV2DevSettings + ? buildProtocolV2PlanForceTargets({ + forceTargets: protocolV2DevSettings[0] ?? [], + forceOnceTargets: protocolV2DevSettings[1] ?? [], + }) + : undefined; + const forceUpdateTargetsForDevice = isProtocolV2ProductType(deviceType) + ? [] + : forceUpdateTargets; + const releaseInfo = - releaseInfoCache ?? - (await this.baseCheckAllFirmwareRelease({ - connectId: originalConnectId, - firmwareType, - skipChangeTransportType: true, - })); + releaseInfoCache?.firmwareUpdatePlan && !pro2ForceTargets?.length + ? releaseInfoCache + : await this.loadBaseFirmwareRelease({ + connectId: originalConnectId, + firmwareType, + skipChangeTransportType: true, + checkFirmwareHash, + resolvedTransportType: currentTransportType, + forceUpdateTargets: forceUpdateTargetsForDevice, + protocolV2ForceUpdateTargets: pro2ForceTargets, + }); const currentFirmwareType = await deviceUtils.getFirmwareType({ - features: releaseInfo.features, + features: releaseInfo.features as unknown as + | IOneKeyDeviceFeatures + | undefined, }); const firmware = await this.checkFirmwareRelease({ @@ -484,6 +836,7 @@ class ServiceFirmwareUpdate extends ServiceBase { firmwareReleasePayload: releaseInfo.firmware as unknown as IFirmwareReleasePayload, saveUpdateInfo: currentFirmwareType === firmwareType, + forceUpdate: forceUpdateTargetsForDevice.includes('firmware'), }); let ble; @@ -500,17 +853,28 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO mock bridge?.shouldUpdate bridge.shouldUpdate = true; } - - // TODO only check bootloader upgrade? - if (!bridge?.shouldUpdate && releaseInfo.bootloader) { - bootloader = await this.checkBootloaderRelease({ - connectId: updatingConnectId, - features, - firmwareUpdateInfo: firmware, - bootloaderReleasePayload: - releaseInfo.bootloader as unknown as IBootloaderReleasePayload, - }); - } + } + const shouldCheckProtocolV2Bootloader = + isProtocolV2ProductType(deviceType) && Boolean(releaseInfo.bootloader); + if ( + !bridge?.shouldUpdate && + releaseInfo.bootloader && + (shouldCheckProtocolV2Bootloader || + (firmware?.toVersion && + (firmware.hasUpgrade || + forceUpdateTargetsForDevice.includes('bootloader')))) + ) { + bootloader = await this.checkBootloaderRelease({ + connectId: updatingConnectId, + features, + firmwareUpdateInfo: firmware, + bootloaderReleasePayload: + releaseInfo.bootloader as unknown as IBootloaderReleasePayload, + forceUpdate: + forceUpdateTargetsForDevice.includes('bootloader') || + pro2ForceTargets?.includes('boot'), + currentVersion: releaseInfo.currentVersions?.bootloader, + }); } if (!bridge?.shouldUpdate) { @@ -519,6 +883,10 @@ class ServiceFirmwareUpdate extends ServiceBase { features, bleReleasePayload: releaseInfo.ble as unknown as IBleFirmwareReleasePayload, + forceUpdate: + forceUpdateTargetsForDevice.includes('ble') || + pro2ForceTargets?.includes('coprocessor'), + currentVersion: releaseInfo.currentVersions?.ble, }); } @@ -557,11 +925,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 }); @@ -608,11 +973,23 @@ class ServiceFirmwareUpdate extends ServiceBase { } } - let device: IDBDevice | undefined; + const pro2TargetsToUpdate = isProtocolV2ProductType(deviceType) + ? buildPro2TargetsToUpdate({ + sdkTargets: releaseInfo.targetsToUpdate, + forceTargets: pro2ForceTargets, + }) + : undefined; + const protocolV2FirmwareVersionInfo = pro2TargetsToUpdate + ? buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate: pro2TargetsToUpdate, + }) + : undefined; + let fixedUpdatingConnectId = updatingConnectId; try { if (platformEnv.isSupportDesktopBle) { - device = await localDb.getDeviceByQuery({ + const device: IDBDevice | undefined = await localDb.getDeviceByQuery({ connectId: originalConnectId, }); fixedUpdatingConnectId = deviceUtils.getFixedUpdatingConnectId({ @@ -622,18 +999,46 @@ class ServiceFirmwareUpdate extends ServiceBase { }); } } catch (_error) { - // ignore - } + // Keep the transport-derived connect ID when the local device is absent. + } + + const effectiveHasUpgrade = + hasUpgrade || Boolean(pro2TargetsToUpdate?.length); + const executableFirmwareUpdatePlan = + releaseInfo.firmwareUpdatePlan?.artifacts.length && + releaseInfo.firmwareUpdatePlan.targetsToUpdate.length + ? releaseInfo.firmwareUpdatePlan + : undefined; + const firmwareUpdatePlanDigest = await ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts.cachePlanDigestIfPreparedSupported({ + hasUpgrade: effectiveHasUpgrade, + plan: executableFirmwareUpdatePlan, + connectId: updatingConnectId, + transportType: currentTransportType, + expectedTargets: isProtocolV2ProductType(deviceType) + ? pro2TargetsToUpdate + : [ + ...new Set([ + ...forceUpdateTargetsForDevice, + ...(firmware?.hasUpgrade ? (['firmware'] as const) : []), + ...(ble?.hasUpgrade ? (['ble'] as const) : []), + ...(bootloader?.hasUpgrade ? (['bootloader'] as const) : []), + ]), + ], + requirePreparedPlan: isProtocolV2ProductType(deviceType), + }); - return { + const result = { updatingConnectId: fixedUpdatingConnectId, originalConnectId, features, deviceType, deviceName, deviceBleName, - deviceUUID, - hasUpgrade, + deviceUUID: deviceSerialNo, + firmwareUpdatePlanDigest, + hasUpgrade: effectiveHasUpgrade, isBootloaderMode: features ? (await deviceUtils.getDeviceModeFromFeatures({ features })) === EOneKeyDeviceMode.bootloader @@ -645,7 +1050,29 @@ class ServiceFirmwareUpdate extends ServiceBase { bridge, }, totalPhase: totalPhase.filter(Boolean), + pro2TargetsToUpdate, + pro2ResourceArchive: releaseInfo.resourceArchive + ? { + archiveSha256: releaseInfo.resourceArchive.archiveSha256, + archiveSize: releaseInfo.resourceArchive.archiveSize, + } + : undefined, + protocolV2FirmwareVersionInfo, }; + + // 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() @@ -654,11 +1081,13 @@ class ServiceFirmwareUpdate extends ServiceBase { features, firmwareReleasePayload, saveUpdateInfo = true, + forceUpdate, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; firmwareReleasePayload: IFirmwareReleasePayload; saveUpdateInfo?: boolean; + forceUpdate?: boolean; }): Promise { const releasePayload: IFirmwareReleasePayload = { ...firmwareReleasePayload, @@ -669,44 +1098,67 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check releaseInfo.version with current version // 1. manual check here // 2. auto check by event: FIRMWARE_EVENT (event emit by method calling like sdk.getFeatures()) - return this.setFirmwareUpdateInfo(releasePayload, saveUpdateInfo); + return this.setFirmwareUpdateInfo( + releasePayload, + saveUpdateInfo, + forceUpdate, + ); } - @backgroundMethod() - async baseCheckAllFirmwareRelease({ + private async loadBaseFirmwareRelease({ connectId, firmwareType, skipChangeTransportType, retryCount, silentMode, + checkFirmwareHash, + resolvedTransportType, + forceUpdateTargets, + protocolV2ForceUpdateTargets, + forceFirmwareManifestRefresh, }: { connectId: string | undefined; firmwareType: EFirmwareType | undefined; skipChangeTransportType?: boolean; retryCount?: number; silentMode?: boolean; + checkFirmwareHash?: boolean; + resolvedTransportType?: EHardwareTransportType; + forceUpdateTargets?: FirmwareUpdatePlanForceTarget[]; + protocolV2ForceUpdateTargets?: IPro2FirmwareUpdateTarget[]; + forceFirmwareManifestRefresh?: boolean; }) { const hardwareSDK = await this.getSDKInstance({ connectId, + hardwareTransportType: resolvedTransportType, + forceFirmwareManifestRefresh, }); const checkBridgeRelease = await this._hasUseBridge(); let currentConnectId = connectId; if (!skipChangeTransportType) { const currentTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + resolvedTransportType ?? (await this.getActiveTransportType()); currentConnectId = deviceUtils.getUpdatingConnectId({ connectId, currentTransportType, }); } + const checkReleaseParams = { + checkBridgeRelease, + firmwareType, + platform: platformEnv.symbol ?? 'web', + retryCount, + checkFirmwareHash, + forceUpdateTargets, + protocolV2ForceUpdateTargets, + }; const result = await convertDeviceResponse( () => // method fail if device on boot mode - hardwareSDK.checkAllFirmwareRelease(currentConnectId, { - checkBridgeRelease, - firmwareType, - retryCount, - }), + hardwareSDK.checkAllFirmwareRelease( + currentConnectId, + checkReleaseParams, + ), { silentMode, }, @@ -715,6 +1167,19 @@ class ServiceFirmwareUpdate extends ServiceBase { return result; } + @backgroundMethod() + baseCheckAllFirmwareRelease( + params: Parameters[0], + ) { + return this.loadBaseFirmwareRelease({ + ...params, + forceFirmwareManifestRefresh: true, + }).then((result) => ({ + ...result, + firmwareUpdatePlan: undefined, + })); + } + @backgroundMethod() async checkFirmwareTypeAvailable({ connectId, @@ -741,10 +1206,14 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId, features, bleReleasePayload, + forceUpdate, + currentVersion, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; bleReleasePayload: IBleFirmwareReleasePayload; + forceUpdate?: boolean; + currentVersion?: string | null; }): Promise { const releasePayload: IBleFirmwareReleasePayload = { ...bleReleasePayload, @@ -755,7 +1224,11 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check releaseInfo.version with current version // 1. manual check here // 2. auto check by event: FIRMWARE_EVENT (event emit by method calling like sdk.getFeatures()) - return this.setBleFirmwareUpdateInfo(releasePayload); + return this.setBleFirmwareUpdateInfo( + releasePayload, + forceUpdate, + currentVersion, + ); } // TODO only for classic and mini? @@ -765,25 +1238,32 @@ class ServiceFirmwareUpdate extends ServiceBase { features, firmwareUpdateInfo, bootloaderReleasePayload, + forceUpdate, + currentVersion, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; firmwareUpdateInfo: IFirmwareUpdateInfo; bootloaderReleasePayload: IBootloaderReleasePayload; + forceUpdate?: boolean; + currentVersion?: string | null; }): Promise { const usedReleasePayload = bootloaderReleasePayload; - const { bootloaderVersion } = await deviceUtils.getDeviceVersion({ - features, - device: undefined, - }); + const { bootloaderVersion: detectedBootloaderVersion } = + await deviceUtils.getDeviceVersion({ + features, + device: undefined, + }); + const bootloaderVersion = currentVersion || detectedBootloaderVersion; let toVersion = ''; let changelog: IFirmwareChangeLog | undefined; // boot releaseInfo?.release may be string of resource download url const versionFromReleaseInfo = - usedReleasePayload?.release?.displayBootloaderVersion; + usedReleasePayload?.release?.displayBootloaderVersion ?? + usedReleasePayload?.release?.version; if (versionFromReleaseInfo && isArray(versionFromReleaseInfo)) { - toVersion = this.arrayVersionToString(versionFromReleaseInfo as any); + toVersion = this.arrayVersionToString(versionFromReleaseInfo); } if (!toVersion) { toVersion = this.arrayVersionToString( @@ -805,6 +1285,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType: undefined, toFirmwareType: undefined, + forceUpdate, }); const updateInfo: IBootloaderUpdateInfo = { @@ -829,6 +1310,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType, toFirmwareType, + forceUpdate, }: { releasePayload: | IFirmwareReleasePayload @@ -839,6 +1321,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion: string; fromFirmwareType: EFirmwareType | undefined; toFirmwareType: EFirmwareType | undefined; + forceUpdate?: boolean; }) { let hasUpgradeForce = false; let hasUpgrade = false; @@ -896,44 +1379,13 @@ class ServiceFirmwareUpdate extends ServiceBase { hasUpgrade = false; } - const mockUpdateFirmware = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateFirmware', - ); - const mockUpdateOnceFirmware = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceFirmware', - ); - const mockUpdateBle = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateBle', - ); - const mockUpdateOnceBle = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceBle', - ); - const mockUpdateBootloader = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateBootloader', - ); - const mockUpdateOnceBootloader = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceBootloader', - ); - if ( - firmwareType === 'firmware' && - (mockUpdateFirmware || mockUpdateOnceFirmware) - ) { - hasUpgrade = true; - } - if (firmwareType === 'ble' && (mockUpdateBle || mockUpdateOnceBle)) { - hasUpgrade = true; - } - if ( - firmwareType === 'bootloader' && - (mockUpdateBootloader || mockUpdateOnceBootloader) - ) { + if (forceUpdate === true) { hasUpgrade = true; + } else if (forceUpdate === undefined) { + const forceUpdateTargets = await this.getFirmwareUpdateDevForceTargets(); + if (forceUpdateTargets.includes(firmwareType)) { + hasUpgrade = true; + } } return { @@ -987,6 +1439,7 @@ class ServiceFirmwareUpdate extends ServiceBase { async setFirmwareUpdateInfo( payload: IFirmwareReleasePayload, saveUpdateInfo = true, + forceUpdate?: boolean, ): Promise { serviceHardwareUtils.hardwareLog('_checkFirmwareUpdate', payload); if (!payload?.features) { @@ -1015,6 +1468,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType, toFirmwareType, + forceUpdate, }); const updateInfo: IFirmwareUpdateInfo = { @@ -1043,7 +1497,11 @@ class ServiceFirmwareUpdate extends ServiceBase { } @backgroundMethod() - async setBleFirmwareUpdateInfo(payload: IBleFirmwareReleasePayload) { + async setBleFirmwareUpdateInfo( + payload: IBleFirmwareReleasePayload, + forceUpdate?: boolean, + currentVersion?: string | null, + ) { serviceHardwareUtils.hardwareLog('showBleFirmwareReleaseInfo', payload); if (!payload.features) { throw new OneKeyLocalError( @@ -1051,11 +1509,12 @@ class ServiceFirmwareUpdate extends ServiceBase { ); } const connectId = await this.getConnectIdFromReleaseInfo(payload); - const { bleVersion } = await deviceUtils.getDeviceVersion({ - device: undefined, - features: payload.features, - }); - const fromVersion = bleVersion || ''; + const { bleVersion: detectedBleVersion } = + await deviceUtils.getDeviceVersion({ + device: undefined, + features: payload.features, + }); + const fromVersion = currentVersion || detectedBleVersion || ''; const toVersion = this.arrayVersionToString(payload?.release?.version); const { hasUpgrade, hasUpgradeForce } = await this.getFirmwareHasUpgradeStatus({ @@ -1065,6 +1524,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType: undefined, toFirmwareType: undefined, + forceUpdate, }); const updateInfo: IBleFirmwareUpdateInfo = { @@ -1088,12 +1548,40 @@ class ServiceFirmwareUpdate extends ServiceBase { return updateInfo; } - async withFirmwareUpdateEvents(fn: () => Promise): Promise { + async withFirmwareUpdateEvents( + fn: () => Promise, + executionArtifacts?: IFirmwareExecutionArtifacts, + ): Promise { const hardwareSDK = await this.getSDKInstance({ connectId: undefined, }); + const transactionId = + executionArtifacts?.preparedArtifacts?.transactionId ?? + executionArtifacts?.bridgeBinaries?.transactionId; + const executor = + executionArtifacts?.preparedArtifacts?.plan.executor ?? + executionArtifacts?.bridgeBinaries?.executor; + let inputMode: IFirmwareUpdateTraceInputMode = 'sdk-managed'; + if (executionArtifacts?.preparedArtifacts) { + inputMode = 'artifact-reader'; + } else if (executionArtifacts?.bridgeBinaries) { + inputMode = 'bridge-binary'; + } const listener = (data: any) => { serviceHardwareUtils.hardwareLog('autoUpdateFirmware', data); + const tipMessage = + get(data, 'data.message') ?? + get(data, 'payload.data.message') ?? + get(data, 'message'); + if (transactionId && typeof tipMessage === 'string') { + firmwareUpdateTrace({ + transactionId, + stage: 'sdk-tip', + executor, + inputMode, + tipMessage, + }); + } // dispatch(setUpdateFirmwareStep(get(data, 'data.message', ''))); }; hardwareSDK.on(EHardwareUiStateAction.FIRMWARE_TIP, listener); @@ -1147,7 +1635,19 @@ class ServiceFirmwareUpdate extends ServiceBase { async updatingBootloader( params: IUpdateFirmwareWorkflowParams, updateInfo: IBootloaderUpdateInfo, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'bootloaderUpdate', + ); + const { + executePreparedDeviceUpdateBootloader, + executePreparedFirmwareUpdateV2Bootloader, + } = await loadFirmwareUpdateRuntime(); const hardwareSDK = await this.getSDKInstance({ connectId: params.releaseResult.updatingConnectId, }); @@ -1177,11 +1677,12 @@ class ServiceFirmwareUpdate extends ServiceBase { }, }, }); - const result = convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV2(params.releaseResult.updatingConnectId, { - updateType: 'firmware', + const result = convertDeviceResponse(() => + executePreparedFirmwareUpdateV2Bootloader({ + sdk: hardwareSDK, + connectId: params.releaseResult.updatingConnectId, + ...executionArtifacts, platform: platformEnv.symbol ?? 'web', - isUpdateBootloader: true, }), ); return result; @@ -1197,15 +1698,15 @@ class ServiceFirmwareUpdate extends ServiceBase { }, }, }); - return convertDeviceResponse(async () => - // TODO connectId can be undefined - hardwareSDK.deviceUpdateBootloader( - params.releaseResult.updatingConnectId as string, - {}, - ), + return convertDeviceResponse(() => + executePreparedDeviceUpdateBootloader({ + sdk: hardwareSDK, + connectId: params.releaseResult.updatingConnectId as string, + ...executionArtifacts, + }), ); } - }); + }, executionArtifacts); } updatingBootloaderForTouchAndProLegacy( @@ -1266,7 +1767,17 @@ class ServiceFirmwareUpdate extends ServiceBase { { connectId, version, firmwareType, deviceType }: IAutoUpdateFirmwareParams, updateInfo: IBleFirmwareUpdateInfo | IFirmwareUpdateInfo, workflowParams: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV2', + ); + const { executePreparedFirmwareUpdateV2 } = + await loadFirmwareUpdateRuntime(); // const { dispatch } = this.backgroundApi; // dispatch(setUpdateFirmwareStep('')); @@ -1284,9 +1795,12 @@ class ServiceFirmwareUpdate extends ServiceBase { // const version = settings.deviceUpdates?.[connectId][firmwareType]?.version; const forceUpdateResEvenIfSameVersion = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + executionArtifacts.preparedArtifacts?.plan.targetsToUpdate.includes( + 'resource', + ) ?? + (await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( 'forceUpdateResEvenSameVersion', - ); + )); const versionArr = version.split('.').map((v) => parseInt(v, 10)); // TODO move to utils await firmwareUpdateStepInfoAtom.set({ step: EFirmwareUpdateSteps.installing, @@ -1299,23 +1813,25 @@ class ServiceFirmwareUpdate extends ServiceBase { }, }); - const currentTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); - - const result = await convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV2( - deviceUtils.getUpdatingConnectId({ connectId, currentTransportType }), - { - updateType: firmwareType as any, - // update res is always enabled when firmware version changed - // forcedUpdateRes for TEST only, means always update res even if firmware version is same (re-flash the same firmware) - forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, - version: versionArr, - platform: platformEnv.symbol ?? 'web', - firmwareType: updateInfo.toFirmwareType, - }, - ), - ); + const currentTransportType = await this.getActiveTransportType(); + + const updateType = firmwareType === 'ble' ? 'ble' : 'firmware'; + const result = await convertDeviceResponse(async () => { + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId, + currentTransportType, + }); + return executePreparedFirmwareUpdateV2({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + updateType, + forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, + version: versionArr, + platform: platformEnv.symbol ?? 'web', + firmwareType: updateInfo.toFirmwareType, + }); + }); if ( result && deviceType === EDeviceType.Touch && @@ -1326,7 +1842,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } // TODO handleErrors UpdatingModal return result; - }); + }, executionArtifacts); } @backgroundMethod() @@ -1364,8 +1880,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } async _hasUseBridge() { - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const hardwareTransportType = await this.getActiveTransportType(); if (hardwareTransportType === EHardwareTransportType.WEBUSB) { return false; } @@ -1400,7 +1915,8 @@ class ServiceFirmwareUpdate extends ServiceBase { updateFlow: 'v1' | 'v2'; releaseResult: ICheckAllFirmwareReleaseResult; }) { - const workflowId = (this.updateWorkflowSequence += 1); + this.updateWorkflowSequence += 1; + const workflowId = this.updateWorkflowSequence; this.updateWorkflowTracking = { workflowId, acceptsTaskResults: true, @@ -1505,11 +2021,11 @@ class ServiceFirmwareUpdate extends ServiceBase { ) { return; } - const attempt = (tracking.attemptCount += 1); + tracking.attemptCount += 1; + const attempt = tracking.attemptCount; const err = error === undefined ? undefined : toPlainErrorObject(error as any); - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const hardwareTransportType = await this.getActiveTransportType(); defaultLogger.update.firmware.firmwareUpdateAttemptResult({ deviceType: tracking.releaseResult.deviceType, transportType: hardwareTransportType, @@ -1578,8 +2094,14 @@ class ServiceFirmwareUpdate extends ServiceBase { @backgroundMethod() async exitUpdateWorkflow() { this.closeUpdateWorkflowTracking(); - await this.updateTasksClear('exitUpdateWorkflow'); - await firmwareUpdateWorkflowRunningAtom.set(false); + try { + const { cancelFirmwareArtifactPreparations } = + await loadFirmwareUpdateRuntime(); + await cancelFirmwareArtifactPreparations(); + } finally { + await this.updateTasksClear('exitUpdateWorkflow'); + await firmwareUpdateWorkflowRunningAtom.set(false); + } } async cancelUpdateWorkflowIfExit() { @@ -1603,8 +2125,7 @@ class ServiceFirmwareUpdate extends ServiceBase { // allowEmptyConnectId: true, // }, // ); - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const hardwareTransportType = await this.getActiveTransportType(); if (actionType === 'nextPhase') { const isWebUsb = hardwareTransportType === EHardwareTransportType.WEBUSB; await timerUtils.wait(isWebUsb ? 20 * 1000 : 15 * 1000); @@ -1650,158 +2171,184 @@ class ServiceFirmwareUpdate extends ServiceBase { if (!dbDevice) { // throw new OneKeyLocalError('device not found'); } - await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { - appEventBus.emit(EAppEventBusNames.BeginFirmwareUpdate, undefined); - // await other hardware task stop processing - await timerUtils.wait(3000); - - // Lock transport type during firmware update to prevent auto-switching - // This prevents the system from switching to BLE when USB device is temporarily - // unavailable during device reboot - const currentTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); - await this.backgroundApi.serviceHardware.setForceTransportType({ - forceTransportType: currentTransportType, - }); - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow: locked transport type', - currentTransportType, - ); - - try { - // TODO verify current device is matched with params.connectId\params.updateFirmware\params.updateBle - // pre checking - await this.validateMnemonicBackuped(params); - await this.validateUSBConnection(params); - // must before validateMinVersionAllowed, go to https://help.onekey.so/ - await this.validateShouldUpdateFullResource(params); - // go to https://firmware.onekey.so/ - await this.validateMinVersionAllowed(params); - await this.validateDeviceBattery(params); - await this.validateShouldUpdateBridge(params); - - // ** clear all retry tasks - await this.updateTasksClear('startUpdateWorkflow'); - - let shouldRebootAfterUpdate = false; - - const waitRebootDelayForNextPhase = async () => { - if (shouldRebootAfterUpdate) { - await this.waitDeviceRestart({ - actionType: 'nextPhase', - releaseResult: params.releaseResult, - }); - shouldRebootAfterUpdate = false; - } - }; - - // ** bootloader update - await this.cancelUpdateWorkflowIfExit(); - if (params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade) { - await waitRebootDelayForNextPhase(); - await this.startUpdateBootloaderTask(params); - - shouldRebootAfterUpdate = true; + await firmwareUpdateWorkflowRunningAtom.set(true); + try { + await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( + async () => { + try { + appEventBus.emit(EAppEventBusNames.BeginFirmwareUpdate, undefined); + // await other hardware task stop processing + await timerUtils.wait(3000); - // await hardware boot install and reboot - // move sdk - await this.waitDeviceRestart({ - actionType: 'boot-done', - releaseResult: params.releaseResult, + // Lock transport type during firmware update to prevent auto-switching + // This prevents the system from switching to BLE when USB device is temporarily + // unavailable during device reboot + const currentTransportType = await this.getActiveTransportType(); + await this.backgroundApi.serviceHardware.setForceTransportType({ + forceTransportType: currentTransportType, }); - } - - // TODO cancel workflow if modal closed or back - - // ** firmware update (including res update) - if (params?.releaseResult?.updateInfos?.firmware?.hasUpgrade) { - await waitRebootDelayForNextPhase(); - - const deviceType = params?.releaseResult?.deviceType; - // TODO recheck release if match with current connect device - // TODO check update version gt current version - // TODO check features matched - await this.cancelUpdateWorkflowIfExit(); - await this.startUpdateFirmwareTaskBase( - { - connectId: params?.releaseResult?.updatingConnectId, - version: - params?.releaseResult?.updateInfos?.firmware?.toVersion, - firmwareType: 'firmware', - deviceType, - }, - params?.releaseResult?.updateInfos?.firmware, - params, + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow: locked transport type', + currentTransportType, ); - shouldRebootAfterUpdate = true; - } - - // ble update - if (params?.releaseResult?.updateInfos?.ble?.hasUpgrade) { - await waitRebootDelayForNextPhase(); - - const deviceType = params?.releaseResult?.deviceType; + // TODO verify current device is matched with params.connectId\params.updateFirmware\params.updateBle + // pre checking + await this.validateMnemonicBackuped(params); + await this.validateUSBConnection(params); + // must before validateMinVersionAllowed, go to https://help.onekey.so/ + await this.validateShouldUpdateFullResource(params); + // go to https://firmware.onekey.so/ + await this.validateMinVersionAllowed(params); + await this.validateDeviceBattery(params); + await this.validateShouldUpdateBridge(params); - // TODO recheck release if match with current connect device - await this.cancelUpdateWorkflowIfExit(); - await this.startUpdateFirmwareTaskBase( - { - connectId: params?.releaseResult?.updatingConnectId, - version: params?.releaseResult?.updateInfos?.ble?.toVersion, - firmwareType: 'ble', - deviceType, + await this.setFirmwareArtifactDownloadState(true); + await ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts.withWorkflowArtifacts( + params.releaseResult, + async (firmwareArtifacts) => { + await this.setFirmwareArtifactDownloadState(false); + // ** clear all retry tasks + await this.updateTasksClear('startUpdateWorkflow'); + + let shouldRebootAfterUpdate = false; + + const waitRebootDelayForNextPhase = async () => { + if (shouldRebootAfterUpdate) { + await this.waitDeviceRestart({ + actionType: 'nextPhase', + releaseResult: params.releaseResult, + }); + shouldRebootAfterUpdate = false; + } + }; + + // ** bootloader update + await this.cancelUpdateWorkflowIfExit(); + if ( + params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade + ) { + await waitRebootDelayForNextPhase(); + await this.startUpdateBootloaderTask( + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; + + // await hardware boot install and reboot + // move sdk + await this.waitDeviceRestart({ + actionType: 'boot-done', + releaseResult: params.releaseResult, + }); + } + + // TODO cancel workflow if modal closed or back + + // ** firmware update (including res update) + if (params?.releaseResult?.updateInfos?.firmware?.hasUpgrade) { + await waitRebootDelayForNextPhase(); + + const deviceType = params?.releaseResult?.deviceType; + // TODO recheck release if match with current connect device + // TODO check update version gt current version + // TODO check features matched + await this.cancelUpdateWorkflowIfExit(); + await this.startUpdateFirmwareTaskBase( + { + connectId: params?.releaseResult?.updatingConnectId, + version: + params?.releaseResult?.updateInfos?.firmware?.toVersion, + firmwareType: 'firmware', + deviceType, + }, + params?.releaseResult?.updateInfos?.firmware, + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; + } + + // ble update + if (params?.releaseResult?.updateInfos?.ble?.hasUpgrade) { + await waitRebootDelayForNextPhase(); + + const deviceType = params?.releaseResult?.deviceType; + + // TODO recheck release if match with current connect device + await this.cancelUpdateWorkflowIfExit(); + await this.startUpdateFirmwareTaskBase( + { + connectId: params?.releaseResult?.updatingConnectId, + version: + params?.releaseResult?.updateInfos?.ble?.toVersion, + firmwareType: 'ble', + deviceType, + }, + params?.releaseResult?.updateInfos?.ble, + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; + + await this.waitDeviceRestart({ + actionType: 'ble-done', + releaseResult: params.releaseResult, + }); + } + + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', + params, + ); + + await firmwareUpdateRetryAtom.set(undefined); + if (params.releaseResult.originalConnectId) { + await this.waitDeviceRestart({ + actionType: 'done', + releaseResult: params.releaseResult, + }); + await this.detectMap.deleteUpdateInfo({ + connectId: params.releaseResult.originalConnectId, + }); + await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( + params, + ); + await this.clearOnceUpdateDevSettings(); + appEventBus.emit( + EAppEventBusNames.FinishFirmwareUpdate, + undefined, + ); + } }, - params?.releaseResult?.updateInfos?.ble, - params, ); - - shouldRebootAfterUpdate = true; - - await this.waitDeviceRestart({ - actionType: 'ble-done', - releaseResult: params.releaseResult, - }); - } - - serviceHardwareUtils.hardwareLog('startUpdateWorkflow DONE', params); - - await firmwareUpdateRetryAtom.set(undefined); - if (params.releaseResult.originalConnectId) { - await this.waitDeviceRestart({ - actionType: 'done', - releaseResult: params.releaseResult, - }); - await this.detectMap.deleteUpdateInfo({ - connectId: params.releaseResult.originalConnectId, - }); - await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( - params, + } finally { + // Always clear transport type lock when firmware update completes (success or failure) + await this.backgroundApi.serviceHardware.clearForceTransportType(); + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow: cleared transport type lock', ); - await this.clearOnceUpdateDevSettings(); - appEventBus.emit(EAppEventBusNames.FinishFirmwareUpdate, undefined); } - } finally { - // Always clear transport type lock when firmware update completes (success or failure) - await this.backgroundApi.serviceHardware.clearForceTransportType(); - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow: cleared transport type lock', - ); - // Reset workflow running state at service level to prevent lock-screen bypass - // This ensures the atom is reset even if the UI component has unmounted - await firmwareUpdateWorkflowRunningAtom.set(false); - } - }, - { - deviceParams: { - dbDevice: dbDevice || ({} as any), }, - skipDeviceCancel: true, - hideCheckingDeviceLoading: true, - debugMethodName: 'startUpdateWorkflow', - }, - ); + { + deviceParams: { + dbDevice: dbDevice || ({} as any), + }, + allowDuringFirmwareUpdate: true, + skipDeviceCancel: true, + hideCheckingDeviceLoading: true, + debugMethodName: 'startUpdateWorkflow', + }, + ); + } finally { + // The bg guard outlives the UI and must cover lock acquisition failures too. + await firmwareUpdateWorkflowRunningAtom.set(false); + } } @backgroundMethod() @@ -1811,6 +2358,7 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId: '', payload: {} as any, }); + await hardwareUiStateCompletedAtom.set(undefined); await firmwareUpdateResultVerifyAtom.set(undefined); } @@ -1835,8 +2383,7 @@ class ServiceFirmwareUpdate extends ServiceBase { }); try { - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const hardwareTransportType = await this.getActiveTransportType(); const trackingInfo = await this.getUpdateWorkflowTrackingInfo(); defaultLogger.update.firmware.firmwareUpdateResult({ @@ -1866,13 +2413,14 @@ class ServiceFirmwareUpdate extends ServiceBase { error: unknown; }) { const err = toPlainErrorObject(error as any); + const displayError = toUserFacingFirmwareUpdateError(err); const updateFirmwareInfo = params.releaseResult.updateInfos?.firmware; serviceHardwareUtils.hardwareLog('startUpdateWorkflow ERROR', error); await firmwareUpdateStepInfoAtom.set({ step: EFirmwareUpdateSteps.error, payload: { - error: err, + error: displayError, }, }); @@ -1887,8 +2435,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } try { - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + const hardwareTransportType = await this.getActiveTransportType(); const trackingInfo = await this.getUpdateWorkflowTrackingInfo(); defaultLogger.update.firmware.firmwareUpdateResult({ @@ -1912,6 +2459,22 @@ class ServiceFirmwareUpdate extends ServiceBase { } } + private async setFirmwareArtifactDownloadState( + isDownloadingArtifacts: boolean, + ) { + const stepInfo = await firmwareUpdateStepInfoAtom.get(); + if (stepInfo.step !== EFirmwareUpdateSteps.updateStart) { + return; + } + await firmwareUpdateStepInfoAtom.set({ + step: EFirmwareUpdateSteps.updateStart, + payload: { + ...stepInfo.payload, + isDownloadingArtifacts, + }, + }); + } + async runUpdateWorkflowV2(params: IUpdateFirmwareWorkflowParams) { try { await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( @@ -1923,9 +2486,33 @@ class ServiceFirmwareUpdate extends ServiceBase { // await other hardware task stop processing await timerUtils.wait(3000); - // Lock transport type during firmware update to prevent auto-switching - const currentTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); + // Desktop firmware updates must use the same USB-only product flow as x. + // Resolve again at execution time so Pro2 and Neo cannot retain a BLE route. + let currentTransportType = await this.getActiveTransportType(); + if (platformEnv.isDesktop) { + const resolvedTransport = + await this.backgroundApi.serviceHardware.resolveHardwareTransport( + { + connectId: + params.releaseResult.originalConnectId ?? + params.releaseResult.updatingConnectId, + hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, + }, + ); + currentTransportType = resolvedTransport.transportType; + params.releaseResult.updatingConnectId = + deviceUtils.getUpdatingConnectId({ + connectId: resolvedTransport.connectId, + currentTransportType, + }); + if ( + currentTransportType === EHardwareTransportType.DesktopWebBle + ) { + throw new OneKeyLocalError( + 'Desktop firmware updates require a USB transport', + ); + } + } await this.backgroundApi.serviceHardware.setForceTransportType({ forceTransportType: currentTransportType, }); @@ -1945,50 +2532,72 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.validateDeviceBattery(params); await this.validateShouldUpdateBridge(params); - // ** clear all retry tasks - await this.updateTasksClear('startUpdateWorkflow'); - - await this.cancelUpdateWorkflowIfExit(); - - const deviceType = params?.releaseResult?.deviceType; - if (deviceType !== EDeviceType.Pro) { - throw new OneKeyLocalError( - 'Do not support update firmware for this device', - ); - } - - const updateResult = - await this.startUpdateFirmwareTaskForNewBootVersion(params); - console.log( - 'startUpdateFirmwareTaskForNewBootVersion result: ===> ', - updateResult, - ); - - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow DONE', - params, - ); - - await firmwareUpdateRetryAtom.set(undefined); - if (params.releaseResult.originalConnectId) { - await this.waitDeviceRestart({ - actionType: 'done', - releaseResult: params.releaseResult, - }); - await this.detectMap.deleteUpdateInfo({ - connectId: params.releaseResult.originalConnectId, - }); - await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( - params, - ); - await this.clearOnceUpdateDevSettings(); - appEventBus.emit( - EAppEventBusNames.FinishFirmwareUpdate, - undefined, + await this.setFirmwareArtifactDownloadState(true); + try { + const runtimeHost = await this.getFirmwareUpdateRuntimeHost(); + await runtimeHost.artifacts.withWorkflowArtifacts( + params.releaseResult, + async (firmwareArtifacts) => { + await this.setFirmwareArtifactDownloadState(false); + // ** clear all retry tasks + await this.updateTasksClear('startUpdateWorkflow'); + + await this.cancelUpdateWorkflowIfExit(); + + const deviceType = params?.releaseResult?.deviceType; + if (!supportsFirmwareUpdateWorkflowV2(deviceType)) { + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflowV2: unsupported device type', + { + deviceType: deviceType ?? 'unknown', + isProtocolV2Product: + isProtocolV2ProductType(deviceType), + }, + ); + throw new OneKeyLocalError( + 'Do not support update firmware for this device', + ); + } + const updateResult = + await this.startUpdateFirmwareTaskForNewBootVersion( + params, + firmwareArtifacts, + ); + console.log( + 'startUpdateFirmwareTaskForNewBootVersion result: ===> ', + updateResult, + ); + + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', + params, + ); + + await firmwareUpdateRetryAtom.set(undefined); + if (params.releaseResult.originalConnectId) { + await this.waitDeviceRestart({ + actionType: 'done', + releaseResult: params.releaseResult, + }); + await this.detectMap.deleteUpdateInfo({ + connectId: params.releaseResult.originalConnectId, + }); + await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( + params, + ); + await this.clearOnceUpdateDevSettings(); + appEventBus.emit( + EAppEventBusNames.FinishFirmwareUpdate, + undefined, + ); + } + // wait verify + await timerUtils.wait(2000); + }, ); + } finally { + await this.setFirmwareArtifactDownloadState(false); } - // wait verify - await timerUtils.wait(2000); } finally { if (shouldClearForceTransportType) { // Always clear transport type lock when firmware update completes @@ -2003,6 +2612,7 @@ class ServiceFirmwareUpdate extends ServiceBase { deviceParams: { dbDevice: {} as any, }, + allowDuringFirmwareUpdate: true, skipDeviceCancel: true, hideCheckingDeviceLoading: true, debugMethodName: 'startUpdateWorkflowV2', @@ -2046,7 +2656,10 @@ class ServiceFirmwareUpdate extends ServiceBase { return { backgroundTaskStarted: true }; } - async startUpdateBootloaderTask(params: IUpdateFirmwareWorkflowParams) { + async startUpdateBootloaderTask( + params: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ) { const firmwareUpdateInfo = params?.releaseResult?.updateInfos?.firmware; const firmwareToVersion = firmwareUpdateInfo?.toVersion; if (!firmwareUpdateInfo || !firmwareToVersion) { @@ -2061,7 +2674,7 @@ class ServiceFirmwareUpdate extends ServiceBase { }); // TODO move to fn - const releaseInfo = await this.baseCheckAllFirmwareRelease({ + const releaseInfo = await this.loadBaseFirmwareRelease({ connectId: params?.releaseResult?.updatingConnectId, firmwareType: params?.releaseResult?.updateInfos?.firmware?.toFirmwareType, @@ -2085,7 +2698,8 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check update version gt current version if (updateInfo?.hasUpgrade || mockUpdateBootloader) { return this.createRunTaskWithRetry({ - fn: async () => this.updatingBootloader(params, updateInfo), + fn: async () => + this.updatingBootloader(params, updateInfo, firmwareArtifacts), }); } } @@ -2094,9 +2708,16 @@ class ServiceFirmwareUpdate extends ServiceBase { params: IAutoUpdateFirmwareParams, updateInfo: IBleFirmwareUpdateInfo | IFirmwareUpdateInfo, workflowParams: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ) { return this.createRunTaskWithRetry({ - fn: async () => this.updatingFirmware(params, updateInfo, workflowParams), + fn: async () => + this.updatingFirmware( + params, + updateInfo, + workflowParams, + firmwareArtifacts, + ), }); } @@ -2167,9 +2788,18 @@ class ServiceFirmwareUpdate extends ServiceBase { // never reject here, we should use retry // await servicePromise.rejectCallback({ id, error }); + const stepInfo = await firmwareUpdateStepInfoAtom.get(); + if (stepInfo.step === EFirmwareUpdateSteps.updateStart) { + await firmwareUpdateStepInfoAtom.set({ + step: EFirmwareUpdateSteps.installing, + payload: {}, + }); + } await firmwareUpdateRetryAtom.set({ id, - error: toPlainErrorObject(error as any), + error: toUserFacingFirmwareUpdateError( + toPlainErrorObject(error as any), + ), }); await this.backgroundApi.serviceHardwareUI.closeHardwareUiStateDialog({ @@ -2218,6 +2848,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({ @@ -2302,9 +2933,23 @@ class ServiceFirmwareUpdate extends ServiceBase { async startUpdateFirmwareTaskForNewBootVersion( params: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ): Promise { const { releaseResult } = params; const { updateInfos } = releaseResult; + // Keep the legacy field name while routing every Protocol V2 product through V4. + const isPro2Device = isProtocolV2ProductType(releaseResult.deviceType); + const plan = releaseResult.firmwareUpdatePlanDigest + ? (await this.getFirmwareUpdateRuntimeHost()).artifacts.getPlan( + releaseResult, + ) + : undefined; + if (isPro2Device && !plan) { + throw new OneKeyLocalError( + 'Firmware update plan is required for Protocol V2 updates', + ); + } + const executor = plan?.executor ?? 'v3'; const updateParams: IFirmwareUpdateV3VersionParams = { connectId: releaseResult.updatingConnectId, @@ -2318,17 +2963,174 @@ class ServiceFirmwareUpdate extends ServiceBase { ? updateInfos.bootloader?.toVersion : undefined, firmwareType: updateInfos.firmware?.toFirmwareType, + isPro2Device, + pro2TargetsToUpdate: releaseResult.pro2TargetsToUpdate, }; + if (plan?.executor === 'v4') { + const targetsToUpdate = ( + await loadFirmwareUpdateRuntime() + ).getFirmwareUpdateV4Targets(plan.targetsToUpdate); + return this.createRunTaskWithRetry({ + fn: async () => + this.updatingFirmwareV4( + { + ...updateParams, + requirePreparedArtifacts: Boolean( + platformEnv.isNative || platformEnv.isDesktop, + ), + targetsToUpdate, + }, + firmwareArtifacts, + ), + }) as Promise; + } + if (executor !== 'v3') { + throw new OneKeyLocalError( + 'Firmware update plan selected an incompatible executor', + ); + } return this.createRunTaskWithRetry({ - fn: async () => this.updatingFirmwareV3(updateParams), + fn: async () => this.updatingFirmwareV3(updateParams, firmwareArtifacts), }) as Promise; } + async updatingFirmwareV4( + params: IFirmwareUpdateV4AppParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV4', + ); + const { assertFirmwareUpdateV4Artifacts, executePreparedFirmwareUpdateV4 } = + await loadFirmwareUpdateRuntime(); + if (params.requirePreparedArtifacts) { + assertFirmwareUpdateV4Artifacts(executionArtifacts); + } + const currentTransportType = await this.getActiveTransportType(); + const hardwareSDK = await this.getSDKInstance({ + connectId: params.connectId, + hardwareTransportType: currentTransportType, + }); + + return this.withFirmwareUpdateEvents(async () => { + await firmwareUpdateStepInfoAtom.set({ + step: EFirmwareUpdateSteps.installing, + payload: { + installingTarget: {} as any, + }, + }); + if (params.requirePreparedArtifacts) { + assertFirmwareUpdateV4Artifacts( + executionArtifacts, + currentTransportType, + ); + } + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId: params.connectId, + currentTransportType, + }); + const [ + legacyForceResource, + protocolV2ForceTargets, + protocolV2ForceOnceTargets, + ] = await Promise.all([ + this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'forceUpdateResEvenSameVersion', + ), + this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateTargets', + ), + this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'pro2ForceUpdateOnceTargets', + ), + ]); + const forceUpdateResEvenIfSameVersion = + shouldForceProtocolV2ResourceUpdate({ + targetsToUpdate: params.targetsToUpdate, + legacyForceResource, + forceTargets: protocolV2ForceTargets, + forceOnceTargets: protocolV2ForceOnceTargets, + }); + const updateResult = await convertDeviceResponse(() => + executePreparedFirmwareUpdateV4({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + platform: platformEnv.symbol ?? 'web', + firmwareType: params.firmwareType, + targetsToUpdate: params.targetsToUpdate, + forcedUpdateRes: forceUpdateResEvenIfSameVersion, + }), + ); + + await firmwareUpdateResultVerifyAtom.set({ + finalBleVersion: updateResult?.bleVersion || '', + finalFirmwareVersion: updateResult?.firmwareVersion || '', + finalBootloaderVersion: updateResult?.bootloaderVersion || '', + }); + + const versionMismatches: string[] = []; + const verifyVersion = ( + expectedVersion: string | undefined, + actualVersion: string | undefined, + ) => { + if ( + expectedVersion && + semver.valid(expectedVersion) && + (!actualVersion || + !semver.valid(actualVersion) || + !semver.eq(actualVersion, expectedVersion)) + ) { + versionMismatches.push(expectedVersion); + } + }; + + if ( + params.targetsToUpdate.some( + (target) => target === 'app_v1' || target === 'app_v2', + ) + ) { + verifyVersion(params.firmwareVersion, updateResult?.firmwareVersion); + } + if (params.targetsToUpdate.includes('boot')) { + verifyVersion( + params.bootloaderVersion, + updateResult?.bootloaderVersion, + ); + } + if (params.targetsToUpdate.includes('coprocessor')) { + verifyVersion(params.bleVersion, updateResult?.bleVersion); + } + + if (versionMismatches.length > 0) { + throw new FirmwareUpdateVersionMismatchError(); + } + + return { message: 'success', ...updateResult }; + }, executionArtifacts); + } + async updatingFirmwareV3( params: IFirmwareUpdateV3VersionParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV3', + ); + const { executePreparedFirmwareUpdateV3 } = + await loadFirmwareUpdateRuntime(); + const currentTransportType = await this.getActiveTransportType(); const hardwareSDK = await this.getSDKInstance({ connectId: params.connectId, + hardwareTransportType: currentTransportType, }); return this.withFirmwareUpdateEvents(async () => { @@ -2351,24 +3153,29 @@ class ServiceFirmwareUpdate extends ServiceBase { const toBleVersion = convertVersion(params.bleVersion); const toBootloaderVersion = convertVersion(params.bootloaderVersion); const versionMismatches: string[] = []; + 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 updateResult = await convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV3( - deviceUtils.getUpdatingConnectId({ - connectId, - currentTransportType, - }), - { - platform: platformEnv.symbol ?? 'web', - bleVersion: toBleVersion, - firmwareVersion: toFirmwareVersion, - bootloaderVersion: toBootloaderVersion, - firmwareType: params.firmwareType, - }, - ), + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId, + currentTransportType, + }); + const updateResult = await convertDeviceResponse(() => + executePreparedFirmwareUpdateV3({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + platform: platformEnv.symbol ?? 'web', + bleVersion: toBleVersion, + firmwareVersion: toFirmwareVersion, + bootloaderVersion: toBootloaderVersion, + firmwareType: params.firmwareType, + }), ); // verify final version @@ -2393,10 +3200,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('.'), @@ -2415,7 +3224,7 @@ class ServiceFirmwareUpdate extends ServiceBase { console.log('updatingFirmwareV3 error: ', error); throw error; } - }); + }, executionArtifacts); } async validateShouldUpdateFullResource( @@ -2532,7 +3341,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/ServiceFirmwareUpdate/firewareUpdateFixtures.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/firewareUpdateFixtures.ts deleted file mode 100644 index 387be4f91833..000000000000 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/firewareUpdateFixtures.ts +++ /dev/null @@ -1,390 +0,0 @@ -export const FIRMWARE_UPDATE_UPDATE_INFO_SAMPLE = { - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - 'deviceType': 'touch', - 'deviceName': 'OneKey Touch', - 'deviceUUID': 'TC01WBD202207290844270000046', - 'hasUpgrade': true, - 'isBootloaderMode': false, - 'updateInfos': { - 'firmware': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '4.9.0', - 'toVersion': '4.9.1', - 'releasePayload': { - 'status': 'outdated', - 'changelog': [ - { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [4, 9, 1], - 'bootloaderResource': - 'https://web.onekey-asset.com/hardware/touch/bootloader/v2.4.9/bootloader.2.4.9-Stable-0807-3e033eb.signed.bin', - 'bootloaderVersion': [2, 4, 8], - 'fullResource': - 'https://web.onekey-asset.com/hardware/touch/resource/res-4.9.0.zip', - 'fullResourceRange': ['3.5.0', '4.0.0'], - 'resource': - 'https://web.onekey-asset.com/hardware/touch/resource/resource-4.0.0-4.9.0.zip', - 'url': - 'https://web.onekey-asset.com/hardware/touch/firmware/v4.9.0/touch.4.9.0-Stable-0410-fccbac8.signed.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - }, - 'bootloaderMode': false, - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [ - 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, - ], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - }, - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.', - }, - 'firmwareType': 'firmware', - }, - 'ble': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '2.1.0', - 'toVersion': '2.1.1', - 'releasePayload': { - 'status': 'outdated', - 'changelog': [ - { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [2, 1, 1], - 'webUpdate': - 'https://common.onekey-asset.com/hw/touch/2.1.0/touch_ble_signed-2022-1102_2.1.0.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - }, - 'bootloaderMode': false, - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [ - 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, - ], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - }, - 'changelog': { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - 'firmwareType': 'ble', - }, - 'bootloader': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '2.4.9', - 'toVersion': '2.4.8', - 'releasePayload': { - 'status': 'valid', - 'changelog': [ - { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [4, 9, 1], - 'bootloaderResource': - 'https://web.onekey-asset.com/hardware/touch/bootloader/v2.4.9/bootloader.2.4.9-Stable-0807-3e033eb.signed.bin', - 'bootloaderVersion': [2, 4, 8], - 'fullResource': - 'https://web.onekey-asset.com/hardware/touch/resource/res-4.9.0.zip', - 'fullResourceRange': ['3.5.0', '4.0.0'], - 'resource': - 'https://web.onekey-asset.com/hardware/touch/resource/resource-4.0.0-4.9.0.zip', - 'url': - 'https://web.onekey-asset.com/hardware/touch/firmware/v4.9.0/touch.4.9.0-Stable-0410-fccbac8.signed.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - }, - 'bootloaderMode': false, - 'shouldUpdate': false, - }, - 'changelog': { - 'zh-CN': '### 🐞 问题修复\r\n- xxxxx', - 'en-US': '### 🐞 Bug Fixes\r\n- xxxx\r\n', - }, - 'firmwareType': 'bootloader', - }, - 'bridge': { - 'shouldUpdate': false, - 'status': 'valid', - 'releaseVersion': '2.2.0', - }, - }, -} as const; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/firmwareUpdateConsts.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/firmwareUpdateConsts.ts index 5f95b695d4c3..00144892ce2d 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/firmwareUpdateConsts.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/firmwareUpdateConsts.ts @@ -17,6 +17,12 @@ export const FIRMWARE_UPDATE_MIN_VERSION_ALLOWED: Partial< // ble: '0.0.0', // bootloader: '0.0.0', }, + [EDeviceType.Pro2]: { + bootloader: '1.0.0', + }, + [EDeviceType.Neo]: { + bootloader: '1.0.0', + }, [EDeviceType.Touch]: { // >= 4.1.0 allowed update by App, < 4.1.0 only allowed update by web firmware: '4.1.0', // only 4.1.0 support bootloader update 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..eb00f8ffa071 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.pro2.test.ts @@ -0,0 +1,684 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import localDb from '../../dbs/local/localDb'; + +import { + DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE, + DeviceSettingsManager, + isDeviceSettingsAlreadyMatched, +} 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('../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getWalletDevice: jest.fn(), + getDeviceByQuery: jest.fn(), + getDevice: jest.fn(), + updateDevice: jest.fn(), + }, +})); + +jest.mock('@onekeyhq/shared/src/locale/appLocale', () => ({ + appLocale: { + intl: { formatMessage: ({ id }: { id: string }) => id }, + }, +})); + +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('isDeviceSettingsAlreadyMatched', () => { + test('detects the Protocol V2 already-matched payload', () => { + expect( + isDeviceSettingsAlreadyMatched({ + message: DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE, + }), + ).toBe(true); + }); + + test('ignores a real settings mutation', () => { + expect(isDeviceSettingsAlreadyMatched({ message: 'Success' })).toBe(false); + expect(isDeviceSettingsAlreadyMatched(undefined)).toBe(false); + expect(isDeviceSettingsAlreadyMatched(null)).toBe(false); + }); +}); + +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([ + [EDeviceType.Pro2, { pinType: DeviceSessionPinType.Any }], + [EDeviceType.Touch, {}], + ])( + 'uses the expected PIN policy when reading %s advanced settings', + async (deviceType, expectedPinParams) => { + const device = buildDevice(deviceType); + jest.spyOn(localDb, 'getWalletDevice').mockResolvedValue(device); + const unlockDevice = jest.fn(async () => undefined); + const backgroundApi = { + serviceHardware: { + unlockDevice, + getDeviceStateByWallet: jest.fn(async () => ({ + status: { passphraseProtection: true }, + })), + getDeviceSupportFeatures: jest.fn(async () => ({ + inputPinOnSoftware: { support: true }, + })), + }, + serviceHardwareUI: { + withHardwareProcessing: jest.fn( + async (action: () => Promise) => action(), + ), + }, + } as unknown as IBackgroundApi; + const manager = new DeviceSettingsManager({ backgroundApi }); + + await expect( + manager.getDeviceAdvanceSettings({ walletId: 'wallet-1' }), + ).resolves.toMatchObject({ + passphraseEnabled: true, + inputPinOnSoftwareSupport: true, + }); + expect(unlockDevice).toHaveBeenCalledWith({ + connectId: device.connectId, + ...expectedPinParams, + }); + }, + ); + + 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.Any, + 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('shows a success toast when Pro2 passphrase already matches the device', async () => { + const emit = jest.spyOn(appEventBus, 'emit'); + const deviceSettings = jest.fn(async () => ({ + success: true as const, + payload: { message: DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE }, + })); + const manager = buildManager(buildDevice(EDeviceType.Pro2), { + deviceSettings, + } as unknown as CoreApi); + + await expect( + manager.setPassphraseEnabled({ + connectId: 'PRO2_CONNECT_ID', + passphraseEnabled: true, + }), + ).resolves.toEqual({ message: DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE }); + + expect(emit).toHaveBeenCalledWith(EAppEventBusNames.ShowToast, { + method: 'success', + title: ETranslations.global_success, + }); + emit.mockRestore(); + }); + + test('does not toast when Pro2 passphrase actually changes on the device', async () => { + const emit = jest.spyOn(appEventBus, 'emit'); + 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(emit).not.toHaveBeenCalledWith( + EAppEventBusNames.ShowToast, + expect.objectContaining({ method: 'success' }), + ); + emit.mockRestore(); + }); + + test.each([ + ['passphrase', 'setPassphraseEnabled', { passphraseEnabled: true }], + ['auto lock', 'setAutoLockDelayMs', { autoLockDelayMs: 120_000 }], + [ + 'auto shutdown', + 'setAutoShutDownDelayMs', + { autoShutdownDelayMs: 300_000 }, + ], + ['language', 'setLanguage', { language: 'en-US' }], + ['brightness', 'setBrightness', { brightness: 80 }], + ['haptic feedback', 'setHapticFeedback', { hapticFeedback: true }], + [ + 'label', + 'setDeviceLabel', + { walletId: 'wallet-1', label: 'Current Label' }, + ], + ] as const)( + 'waits for the Pro2 DeviceState event after changing %s', + async (_settingName, methodName, params) => { + const device = buildDevice(EDeviceType.Pro2); + jest.spyOn(localDb, 'getDeviceByQuery').mockResolvedValue(device); + jest.spyOn(localDb, 'getWalletDevice').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); + + const method = manager[methodName] as ( + input: typeof params & { connectId: string }, + ) => Promise; + let completed = false; + const settingTask = method + .call(manager, { connectId: device.connectId, ...params }) + .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 and reads device settings back', + 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 getDeviceState = 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, + getDeviceState, + }, + 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, + }); + + // The V1 refresh path owns the HardwareFeaturesUpdate signal, so the + // direct DB write must suppress its own event. + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(localDb.updateDevice).toHaveBeenCalledWith({ + features: device.featuresInfo, + preciseUpdateFields, + skipFeaturesUpdateEvent: true, + }); + // V1 mutations now drain pending state-event persists and read the + // settings back before notifying UI consumers. + expect(waitForDeviceStateSync).toHaveBeenCalled(); + expect(getDeviceState).toHaveBeenCalledWith( + expect.objectContaining({ params: { scope: 'settings' } }), + ); + }, + ); + + 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.each([EDeviceType.Pro2, EDeviceType.Neo] as const)( + 'uploads a custom wallpaper to %s with generated Base64', + async (deviceType) => { + const device = buildDevice(deviceType); + jest.spyOn(localDb, 'getDevice').mockResolvedValue(device); + const waitForDeviceStateSync = jest.fn(async () => undefined); + 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), + waitForDeviceStateSync, + }, + 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: `${deviceType} custom wallpaper`, + resType: 'custom', + url: `https://example.com/${deviceType}-wallpaper.jpg`, + screenBase64: '/9j/', + }, + }); + + expect(deviceUploadWallpaper).toHaveBeenCalledWith(device.connectId, { + jpegBase64: '/9j/', + fileName: `${deviceType}-custom-wallpaper`, + }); + expect(waitForDeviceStateSync).toHaveBeenCalledWith({ + connectIds: expect.arrayContaining([device.connectId, device.deviceId]), + }); + 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..bd841dca31d5 100644 --- a/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts +++ b/packages/kit-bg/src/services/ServiceHardware/DeviceSettingsManager.ts @@ -1,4 +1,11 @@ -import { ResourceType, type Success } from '@onekeyfe/hd-transport'; +import { + type CoreApi, + type DeviceSettingsParams, + type DeviceSuccess, + type DeviceUploadResourceParams, + type DeviceUploadResourceResponse, +} from '@onekeyfe/hd-core'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; import { isNil } from 'lodash'; import { backgroundMethod } from '@onekeyhq/shared/src/background/backgroundDecorators'; @@ -8,8 +15,18 @@ import { } from '@onekeyhq/shared/src/errors'; import { convertDeviceResponse } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import { convertThirdPartyDeviceError } from '@onekeyhq/shared/src/errors/utils/thirdPartyDeviceErrorUtils'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { appLocale } from '@onekeyhq/shared/src/locale/appLocale'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; -import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { devOnlyData } from '@onekeyhq/shared/src/utils/devModeUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; +import { isAsciiAlphanumericWithSpaces } from '@onekeyhq/shared/src/utils/stringUtils'; +import thirdPartyDeviceUtils from '@onekeyhq/shared/src/utils/thirdPartyDeviceUtils'; +import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareCallContext, EHardwareVendor, @@ -24,7 +41,9 @@ import { getTrezorAdapterFromBackgroundApi, } from '../../vaults/base/trezorTransportUtils'; +import { getWallpaperResourceType } from './getWallpaperResourceType'; import { ServiceHardwareManagerBase } from './ServiceHardwareManagerBase'; +import serviceHardwareUtils from './serviceHardwareUtils'; import type { TrezorDeviceSettingsParams } from './adapters/types'; import type { @@ -32,12 +51,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,10 +80,29 @@ export type ISetHapticFeedbackParams = IBaseDeviceProcessingParams & { hapticFeedback: boolean; }; +export type ISetBrightnessParams = IBaseDeviceProcessingParams & { + brightness?: number; +}; + export type ISetPassphraseEnabledParams = IBaseDeviceProcessingParams & { passphraseEnabled: boolean; }; +// Matches hardware-js-sdk DeviceSettings when Protocol V2 already has the +// requested passphrase/air-gap value and skips the on-device settings page. +export const DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE = + 'Settings already match requested value.'; + +export function isDeviceSettingsAlreadyMatched(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + 'message' in result && + (result as { message?: unknown }).message === + DEVICE_SETTINGS_ALREADY_MATCHED_MESSAGE + ); +} + export type IWipeDeviceParams = IBaseDeviceProcessingParams; export type IGetDeviceAdvanceSettingsParams = { walletId: string }; @@ -89,6 +121,7 @@ export type IHardwareHomeScreenData = { url?: string; // preview image url nameHex?: string; // Pro、Touch: image name hex, only system res type screenHex?: string; // Classic、mini、1s、pure: image hex, only prebuilt res type + screenBase64?: string; // Pro2/Neo JPEG Base64 without a data URL prefix // software generated image thumbnailHex?: string; // Pro、Touch:thumb image hex by resize @@ -122,6 +155,10 @@ type IWithDeviceProcessingParams = { hardwareCallContext?: EHardwareCallContext; dbDevice?: IDBDevice; params?: IWithHardwareProcessingControlParams; + preciseUpdateFields?: Partial; + // Set for destructive V1 flows (e.g. wipe) whose aftermath is handled by + // their own teardown flow; a settings-sync refresh would only race it. + skipV1SettingsSyncNotify?: boolean; }; type ITrezorDeviceSettingsAction = (params: { @@ -130,6 +167,122 @@ type ITrezorDeviceSettingsAction = (params: { }) => Promise>>; export class DeviceSettingsManager extends ServiceHardwareManagerBase { + /** + * Protocol V1 settings mutations cannot rely on SDK DEVICE.STATE events + * alone: legacy SDKs emit nothing when the optimistic ApplySettings patch + * matches the SDK cache (same-value writes, on-device brightness), and + * events can be dropped by staleness/identity guards. After the mutation, + * drain the pending event persists, then explicitly read the settings back + * (a V1 GetFeatures round trip) and persist that snapshot, so device-side + * changes (e.g. a language changed on the device itself) always reach the + * DB. The whole sync is bounded: a device that dropped off right after the + * write would otherwise hold the flow for the SDK's 60s timeout, and a + * stuck event queue must never block the final UI refresh signal. + */ + private async _notifyProtocolV1SettingsSynced({ + device, + compatibleConnectId, + }: { + device: IDBDevice; + compatibleConnectId?: string; + }) { + let timeoutId: ReturnType | undefined; + const timeoutGuard = new Promise((resolve) => { + timeoutId = setTimeout( + resolve, + timerUtils.getTimeDurationMs({ seconds: 8 }), + ); + }); + try { + await Promise.race([ + this._syncProtocolV1SettingsSnapshot({ device, compatibleConnectId }), + timeoutGuard, + ]); + } catch (error) { + // The read-back is best-effort; the mutation itself already succeeded. + serviceHardwareUtils.hardwareLog( + 'v1 settings read-back failed', + devOnlyData(error instanceof Error ? error.message : error), + ); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + // Consumers re-read whatever the DB holds; this signal must fire on + // every path, especially when updateDevice suppressed its own event + // via skipFeaturesUpdateEvent. Subscribers run synchronously in the + // same heap on desktop/web, so a throwing subscriber must not fail + // the already-successful mutation (or leak as an unhandled rejection + // from fire-and-forget callers). + try { + appEventBus.emit(EAppEventBusNames.HardwareFeaturesUpdate, { + deviceId: device.id, + }); + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'v1 settings refresh subscriber failed', + devOnlyData(error instanceof Error ? error.message : error), + ); + } + } + } + + private async _syncProtocolV1SettingsSnapshot({ + device, + compatibleConnectId, + }: { + device: IDBDevice; + compatibleConnectId?: string; + }) { + const syncConnectIds = [ + compatibleConnectId, + device.connectId, + device.usbConnectId, + device.bleConnectId, + device.uuid, + device.deviceId, + device.deviceStateInfo?.identity.serialNo, + device.deviceStateInfo?.identity.deviceId, + ]; + await this.serviceHardware.waitForDeviceStateSync({ + connectIds: syncConnectIds, + }); + const connectId = compatibleConnectId || device.connectId; + if (!connectId) { + return; + } + const state = await this.serviceHardware.getDeviceState({ + connectId, + params: { scope: 'settings' }, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + silentMode: true, + }); + if (!state) { + return; + } + // The snapshot also carries status fields written by V1 settings (e.g. + // passphraseProtection after setPassphraseEnabled); persist both + // sections, not just settings. + const persistResult = await localDb.updateDeviceState({ + changedKeys: ['settings', 'status'], + connectId, + revision: state.revision, + source: 'settings-read', + state, + }); + // The read-back GetFeatures may itself have emitted a DEVICE.STATE event + // whose persistence task was queued after the first drain; drain again so + // the refresh signal only fires once the authoritative state is in the DB. + await this.serviceHardware.waitForDeviceStateSync({ + connectIds: syncConnectIds, + }); + serviceHardwareUtils.hardwareLog('v1 settings read-back', { + kind: persistResult.kind, + language: state.settings?.language, + revision: state.revision, + }); + } + private async _getDeviceForSettings({ walletId, connectId, @@ -162,6 +315,31 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { ); } + private _isProtocolV2Product(device: IDBDevice | undefined): boolean { + return isProtocolV2ProductType(device?.deviceType); + } + + private async _waitForProtocolV2SettingsSync({ + device, + compatibleConnectId, + }: { + device: IDBDevice; + compatibleConnectId: string; + }) { + await this.serviceHardware.waitForDeviceStateSync({ + connectIds: [ + compatibleConnectId, + device.connectId, + device.usbConnectId, + device.bleConnectId, + device.uuid, + device.deviceId, + device.deviceStateInfo?.identity.serialNo, + device.deviceStateInfo?.identity.deviceId, + ], + }); + } + private async _withTrezorDeviceProcessing({ walletId, connectId, @@ -182,7 +360,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { > & { action: ITrezorDeviceSettingsAction; preciseUpdateFields?: Partial; - }): Promise { + }): Promise { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -240,7 +418,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { > & { settings: TrezorDeviceSettingsParams; preciseUpdateFields?: Partial; - }): Promise { + }): Promise { return this._withTrezorDeviceProcessing({ walletId, connectId, @@ -269,6 +447,8 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { debugMethodName, action, params, + preciseUpdateFields, + skipV1SettingsSyncNotify, }: IWithDeviceProcessingParams & { action: ( hardwareSDK: CoreApi, @@ -276,21 +456,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 +474,34 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const hardwareSDK = await this.getSDKInstance({ connectId: compatibleConnectId, }); - return convertDeviceResponse(() => + const result = await convertDeviceResponse(() => action(hardwareSDK, compatibleConnectId, device), ); + if (this._isProtocolV2Product(device)) { + await this._waitForProtocolV2SettingsSync({ + device, + compatibleConnectId, + }); + } else { + const shouldNotifySettingsSynced = !skipV1SettingsSyncNotify; + if (preciseUpdateFields && device.featuresInfo) { + await localDb.updateDevice({ + features: device.featuresInfo, + preciseUpdateFields, + // When the authoritative notify below fires (after the SDK's + // settings-read event persisted), emitting here as well would + // trigger a redundant refresh that can read the DB too early. + skipFeaturesUpdateEvent: shouldNotifySettingsSynced, + }); + } + if (shouldNotifySettingsSynced) { + await this._notifyProtocolV1SettingsSynced({ + device, + compatibleConnectId, + }); + } + } + return result; }, { deviceParams: { @@ -324,7 +520,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { connectId, featuresDeviceId, remove, - }: IChangePinParams): Promise { + }: IChangePinParams): Promise { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -391,10 +587,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 +599,20 @@ 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 + // Protocol V2 exposes PIN selection, and non-sensitive settings may + // be unlocked by either the main PIN or an Attach PIN. Protocol V1 + // has no PIN type parameter, so omit it and preserve the device-defined + // legacy unlock behavior. await this.serviceHardware.unlockDevice({ connectId: dbDevice.connectId, + ...(this._isProtocolV2Product(dbDevice) + ? { pinType: DeviceSessionPinType.Any } + : {}), }); - const features = await this.serviceHardware.getFeaturesByWallet({ + const state = await this.serviceHardware.getDeviceStateByWallet({ walletId, + params: { scope: 'settings' }, }); const supportFeatures = await this.serviceHardware.getDeviceSupportFeatures( @@ -417,7 +621,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 +648,30 @@ 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, + // Protocol V2 settings accept the main PIN or an Attach PIN. + // Protocol V1 does not define PIN types, so leave the parameter + // absent and let the legacy device command handle the unlock. + ...(this._isProtocolV2Product(device) + ? { pinType: DeviceSessionPinType.Any } + : {}), + 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 +685,14 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { @backgroundMethod() async setDeviceLabel({ walletId, label }: ISetDeviceLabelParams) { const device = await localDb.getWalletDevice({ walletId }); + if ( + this._isProtocolV2Product(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 +702,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() @@ -510,6 +722,7 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const { nameHex, screenHex, + screenBase64, thumbnailHex, blurScreenHex, resType, @@ -527,62 +740,102 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { const finallyScreenHex = screenHex || nameHex || ''; const finallyThumbnailHex: string | undefined = thumbnailHex; - return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( - async () => { - // pro touch custom upload wallpaper - if (needUploadResource) { - if (!finallyThumbnailHex) { - throw new OneKeyLocalError( - 'Upload screen item error: thumbnailHex not defined', - ); - } + const result: DeviceUploadResourceResponse = + await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( + async () => { + // pro touch custom upload wallpaper + if (needUploadResource) { + if (this._isProtocolV2Product(device)) { + if (!screenBase64) { + throw new OneKeyLocalError( + 'Upload Pro2 wallpaper error: screenBase64 not defined', + ); + } + 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, { + jpegBase64: screenBase64, + fileName: screenItem.id.replace(/[^A-Za-z0-9_-]/g, '-'), + }), + ); + await this._waitForProtocolV2SettingsSync({ + device, + compatibleConnectId, + }); + return { + ...response, + message: response.message ?? 'Success', + applyScreen: true, + }; + } + if (!finallyThumbnailHex) { + throw new OneKeyLocalError( + 'Upload screen item error: thumbnailHex not defined', + ); + } - const compatibleConnectId = - await this.serviceHardware.getCompatibleConnectId({ - connectId: device.connectId, - featuresDeviceId: device.deviceId, - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + const compatibleConnectId = + await this.serviceHardware.getCompatibleConnectId({ + connectId: device.connectId, + featuresDeviceId: device.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, }); - const hardwareSDK = await this.getSDKInstance({ - connectId: compatibleConnectId, + const uploadResParams: DeviceUploadResourceParams = { + resType: getWallpaperResourceType(), + suffix: 'jpeg', + dataHex: finallyScreenHex, + thumbnailDataHex: finallyThumbnailHex, + blurDataHex: blurScreenHex ?? '', + nftMetaData: '', + }; + // upload wallpaper resource will automatically set the home screen + return convertDeviceResponse(() => + hardwareSDK.deviceUploadResource( + compatibleConnectId, + uploadResParams, + ), + ); + } + // Pro、Touch: built-in wallpaper + // Classic、mini、1s、pure: custom upload and built-in wallpaper + if (!finallyScreenHex && !isMonochrome) { + // empty string will clear the home screen(classic,mini) + throw new OneKeyLocalError('Invalid home screen hex'); + } + const response = await this.applySettingsToDevice(device.connectId, { + homescreen: finallyScreenHex, }); - const uploadResParams: DeviceUploadResourceParams = { - resType: ResourceType.WallPaper, - suffix: 'jpeg', - dataHex: finallyScreenHex, - thumbnailDataHex: finallyThumbnailHex, - blurDataHex: blurScreenHex ?? '', - nftMetaData: '', + return { + ...response, + applyScreen: true, }; - // upload wallpaper resource will automatically set the home screen - return convertDeviceResponse(() => - hardwareSDK.deviceUploadResource( - compatibleConnectId, - uploadResParams, - ), - ); - } - // Pro、Touch: built-in wallpaper - // Classic、mini、1s、pure: custom upload and built-in wallpaper - if (!finallyScreenHex && !isMonochrome) { - // empty string will clear the home screen(classic,mini) - throw new OneKeyLocalError('Invalid home screen hex'); - } - const response = await this.applySettingsToDevice(device.connectId, { - homescreen: finallyScreenHex, - }); - return { - ...response, - applyScreen: true, - }; - }, - { - deviceParams: { - dbDevice: device, }, - debugMethodName: 'deviceSettings.applySettingsToDevice', - }, - ); + { + deviceParams: { + dbDevice: device, + }, + debugMethodName: 'deviceSettings.applySettingsToDevice', + }, + ); + if (!this._isProtocolV2Product(device) && !this._isTrezorDevice(device)) { + // Fire-and-forget: the wallpaper is already applied and the processing + // dialog closed; the caller must not stay pending on the read-back. + void this._notifyProtocolV1SettingsSynced({ device }).catch( + () => undefined, + ); + } + return result; } @backgroundMethod() @@ -597,42 +850,45 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { connectId, featuresDeviceId, }); - if (this._isTrezorDevice(device)) { - return this._applyTrezorSettings({ - walletId, - connectId, - featuresDeviceId, - dbDevice: device, - debugMethodName: 'deviceSettings.setPassphraseEnabled.trezor', - settings: { use_passphrase: passphraseEnabled }, - preciseUpdateFields: { - passphrase_protection: passphraseEnabled, - }, + const result = this._isTrezorDevice(device) + ? await this._applyTrezorSettings({ + walletId, + connectId, + featuresDeviceId, + dbDevice: device, + debugMethodName: 'deviceSettings.setPassphraseEnabled.trezor', + settings: { use_passphrase: passphraseEnabled }, + preciseUpdateFields: { + passphrase_protection: passphraseEnabled, + }, + }) + : await this._withDeviceProcessing({ + walletId, + connectId, + featuresDeviceId, + dbDevice: device, + debugMethodName: 'deviceSettings.setPassphraseEnabled', + preciseUpdateFields: { + passphrase_protection: passphraseEnabled, + }, + action: async (sdk, compatibleConnectId) => + sdk.deviceSettings(compatibleConnectId, { + usePassphrase: passphraseEnabled, + }), + }); + // Protocol V2 returns immediately when the device already has this + // passphrase value (common after a Pass PIN unlock, when the app still + // thinks passphrase is off). There is no on-device confirm page, so surface + // the no-op as a success toast. + if (isDeviceSettingsAlreadyMatched(result)) { + appEventBus.emit(EAppEventBusNames.ShowToast, { + method: 'success', + title: appLocale.intl.formatMessage({ + id: ETranslations.global_success, + }), }); } - return this._withDeviceProcessing({ - walletId, - connectId, - 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; - }), - }); + return result; } @backgroundMethod() @@ -666,22 +922,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 +953,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 +994,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 +1009,8 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { walletId, connectId, featuresDeviceId, - }: IBaseDeviceProcessingParams) { + brightness, + }: ISetBrightnessParams) { const device = await this._getDeviceForSettings({ walletId, connectId, @@ -813,9 +1043,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 +1083,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, + }), }); } @@ -906,6 +1130,9 @@ export class DeviceSettingsManager extends ServiceHardwareManagerBase { featuresDeviceId, dbDevice: device, debugMethodName: 'deviceSettings.wipeDevice', + // Wipe teardown (wallet removal) drives its own UI updates; a + // settings-sync refresh here would race the removal flow. + skipV1SettingsSyncNotify: true, action: async (sdk, compatibleConnectId, targetDevice) => { const response = await sdk.deviceWipe(compatibleConnectId); if ( 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..631fd19885c5 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,15 +57,42 @@ 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'; + } + } + + async getTransportTypeForChannel({ + transportType, + connectProtocol, + }: { + transportType: 'usb' | 'ble'; + connectProtocol?: HardwareConnectProtocol; + }): Promise { + if (transportType === 'ble') { + return platformEnv.isSupportDesktopBle + ? EHardwareTransportType.DesktopWebBle + : EHardwareTransportType.BLE; } + + const mode = await this.getDesktopUsbSetting(connectProtocol); + return mode === 'bridge' + ? EHardwareTransportType.Bridge + : EHardwareTransportType.WEBUSB; } private async requestBluetoothPermission(): Promise { @@ -107,7 +139,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; @@ -117,7 +149,12 @@ export class HardwareConnectionManager { const isOneKey = ONEKEY_WEBUSB_FILTER?.some( (d) => dev?.vendorId === d.vendorId && dev?.productId === d.productId, ); - return isOneKey; + // The SDK uses serialNumber as the WebUSB device path. Authorized + // devices without one cannot be acquired by the transport. + const hasSerialNumber = + typeof dev?.serialNumber === 'string' && + dev.serialNumber.trim().length > 0; + return isOneKey && hasSerialNumber; }); return onekeyDevices.length > 0; } catch { @@ -125,7 +162,7 @@ export class HardwareConnectionManager { } } - async detectBridgeAvailability(): Promise { + async detectBridgeAvailability(_connectId?: string): Promise { if (!platformEnv.isSupportDesktopBle) { return true; } @@ -139,22 +176,27 @@ 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; + } + return devices.length > 0; } 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 +326,24 @@ 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); + if (hardwareCallContext === EHardwareCallContext.UPDATE_FIRMWARE) { + return mode === 'bridge' + ? EHardwareTransportType.Bridge + : EHardwareTransportType.WEBUSB; + } + const usbAvailable = await this.detectUSBDeviceAvailability( + connectId, + connectProtocol, + ); + if (usbAvailable) { return mode === 'bridge' ? EHardwareTransportType.Bridge : EHardwareTransportType.WEBUSB; @@ -315,9 +367,11 @@ export class HardwareConnectionManager { shouldSwitchTransportType = memoizee( async ({ connectId, + connectProtocol, hardwareCallContext, }: { connectId?: string; + connectProtocol?: HardwareConnectProtocol; hardwareCallContext?: IHardwareCallContext; }): Promise<{ shouldSwitch: boolean; @@ -328,27 +382,41 @@ export class HardwareConnectionManager { await hardwareForceTransportAtom.get(); const forceTransportType = hardwareForceTransportAtomState.forceTransportType; + const normalizedForceTransportType = forceTransportType + ? deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: forceTransportType, + connectProtocol, + }) + : undefined; + + // quick detect mini device + const isMiniDevice = connectId && connectId.startsWith('MI'); // If a specific transport type is forced (e.g., for onboarding), use it directly - if (forceTransportType) { - const shouldSwitch = this.actualTransportType !== forceTransportType; + if ( + normalizedForceTransportType && + (!isMiniDevice || + normalizedForceTransportType === EHardwareTransportType.WEBUSB || + normalizedForceTransportType === EHardwareTransportType.Bridge) + ) { + const shouldSwitch = + this.actualTransportType !== normalizedForceTransportType; return { shouldSwitch, - targetType: forceTransportType, + targetType: normalizedForceTransportType, }; } - // 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 +424,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 +441,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,22 +464,40 @@ 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].connectProtocol || '', + args[0].connectId?.startsWith('MI') ? 'mini' : 'other', + ]), }, ); + async resolveTransportType(params: { + connectId?: string; + connectProtocol?: HardwareConnectProtocol; + hardwareCallContext?: IHardwareCallContext; + }): Promise<{ + shouldSwitch: boolean; + targetType: EHardwareTransportType; + }> { + const result = await this.shouldSwitchTransportType(params); + await this.setCurrentTransportType(result.targetType); + return result; + } + async getCurrentTransportType(): Promise { const currentTransportType = await this.backgroundApi.serviceSetting.getHardwareTransportType(); return this.actualTransportType || currentTransportType; } - setCurrentTransportType(transportType: EHardwareTransportType): void { + async setCurrentTransportType( + transportType: EHardwareTransportType, + ): Promise { // Only clear cache when transport type actually changes if (this.actualTransportType !== transportType) { - void this.backgroundApi.serviceSetting.setHardwareTransportType( - transportType, - ); + // 先更新运行时状态,避免持久化期间的并发调用继续使用旧传输。 this.actualTransportType = transportType; // Clear cache when transport type changes to ensure fresh detection try { @@ -415,6 +505,14 @@ export class HardwareConnectionManager { } catch { // Ignore cache clear errors } + if ( + typeof this.backgroundApi.serviceSetting?.setHardwareTransportType === + 'function' + ) { + await this.backgroundApi.serviceSetting.setHardwareTransportType( + transportType, + ); + } } } } diff --git a/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts b/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts index 365b2d9156d7..4b1ed389706e 100644 --- a/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts +++ b/packages/kit-bg/src/services/ServiceHardware/HardwareVerifyManager.ts @@ -53,7 +53,81 @@ export type IFirmwareAuthenticateParams = { const deviceCheckingCodes = new Set([10_104, 10_105, 10_106, 10_107]); +type FirmwareVerifyPayload = { + data: string; + dataHex: string; +}; + +function getFirmwareVerifyPayload({ + instanceId, +}: { + instanceId: string; +}): FirmwareVerifyPayload { + // Same challenge as Pro/Classic: wallet splits `data` on '_' and requires + // a UUID v4 instanceId. Device gets the UTF-8 bytes; Pro2/Neo firmware + // must accept this variable-length message the same way Pro does. + const data = `${instanceId}_${Date.now()}_${stringUtils.randomString(12)}`; + return { + data, + dataHex: bufferUtils.textToHex(data, 'utf-8'), + }; +} + +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 +153,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 +182,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', @@ -109,12 +193,10 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { } return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( async () => { - const ts = Date.now(); const settings = await settingsPersistAtom.get(); - const data = `${settings.instanceId}_${ts}_${stringUtils.randomString( - 12, - )}`; - const dataHex = bufferUtils.textToHex(data, 'utf-8'); + const { data, dataHex } = getFirmwareVerifyPayload({ + instanceId: settings.instanceId, + }); const verifySig: DeviceVerifySignature = // call sdk.deviceVerify() await this.getDeviceCertWithSig({ @@ -204,12 +286,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 +350,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 +400,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 +419,8 @@ export class HardwareVerifyManager extends ServiceHardwareManagerBase { } const verifyVersions = - await deviceUtils.getDeviceVerifyVersionsFromFeatures({ - features: onekeyFeatures, + await deviceUtils.getDeviceVerifyVersionsFromRawOnekeyFeatures({ + onekeyFeatures, deviceType, }); if (!verifyVersions) { @@ -370,7 +461,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..6b6fe37f8081 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts @@ -0,0 +1,1090 @@ +import { + checkBLEPermissions, + checkBLEState, +} from '@onekeyhq/shared/src/hardware/blePermissions'; +import * as hardwareInstance from '@onekeyhq/shared/src/hardware/instance'; +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 { hardwareForceTransportAtom } from '../../states/jotai/atoms'; + +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/hardware/instance', () => ({ + CoreSDKLoader: jest.fn(async () => ({})), + getHardwareSDKInstance: jest.fn(), + resetHardwareSDKInstance: 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: { + get: jest.fn(async () => ({})), + }, +})); + +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); +const mockedHardwareForceTransportAtomGet = jest.mocked( + hardwareForceTransportAtom.get, +); +let appStatusData: ISimpleDBAppStatus; + +function buildDevice({ + features, + connectProtocol, + deviceType = 'pro', +}: { + features?: Features; + connectProtocol?: 'V1' | 'V2'; + deviceType?: 'pro' | 'pro2' | 'neo'; +}) { + return { + connectId: 'USB_SERIAL', + uuid: 'DEVICE_SERIAL', + deviceId: 'DEVICE_ID', + deviceType, + 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); + mockedHardwareForceTransportAtomGet.mockResolvedValue({ + forceTransportType: undefined, + }); + }); + + 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.each(['pro2', 'neo'] as const)( + 'force-refreshes %s after update instead of reusing WebUSB loader state', + async (deviceType) => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('USB_SERIAL'); + const freshFeatures = { + protocol: 'V2', + deviceType, + deviceId: 'FRESH_DEVICE_ID', + bootloaderMode: false, + } as unknown as Features; + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue(freshFeatures); + const cachedLoaderFeatures = { + protocol: 'V2', + deviceType, + bootloaderMode: true, + } as unknown as Features; + + await expect( + service.connect({ + device: buildDevice({ + features: cachedLoaderFeatures, + connectProtocol: 'V2', + deviceType, + }), + forceFeaturesRefresh: true, + }), + ).resolves.toBe(freshFeatures); + 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: {}, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + it('桌面 BLE 候选即使携带 features 也必须真实连接验证', async () => { + mutablePlatformEnv.isSupportDesktopBle = true; + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ deviceId: 'PRO2_DEVICE_ID' } as Features); + const blePeripheralId = 'f7e440001d2c1c79509d55dfdc8201ff'; + + await service.connect({ + device: { + connectId: blePeripheralId, + uuid: blePeripheralId, + deviceId: null, + deviceType: 'pro2', + name: 'Pro 2 0088', + commType: 'electron-ble', + features: { deviceId: 'PRO2_DEVICE_ID' }, + } as SearchDevice, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + expect(connectDevice).toHaveBeenCalledWith({ + connectId: blePeripheralId, + params: {}, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + 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 }, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + 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 }, + hardwareTransportType: EHardwareTransportType.BLE, + }); + }, + ); + + it('uses the explicit Android USB transport without running stale BLE prechecks', async () => { + mutablePlatformEnv.isNative = true; + mutablePlatformEnv.isNativeAndroid = true; + mockedCheckBLEPermissions.mockResolvedValue(false); + + 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 expect( + service.connect({ + device: buildDevice({ connectProtocol: 'V1' }), + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).resolves.toEqual({ label: 'OneKey' }); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(connectDevice).toHaveBeenCalledWith({ + connectId: 'USB_SERIAL', + params: { connectProtocol: 'V1' }, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + }); + + 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('桌面 BLE 初始化不执行 Bridge fallback', async () => { + const checkBridgeStatus = jest + .fn() + .mockRejectedValue(new Error('Bridge is unavailable')); + const switchTransport = jest.fn(); + const sdkInstance = { checkBridgeStatus, switchTransport }; + jest + .mocked(hardwareInstance.getHardwareSDKInstance) + .mockResolvedValue(sdkInstance as never); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + setHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.registerSdkEvents = jest.fn(); + jest + .spyOn(service.connectionManager, 'getCurrentTransportType') + .mockResolvedValue(EHardwareTransportType.DesktopWebBle); + jest + .spyOn(service.connectionManager, 'setCurrentTransportType') + .mockResolvedValue(undefined); + + await service.getSDKInstance({ + connectId: undefined, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + expect(checkBridgeStatus).not.toHaveBeenCalled(); + expect(switchTransport).not.toHaveBeenCalled(); + }); + + it('串行执行不同 transport 的 SDK 生命周期切换', async () => { + let resolveFirstInstance: ((value: object) => void) | undefined; + const firstInstancePromise = new Promise((resolve) => { + resolveFirstInstance = resolve; + }); + const firstInstance = { name: 'webusb-sdk' }; + const secondInstance = { name: 'desktop-ble-sdk' }; + const getHardwareSDKInstance = jest.mocked( + hardwareInstance.getHardwareSDKInstance, + ); + getHardwareSDKInstance + .mockReturnValueOnce(firstInstancePromise as never) + .mockResolvedValueOnce(secondInstance as never); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.registerSdkEvents = jest.fn(); + jest + .spyOn(service.connectionManager, 'getCurrentTransportType') + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn(service.connectionManager, 'setCurrentTransportType') + .mockResolvedValue(undefined); + + const firstCall = service.getSDKInstance({ + connectId: undefined, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } + const secondCall = service.getSDKInstance({ + connectId: undefined, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } + + expect(getHardwareSDKInstance).toHaveBeenCalledTimes(1); + expect(hardwareInstance.resetHardwareSDKInstance).not.toHaveBeenCalled(); + + resolveFirstInstance?.(firstInstance); + await expect(firstCall).resolves.toBe(firstInstance); + await expect(secondCall).resolves.toBe(secondInstance); + + expect(getHardwareSDKInstance).toHaveBeenCalledTimes(2); + expect(hardwareInstance.resetHardwareSDKInstance).toHaveBeenCalledTimes(1); + }); + + it('手动 reset 等待正在初始化的 SDK 生命周期完成', async () => { + let resolveInstance: ((value: object) => void) | undefined; + const instancePromise = new Promise((resolve) => { + resolveInstance = resolve; + }); + const sdkInstance = { name: 'initializing-sdk' }; + jest + .mocked(hardwareInstance.getHardwareSDKInstance) + .mockReturnValueOnce(instancePromise as never); + const runExclusiveOneKeyOperation = jest.fn( + async (operation: () => Promise) => operation(), + ); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceHardwareUI: { + runExclusiveOneKeyOperation, + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.registerSdkEvents = jest.fn(); + jest + .spyOn(service.connectionManager, 'getCurrentTransportType') + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn(service.connectionManager, 'setCurrentTransportType') + .mockResolvedValue(undefined); + + const initialization = service.getSDKInstance({ + connectId: undefined, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } + const reset = service.resetHardwareSDK(); + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } + + expect(hardwareInstance.resetHardwareSDKInstance).not.toHaveBeenCalled(); + + resolveInstance?.(sdkInstance); + await expect(initialization).resolves.toBe(sdkInstance); + await expect(reset).resolves.toBeUndefined(); + + expect(runExclusiveOneKeyOperation).toHaveBeenCalledTimes(1); + expect(hardwareInstance.resetHardwareSDKInstance).toHaveBeenCalledTimes(1); + }); + + it('显式 transport 不会绕过固件流程的 force transport 锁', async () => { + mockedHardwareForceTransportAtomGet.mockResolvedValue({ + forceTransportType: EHardwareTransportType.WEBUSB, + }); + const sdkInstance = { name: 'forced-webusb-sdk' }; + jest + .mocked(hardwareInstance.getHardwareSDKInstance) + .mockResolvedValue(sdkInstance as never); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.registerSdkEvents = jest.fn(); + jest + .spyOn(service.connectionManager, 'getCurrentTransportType') + .mockResolvedValue(EHardwareTransportType.WEBUSB); + const setCurrentTransportType = jest + .spyOn(service.connectionManager, 'setCurrentTransportType') + .mockResolvedValue(undefined); + const internals = service as unknown as { + activeHardwareTransportType: EHardwareTransportType; + }; + internals.activeHardwareTransportType = EHardwareTransportType.WEBUSB; + + await expect( + service.getSDKInstance({ + connectId: undefined, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }), + ).resolves.toBe(sdkInstance); + + expect(hardwareInstance.getHardwareSDKInstance).toHaveBeenCalledWith( + expect.objectContaining({ + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ); + expect(setCurrentTransportType).toHaveBeenCalledWith( + EHardwareTransportType.WEBUSB, + ); + expect(hardwareInstance.resetHardwareSDKInstance).not.toHaveBeenCalled(); + }); + + it('桌面后台显式 transport 优先于遗留的 BLE force transport', async () => { + mutablePlatformEnv.isSupportDesktopBle = true; + mockedHardwareForceTransportAtomGet.mockResolvedValue({ + forceTransportType: EHardwareTransportType.DesktopWebBle, + }); + const sdkInstance = { name: 'active-webusb-sdk' }; + jest + .mocked(hardwareInstance.getHardwareSDKInstance) + .mockResolvedValue(sdkInstance as never); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.registerSdkEvents = jest.fn(); + jest + .spyOn(service.connectionManager, 'getCurrentTransportType') + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn(service.connectionManager, 'shouldSwitchTransportType') + .mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.WEBUSB, + }); + const setCurrentTransportType = jest + .spyOn(service.connectionManager, 'setCurrentTransportType') + .mockResolvedValue(undefined); + const internals = service as unknown as { + activeHardwareTransportType: EHardwareTransportType; + }; + internals.activeHardwareTransportType = EHardwareTransportType.WEBUSB; + + await expect( + service.getSDKInstance({ + connectId: undefined, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).resolves.toBe(sdkInstance); + + expect(hardwareInstance.getHardwareSDKInstance).toHaveBeenCalledWith( + expect.objectContaining({ + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ); + expect(setCurrentTransportType).toHaveBeenCalledWith( + EHardwareTransportType.WEBUSB, + ); + expect(hardwareInstance.resetHardwareSDKInstance).not.toHaveBeenCalled(); + }); + + 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..2778aeb9f0bc 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts @@ -1,3 +1,7 @@ +import { HardwareErrorCode } from '@onekeyfe/hd-shared'; +import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; +import axios from 'axios'; + import { EAppEventBusNames, appEventBus, @@ -6,20 +10,29 @@ import { checkBLEPermissions, checkBLEState, } from '@onekeyhq/shared/src/hardware/blePermissions'; +import { + getHardwareSDKInstance, + resetHardwareSDKInstance, +} from '@onekeyhq/shared/src/hardware/instance'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { EHardwareCallContext, EHardwareVendor, } from '@onekeyhq/shared/types/device'; +import type { IOneKeyDeviceFeaturesWithAppParams } from '@onekeyhq/shared/types/device'; import localDb from '../../dbs/local/localDb'; +import { hardwareForceTransportAtom } from '../../states/jotai/atoms'; +import { hardwareForceTransportAtom as desktopHardwareForceTransportAtom } from '../../states/jotai/atoms/desktopBluetooth'; +import { getFirmwareManifestSnapshot } from '../ServiceFirmwareUpdate/FirmwareManifestProvider'; import { HardwareConnectionManager } from './HardwareConnectionManager'; import ServiceHardware from './ServiceHardware'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IDBDevice } from '../../dbs/local/types'; +import type { RemoteConfigResponse, SearchDevice } from '@onekeyfe/hd-core'; jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ backgroundClass: () => (target: unknown) => target, @@ -63,6 +76,19 @@ 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/instance', () => ({ + CoreSDKLoader: jest.fn(), + getHardwareSDKInstance: jest.fn(), + resetHardwareSDKInstance: jest.fn(), +})); + jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ __esModule: true, DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, @@ -74,6 +100,17 @@ jest.mock('../../dbs/local/localDb', () => ({ __esModule: true, default: { getDeviceByQuery: jest.fn(), + updateDeviceConnectId: jest.fn(), + }, +})); + +jest.mock('../../dbs/simple/simpleDb', () => ({ + __esModule: true, + default: { + appStatus: { + getRawData: jest.fn().mockResolvedValue({}), + setRawData: jest.fn().mockResolvedValue({}), + }, }, })); @@ -88,13 +125,36 @@ jest.mock('../../states/jotai/atoms', () => ({ }, hardwareUiStateAtom: {}, hardwareUiStateCompletedAtom: {}, - settingsPersistAtom: {}, + settingsPersistAtom: { + get: jest.fn(async () => ({ hardwareConnectSrc: undefined })), + }, +})); + +jest.mock('../ServiceFirmwareUpdate/FirmwareManifestProvider', () => ({ + getFirmwareManifestSnapshot: jest.fn(), +})); + +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 mockedAxios = jest.mocked(axios); const mockedAppEventBus = jest.mocked(appEventBus); +const mockedGetFirmwareManifestSnapshot = jest.mocked( + getFirmwareManifestSnapshot, +); +const mockedGetHardwareSDKInstance = jest.mocked(getHardwareSDKInstance); +const mockedResetHardwareSDKInstance = jest.mocked(resetHardwareSDKInstance); const mutablePlatformEnv = platformEnv as unknown as { isDesktop: boolean; isJest: boolean; @@ -117,17 +177,81 @@ describe('ServiceHardware.getCompatibleConnectId', () => { mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); mockedCheckBLEPermissions.mockResolvedValue(true); mockedCheckBLEState.mockResolvedValue(true); + mockedAxios.post.mockReset(); + jest.mocked(hardwareForceTransportAtom.get).mockResolvedValue({ + forceTransportType: undefined, + }); + jest.mocked(desktopHardwareForceTransportAtom.get).mockResolvedValue({ + forceTransportType: undefined, + }); }); - it('uses a bound Trezor BLE connectId when desktop BLE is selected', async () => { + 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: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.BLE), + }, + } 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-device-1', - connectId: 'USB_ID', - usbConnectId: 'USB_ID', - bleConnectId: 'BLE_ID', - deviceId: 'FEATURES_DEVICE_ID', - vendor: EHardwareVendor.trezor, - name: 'Trezor Safe 7', + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', features: '{}', settingsRaw: '{}', createdAt: 0, @@ -135,13 +259,9 @@ describe('ServiceHardware.getCompatibleConnectId', () => { } as IDBDevice); const service = new ServiceHardware({ - backgroundApi: { - serviceSetting: { - getHardwareTransportType: jest.fn(), - }, - } as unknown as IBackgroundApi, + backgroundApi: {} as unknown as IBackgroundApi, }); - const shouldSwitchTransportTypeMock = Object.assign( + service.connectionManager.shouldSwitchTransportType = Object.assign( jest.fn().mockResolvedValue({ shouldSwitch: true, targetType: EHardwareTransportType.DesktopWebBle, @@ -150,214 +270,2434 @@ describe('ServiceHardware.getCompatibleConnectId', () => { clear: jest.fn(), delete: jest.fn(), }, - ); - service.connectionManager.shouldSwitchTransportType = - shouldSwitchTransportTypeMock as typeof service.connectionManager.shouldSwitchTransportType; + ) as typeof service.connectionManager.shouldSwitchTransportType; await expect( service.getCompatibleConnectId({ - connectId: 'USB_ID', - featuresDeviceId: 'FEATURES_DEVICE_ID', + connectId: 'PRB09B0088A', + featuresDeviceId: 'STALE_DEVICE_ID', hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }), - ).resolves.toBe('BLE_ID'); + ).resolves.toBe(bleConnectId); + + expect(mockedLocalDb.getDeviceByQuery.mock.calls[0]).toEqual([ + { connectId: 'PRB09B0088A' }, + ]); }); - it('rejects a stored third-party connectId before initializing OneKey SDK', async () => { - mockedLocalDb.getDeviceByQuery.mockResolvedValue({ - id: 'db-device-1', - connectId: 'USB_ID', - usbConnectId: 'USB_ID', - deviceId: 'FEATURES_DEVICE_ID', - vendor: EHardwareVendor.trezor, - name: 'Trezor Safe 7', + it('falls back to the persisted USB connectId for an offline background task', async () => { + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', features: '{}', settingsRaw: '{}', createdAt: 0, updatedAt: 0, - } as IDBDevice); + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockResolvedValue(dbDevice); const service = new ServiceHardware({ - backgroundApi: {} as unknown as IBackgroundApi, + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + } as unknown as IBackgroundApi, }); - service.checkSdkVersionValid = jest.fn(); await expect( - service.getSDKInstance({ - connectId: 'USB_ID', + service.getCompatibleConnectId({ + connectId: dbDevice.connectId, + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, }), - ).rejects.toThrow( - 'ServiceHardware SDK is OneKey-only; connectId "USB_ID" belongs to third-party vendor "trezor". Use ServiceThirdPartyHardware instead.', - ); + ).resolves.toBe(dbDevice.connectId); }); - it('shows BLE permission guidance before Android user hardware calls when permission is missing', async () => { - Object.assign(mutablePlatformEnv, { - isDesktop: false, - isSupportDesktopBle: false, - isNative: true, - isNativeAndroid: true, - }); - mockedCheckBLEPermissions.mockResolvedValue(false); - + it('keeps an explicitly pinned USB transport and endpoint together in a background call', async () => { + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRO2_USB_ID', + usbConnectId: 'PRO2_USB_ID', + bleConnectId: 'PRO2_BLE_ID', + deviceId: 'PRO2_DEVICE_ID', + connectProtocol: 'V2', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockResolvedValue(dbDevice); const service = new ServiceHardware({ backgroundApi: { serviceSetting: { - getHardwareTransportType: jest.fn( - async () => EHardwareTransportType.BLE, - ), + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), }, } as unknown as IBackgroundApi, }); await expect( service.getCompatibleConnectId({ - connectId: 'ANDROID_BLE_ID', - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + connectId: dbDevice.connectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.WEBUSB, }), - ).rejects.toThrow('NeedBluetoothPermissions'); - - expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ - EAppEventBusNames.RequestHardwareUIDialog, - { - uiRequestType: 'ui-location_permission', - }, - ]); - expect(mockedCheckBLEState).not.toHaveBeenCalled(); - expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); + ).resolves.toBe(dbDevice.usbConnectId); }); - it('shows Bluetooth settings guidance before Android user hardware calls when Bluetooth is off', async () => { - Object.assign(mutablePlatformEnv, { - isDesktop: false, - isSupportDesktopBle: false, - isNative: true, - isNativeAndroid: true, - }); - mockedCheckBLEPermissions.mockResolvedValue(true); - mockedCheckBLEState.mockResolvedValue(false); - + it('uses the persisted USB setting instead of stale BLE runtime state for a desktop background task', async () => { const service = new ServiceHardware({ backgroundApi: { serviceSetting: { - getHardwareTransportType: jest.fn( - async () => EHardwareTransportType.BLE, - ), + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + setHardwareTransportType: jest.fn(), }, } as unknown as IBackgroundApi, }); + await service.connectionManager.setCurrentTransportType( + EHardwareTransportType.DesktopWebBle, + ); + const detectUSBDeviceAvailability = jest.spyOn( + service.connectionManager, + 'detectUSBDeviceAvailability', + ); + const detectBluetoothAvailability = jest.spyOn( + service.connectionManager, + 'detectBluetoothAvailability', + ); await expect( - service.getCompatibleConnectId({ - connectId: 'ANDROID_BLE_ID', - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + service.connectionManager.shouldSwitchTransportType({ + connectId: 'USB_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, }), - ).rejects.toThrow('NeedBluetoothTurnedOn'); - - expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ - EAppEventBusNames.RequestHardwareUIDialog, - { - uiRequestType: 'ui-bluetooth_permission', - }, - ]); - expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); - }); - - it('does not run BLE prechecks for Android third-party USB devices', async () => { - Object.assign(mutablePlatformEnv, { - isDesktop: false, - isSupportDesktopBle: false, - isNative: true, - isNativeAndroid: true, + ).resolves.toEqual({ + shouldSwitch: false, + targetType: EHardwareTransportType.WEBUSB, }); - mockedCheckBLEPermissions.mockResolvedValue(false); - mockedLocalDb.getDeviceByQuery.mockResolvedValue({ - id: 'db-device-1', - connectId: 'USB_ID', - usbConnectId: 'USB_ID', - deviceId: 'FEATURES_DEVICE_ID', - vendor: EHardwareVendor.trezor, - name: 'Trezor Safe 7', - features: '{}', - settingsRaw: '{}', - createdAt: 0, - updatedAt: 0, - } as IDBDevice); + expect(detectUSBDeviceAvailability).not.toHaveBeenCalled(); + expect(detectBluetoothAvailability).not.toHaveBeenCalled(); + }); + it('keeps the USB fallback reachable for a desktop background device read', async () => { const service = new ServiceHardware({ - backgroundApi: {} as unknown as IBackgroundApi, + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + } as unknown as IBackgroundApi, }); + const getCompatibleConnectId = jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('USB_ID'); + const getDeviceStateWithMutex = jest + .spyOn( + service as unknown as { + _getDeviceStateWithMutex: ServiceHardware['getDeviceState']; + }, + '_getDeviceStateWithMutex', + ) + .mockResolvedValue({ + identity: { deviceId: 'DEVICE_ID' }, + protocol: 'V2', + } as never); await expect( - service.getCompatibleConnectId({ + service.getDeviceState({ connectId: 'USB_ID', - hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, }), - ).resolves.toBe('USB_ID'); - - expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); - expect(mockedCheckBLEState).not.toHaveBeenCalled(); - expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); - }); - - it('does not show BLE permission guidance for Android background hardware calls', async () => { - Object.assign(mutablePlatformEnv, { - isDesktop: false, - isSupportDesktopBle: false, - isNative: true, - isNativeAndroid: true, + ).resolves.toEqual(expect.objectContaining({ protocol: 'V2' })); + expect(getCompatibleConnectId).toHaveBeenCalledWith({ + connectId: 'USB_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: undefined, }); - mockedCheckBLEPermissions.mockResolvedValue(false); - mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + expect(getDeviceStateWithMutex).toHaveBeenCalledWith( + expect.objectContaining({ connectId: 'USB_ID' }), + ); + }); + it('allows only the scoped connected-only desktop BLE background call', async () => { const service = new ServiceHardware({ backgroundApi: {} as unknown as IBackgroundApi, }); + const getCompatibleConnectId = jest + .spyOn(service, 'getCompatibleConnectId') + .mockResolvedValue('PRO2_BLE_ID'); + const getDeviceStateWithMutex = jest + .spyOn( + service as unknown as { + _getDeviceStateWithMutex: ServiceHardware['getDeviceState']; + }, + '_getDeviceStateWithMutex', + ) + .mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + } as never); await expect( - service.getCompatibleConnectId({ - connectId: 'ANDROID_BLE_ID', - hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + service.getDeviceState({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, }), - ).resolves.toBe('ANDROID_BLE_ID'); + ).resolves.toEqual(expect.objectContaining({ protocol: 'V2' })); - expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); - expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + expect(getCompatibleConnectId).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + expect(getDeviceStateWithMutex).toHaveBeenCalledWith( + expect.objectContaining({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }), + ); }); - it('keeps OneKey standard wallet EVM address lookup on empty passphrase', async () => { - const evmGetAddress = jest.fn().mockResolvedValue({ - success: true, - payload: { - address: '0xOneKeyStandardAddress', + it('guards the desktop BLE state read without passing unsupported SDK params', async () => { + const originalDesktopApi = globalThis.desktopApi; + const beginConnectedOnlyScope = jest.fn().mockReturnValue(1); + const endConnectedOnlyScope = jest.fn(); + globalThis.desktopApi = { + nobleBle: { + beginConnectedOnlyScope, + endConnectedOnlyScope, }, - }); + } as never; const service = new ServiceHardware({ backgroundApi: {} as unknown as IBackgroundApi, }); - service.getCompatibleConnectId = jest.fn().mockResolvedValue('ONEKEY_USB'); + const getDeviceState = jest.fn().mockResolvedValue({ + success: true, + payload: { + identity: { deviceId: 'PRO2_DEVICE_ID', serialNo: 'PRO2_SERIAL' }, + protocol: 'V2', + }, + }); service.getSDKInstance = jest.fn().mockResolvedValue({ - evmGetAddress, + getDeviceState, } as unknown as Awaited>); + jest + .spyOn( + service as unknown as { + rememberDeviceProtocol: () => Promise; + }, + 'rememberDeviceProtocol', + ) + .mockResolvedValue(undefined); + + try { + await expect( + service._getDeviceStateLowLevel({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + params: { connectProtocol: 'V2' }, + }), + ).resolves.toEqual(expect.objectContaining({ protocol: 'V2' })); + expect(beginConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID'); + expect(getDeviceState).toHaveBeenCalledWith('PRO2_BLE_ID', { + connectProtocol: 'V2', + }); + expect(endConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID', 1); + } finally { + globalThis.desktopApi = originalDesktopApi; + } + }); + + it('uses the USB connectId for desktop firmware preflight', async () => { + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: 'f7e440001d2c1c79509d55dfdc8201ff', + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockResolvedValue(dbDevice); + const withHardwareProcessing = jest.fn( + async (callback: () => Promise) => callback(), + ); + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + setHardwareTransportType: jest.fn(), + }, + serviceHardwareUI: { withHardwareProcessing }, + } as unknown as IBackgroundApi, + }); + const getFeaturesWithoutCache = jest + .spyOn(service, 'getFeaturesWithoutCache') + .mockResolvedValue({ success: true } as any); await expect( - service.getEvmAddressByStandardWallet({ - connectId: 'ONEKEY_USB', - deviceId: 'ONEKEY_DEVICE_ID', - path: "m/44'/60'/0'/0/0", - vendor: EHardwareVendor.onekey, + service.checkDeviceReachableForFirmwareUpdate({ + connectId: dbDevice.usbConnectId as string, }), - ).resolves.toBe('0xOneKeyStandardAddress'); + ).resolves.toBe(dbDevice.usbConnectId); + expect(getFeaturesWithoutCache).toHaveBeenCalledWith({ + connectId: dbDevice.usbConnectId, + params: { + retryCount: 1, + forceProtocolDetection: false, + }, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + }); - expect(evmGetAddress).toHaveBeenCalledWith( - 'ONEKEY_USB', - 'ONEKEY_DEVICE_ID', + it.each([ + ['missing', undefined], + ['USB-aliasing', 'PRB09B0088A'], + ])( + 'pairs a %s BLE connectId instead of passing the USB serial to Noble', + async (_caseName, storedBleConnectId) => { + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: storedBleConnectId, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockResolvedValue(dbDevice); + jest.mocked(hardwareForceTransportAtom.get).mockResolvedValue({ + forceTransportType: EHardwareTransportType.DesktopWebBle, + }); + + const showBluetoothDevicePairingDialog = jest.fn(); + const setHardwareTransportType = jest.fn(); + const createCallback = jest.fn( + ({ resolve }: { resolve: (value: string) => void }) => { + resolve(bleConnectId); + return 'ble-pairing-promise'; + }, + ); + const service = new ServiceHardware({ + backgroundApi: { + servicePromise: { createCallback }, + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + serviceSetting: { setHardwareTransportType }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + + await expect( + service.getCompatibleConnectId({ + connectId: 'PRB09B0088A', + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, + }), + ).resolves.toBe(bleConnectId); + + expect(setHardwareTransportType).toHaveBeenCalledWith( + EHardwareTransportType.DesktopWebBle, + ); + + expect(showBluetoothDevicePairingDialog).toHaveBeenCalledWith( + expect.objectContaining({ + device: dbDevice, + usbConnectId: 'PRB09B0088A', + promiseId: 'ble-pairing-promise', + }), + ); + }, + ); + + it('binds a live BLE connectId silently instead of showing the pairing dialog', async () => { + const liveBleConnectId = '714d4c59ef4af3df00d92885755c4a58'; + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + // The live BLE connectId matches no record; the featuresDeviceId + // fallback resolves the USB-created record without a BLE binding. + mockedLocalDb.getDeviceByQuery + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(dbDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + serviceSetting: { setHardwareTransportType: jest.fn() }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.DesktopWebBle, + }), { - path: "m/44'/60'/0'/0/0", - showOnOneKey: false, - useEmptyPassphrase: true, - passphraseState: undefined, + clear: jest.fn(), + delete: jest.fn(), }, - ); + ) as typeof service.connectionManager.shouldSwitchTransportType; + // V2 state projections expose only the raw device_id field (no + // SDK-normalized deviceId) — the identity check must handle that shape. + const getFeaturesSpy = jest + .spyOn(service, 'getFeaturesWithoutCache') + .mockResolvedValue({ device_id: 'PRO2_DEVICE_ID' } as any); + jest + .spyOn( + service as unknown as { + getKnownDeviceProtocol: ( + connectId?: string, + ) => Promise<'V1' | 'V2' | undefined>; + }, + 'getKnownDeviceProtocol', + ) + .mockResolvedValue('V1'); + // Live traffic was just observed on this connectId (paired by evidence). + service.recordLiveConnectIdEvidence(liveBleConnectId); + + await expect( + service.getCompatibleConnectId({ + connectId: liveBleConnectId, + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(liveBleConnectId); + + // silentMode must be set: a failed probe would otherwise emit the + // global DeviceNotFound error dialog from the error constructor. + // The probe must pin the remembered protocol instead of forcing + // re-detection — a V2 Ping into an active V1 session can go unanswered + // (SDK error 713) and would spuriously fall back to the pairing dialog. + expect(getFeaturesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + connectId: liveBleConnectId, + silentMode: true, + hardwareCallContext: + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + params: expect.objectContaining({ + retryCount: 1, + connectProtocol: 'V1', + timeout: 10_000, + }), + }), + ); + expect(mockedLocalDb.updateDeviceConnectId.mock.calls).toEqual([ + [{ dbDeviceId: dbDevice.id, bleConnectId: liveBleConnectId }], + ]); + expect(showBluetoothDevicePairingDialog).not.toHaveBeenCalled(); + }); + + it('falls back to the pairing dialog when silent BLE binding fails', async () => { + const liveBleConnectId = '714d4c59ef4af3df00d92885755c4a58'; + const pairedBleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(dbDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const createCallback = jest.fn( + ({ resolve }: { resolve: (value: string) => void }) => { + resolve(pairedBleConnectId); + return 'ble-pairing-promise'; + }, + ); + const service = new ServiceHardware({ + backgroundApi: { + servicePromise: { createCallback }, + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + serviceSetting: { setHardwareTransportType: jest.fn() }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + const getFeaturesSpy = jest + .spyOn(service, 'getFeaturesWithoutCache') + .mockRejectedValue(new Error('ble subscribe timeout')); + jest + .spyOn( + service as unknown as { + getKnownDeviceProtocol: ( + connectId?: string, + ) => Promise<'V1' | 'V2' | undefined>; + }, + 'getKnownDeviceProtocol', + ) + .mockResolvedValue('V1'); + service.recordLiveConnectIdEvidence(liveBleConnectId); + + await expect( + service.getCompatibleConnectId({ + connectId: liveBleConnectId, + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(pairedBleConnectId); + + expect(getFeaturesSpy).toHaveBeenCalledWith( + expect.objectContaining({ silentMode: true }), + ); + + expect(mockedLocalDb.updateDeviceConnectId.mock.calls).toEqual([]); + expect(showBluetoothDevicePairingDialog).toHaveBeenCalledWith( + expect.objectContaining({ + device: dbDevice, + usbConnectId: 'PRB09B0088A', + promiseId: 'ble-pairing-promise', + }), + ); + }); + + it('never probes a connectId whose evidence was cleared by a disconnect', async () => { + const liveBleConnectId = '714d4c59ef4af3df00d92885755c4a58'; + const pairedBleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(dbDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const createCallback = jest.fn( + ({ resolve }: { resolve: (value: string) => void }) => { + resolve(pairedBleConnectId); + return 'ble-pairing-promise'; + }, + ); + const service = new ServiceHardware({ + backgroundApi: { + servicePromise: { createCallback }, + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + serviceSetting: { setHardwareTransportType: jest.fn() }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + const getFeaturesSpy = jest.spyOn(service, 'getFeaturesWithoutCache'); + + // Traffic was observed, then the device disconnected (e.g. factory + // reset or OS-level unpair) — the DISCONNECT handler clears the stamp. + service.recordLiveConnectIdEvidence(liveBleConnectId); + service.clearLiveConnectIdEvidence(liveBleConnectId); + + await expect( + service.getCompatibleConnectId({ + connectId: liveBleConnectId, + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(pairedBleConnectId); + + expect(getFeaturesSpy).not.toHaveBeenCalled(); + expect(showBluetoothDevicePairingDialog).toHaveBeenCalledWith( + expect.objectContaining({ + device: dbDevice, + promiseId: 'ble-pairing-promise', + }), + ); + }); + + it('never probes a connectId without recent live traffic evidence', async () => { + // A stale BLE UUID (device rebooted / unpaired meanwhile) must not be + // probed: the probe's characteristic subscription could summon the OS + // pairing prompt without any app guidance UI. + const staleBleConnectId = '99994c59ef4af3df00d92885755c4a58'; + const pairedBleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(dbDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const createCallback = jest.fn( + ({ resolve }: { resolve: (value: string) => void }) => { + resolve(pairedBleConnectId); + return 'ble-pairing-promise'; + }, + ); + const service = new ServiceHardware({ + backgroundApi: { + servicePromise: { createCallback }, + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + serviceSetting: { setHardwareTransportType: jest.fn() }, + } as unknown as IBackgroundApi, + }); + service.connectionManager.shouldSwitchTransportType = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: false, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ) as typeof service.connectionManager.shouldSwitchTransportType; + const getFeaturesSpy = jest.spyOn(service, 'getFeaturesWithoutCache'); + + await expect( + service.getCompatibleConnectId({ + connectId: staleBleConnectId, + featuresDeviceId: dbDevice.deviceId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe(pairedBleConnectId); + + expect(getFeaturesSpy).not.toHaveBeenCalled(); + expect(showBluetoothDevicePairingDialog).toHaveBeenCalledWith( + expect.objectContaining({ + device: dbDevice, + promiseId: 'ble-pairing-promise', + }), + ); + }); + + it('does not start BLE pairing for a no-dialog device details refresh', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + } 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', + hardwareCallContext: + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + }), + ).rejects.toThrow(); + + expect(showBluetoothDevicePairingDialog).not.toHaveBeenCalled(); + }); + + it('falls back to the persisted USB connectId for a silent cleanup', async () => { + const dbDevice = { + id: 'db-pro2-device', + connectId: 'PRB09B0088A', + usbConnectId: 'PRB09B0088A', + bleConnectId: undefined, + deviceId: 'PRO2_DEVICE_ID', + vendor: EHardwareVendor.onekey, + name: 'OneKey Pro 2', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockResolvedValue(dbDevice); + const showBluetoothDevicePairingDialog = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { showBluetoothDevicePairingDialog }, + } 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: dbDevice.connectId, + hardwareCallContext: EHardwareCallContext.SILENT_CALL, + }), + ).resolves.toBe(dbDevice.usbConnectId); + + expect(showBluetoothDevicePairingDialog).not.toHaveBeenCalled(); + }); + + 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 () => { + const trezorDevice = { + id: 'db-device-1', + connectId: 'USB_ID', + usbConnectId: 'USB_ID', + bleConnectId: 'BLE_ID', + deviceId: 'FEATURES_DEVICE_ID', + vendor: EHardwareVendor.trezor, + name: 'Trezor Safe 7', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice; + mockedLocalDb.getDeviceByQuery.mockImplementation(async (query) => + query.vendor === EHardwareVendor.trezor ? trezorDevice : undefined, + ); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + const shouldSwitchTransportTypeMock = Object.assign( + jest.fn().mockResolvedValue({ + shouldSwitch: true, + targetType: EHardwareTransportType.DesktopWebBle, + }), + { + clear: jest.fn(), + delete: jest.fn(), + }, + ); + service.connectionManager.shouldSwitchTransportType = + shouldSwitchTransportTypeMock as typeof service.connectionManager.shouldSwitchTransportType; + + await expect( + service.getCompatibleConnectId({ + connectId: 'USB_ID', + featuresDeviceId: 'FEATURES_DEVICE_ID', + vendor: EHardwareVendor.trezor, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe('BLE_ID'); + expect(mockedLocalDb.getDeviceByQuery.mock.calls[0]).toEqual([ + { + connectId: 'USB_ID', + vendor: EHardwareVendor.trezor, + }, + ]); + }); + + it('uses USB when any authorized OneKey WebUSB device is available', 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.detectWebUSBAvailability('OTHER_USB_ID'), + ).resolves.toBe(true); + await expect( + service.connectionManager.detectWebUSBAvailability('PRO2_USB_ID'), + ).resolves.toBe(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('PRB50B0127B'); + expect(detectBluetoothAvailability).not.toHaveBeenCalled(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator); + } else { + delete (globalThis as { navigator?: Navigator }).navigator; + } + } + }); + + it('falls back to BLE when navigator.usb only reports a serial-less device', 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, + // Chromium 可以返回已授权但没有可用 serialNumber 的设备; + // 这种设备无法被 SDK WebUSB transport acquire。 + serialNumber: '', + }, + ]), + }, + }, + }); + + 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({ + shouldSwitch: true, + targetType: EHardwareTransportType.DesktopWebBle, + }); + expect(detectBluetoothAvailability).toHaveBeenCalled(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator); + } else { + delete (globalThis as { navigator?: Navigator }).navigator; + } + } + }); + + it('keeps one transport decision when the same call changes from USB serial to BLE UUID', async () => { + const getDevices = jest + .fn() + .mockResolvedValue([ + { vendorId: 0x12_09, productId: 0x4f_4c, serialNumber: 'USB_ID' }, + ]); + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + 'navigator', + ); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { usb: { getDevices } }, + }); + + 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, + }); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toMatchObject({ targetType: EHardwareTransportType.WEBUSB }); + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'BLE_PERIPHERAL_UUID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toMatchObject({ targetType: EHardwareTransportType.WEBUSB }); + expect(getDevices).toHaveBeenCalledTimes(1); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator); + } else { + delete (globalThis as { navigator?: Navigator }).navigator; + } + } + }); + + it('uses Bridge when any Bridge device is enumerated', 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.detectBridgeAvailability('OTHER_USB_ID'), + ).resolves.toBe(true); + await expect( + service.connectionManager.detectBridgeAvailability('UNRELATED_USB_ID'), + ).resolves.toBe(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('PRB50B0127B'); + expect(detectBluetoothAvailability).not.toHaveBeenCalled(); + }); + + 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, + }); + await 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('keeps Mini on USB for a desktop background call after BLE was active', async () => { + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + } as unknown as IBackgroundApi, + }); + await service.connectionManager.setCurrentTransportType( + EHardwareTransportType.DesktopWebBle, + ); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'MI123456789', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + }), + ).resolves.toEqual({ + shouldSwitch: true, + targetType: EHardwareTransportType.WEBUSB, + }); + }); + + it('honors a pinned transport before applying the Mini USB preference', async () => { + jest.mocked(desktopHardwareForceTransportAtom.get).mockResolvedValue({ + forceTransportType: EHardwareTransportType.Bridge, + }); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + } as unknown as IBackgroundApi, + }); + await service.connectionManager.setCurrentTransportType( + EHardwareTransportType.WEBUSB, + ); + + await expect( + service.connectionManager.shouldSwitchTransportType({ + connectId: 'MI123456789', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toEqual({ + shouldSwitch: true, + targetType: EHardwareTransportType.Bridge, + }); + }); + + it('keeps Mini on USB when the forced transport is BLE', async () => { + jest.mocked(desktopHardwareForceTransportAtom.get).mockResolvedValue({ + forceTransportType: EHardwareTransportType.DesktopWebBle, + }); + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + } as unknown as IBackgroundApi, + }); + await 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.each([ + { + firstProtocol: 'V1' as const, + secondProtocol: 'V2' as const, + expectedTargets: [ + EHardwareTransportType.Bridge, + EHardwareTransportType.WEBUSB, + ], + }, + { + firstProtocol: 'V2' as const, + secondProtocol: 'V1' as const, + expectedTargets: [ + EHardwareTransportType.WEBUSB, + EHardwareTransportType.Bridge, + ], + }, + ])( + 'isolates $firstProtocol and $secondProtocol transport decisions in the same context', + async ({ firstProtocol, secondProtocol, expectedTargets }) => { + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'bridge' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.Bridge), + }, + } as unknown as IBackgroundApi, + }); + jest + .spyOn(service.connectionManager, 'detectBridgeAvailability') + .mockResolvedValue(true); + jest + .spyOn(service.connectionManager, 'detectWebUSBAvailability') + .mockResolvedValue(true); + + const firstResult = + await service.connectionManager.shouldSwitchTransportType({ + connectId: 'PRO2_USB_ID', + connectProtocol: firstProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const secondResult = + await service.connectionManager.shouldSwitchTransportType({ + connectId: 'PRO2_USB_ID', + connectProtocol: secondProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + + expect([firstResult.targetType, secondResult.targetType]).toEqual( + expectedTargets, + ); + }, + ); + + it('isolates Mini and BLE-capable device transport decisions', async () => { + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getDevSetting: jest.fn().mockResolvedValue({ + settings: { usbCommunicationMode: 'webusb' }, + }), + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + } as unknown as IBackgroundApi, + }); + jest + .spyOn(service.connectionManager, 'detectWebUSBAvailability') + .mockResolvedValue(false); + jest + .spyOn(service.connectionManager, 'detectBluetoothAvailability') + .mockResolvedValue(true); + + const regularResult = + await service.connectionManager.shouldSwitchTransportType({ + connectId: 'PRO_USB_ID', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const miniResult = + await service.connectionManager.shouldSwitchTransportType({ + connectId: 'MI123456789', + connectProtocol: 'V1', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + + expect(regularResult.targetType).toBe(EHardwareTransportType.DesktopWebBle); + expect(miniResult.targetType).toBe(EHardwareTransportType.WEBUSB); + }); + + it('rejects a stored third-party connectId before initializing OneKey SDK', async () => { + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'USB_ID', + usbConnectId: 'USB_ID', + deviceId: 'FEATURES_DEVICE_ID', + vendor: EHardwareVendor.trezor, + name: 'Trezor Safe 7', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + + await expect( + service.getSDKInstance({ + connectId: 'USB_ID', + }), + ).rejects.toThrow( + 'ServiceHardware SDK is OneKey-only; connectId "USB_ID" belongs to third-party vendor "trezor". Use ServiceThirdPartyHardware instead.', + ); + }); + + it('reloads the SDK only when a forced manifest refresh changes the snapshot', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + const manifest1 = { bridge: { version: '1' } }; + const manifest2 = { bridge: { version: '2' } }; + mockedGetFirmwareManifestSnapshot + .mockResolvedValueOnce(manifest1 as unknown as RemoteConfigResponse) + .mockResolvedValueOnce(manifest1 as unknown as RemoteConfigResponse) + .mockResolvedValueOnce(manifest2 as unknown as RemoteConfigResponse); + mockedGetHardwareSDKInstance.mockImplementation(async (params) => { + await params.loadFirmwareConfig?.(); + return {} as Awaited>; + }); + + const service = new ServiceHardware({ + backgroundApi: { + serviceApp: { + showToast: jest.fn(), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceSetting: { + setHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.connectionManager.getCurrentTransportType = jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn( + service as unknown as { + registerSdkEvents: () => Promise; + }, + 'registerSdkEvents', + ) + .mockResolvedValue(undefined); + + const options = { + connectId: undefined, + forceFirmwareManifestRefresh: true, + }; + await service.getSDKInstance(options); + await service.getSDKInstance(options); + expect(mockedResetHardwareSDKInstance).not.toHaveBeenCalled(); + + await service.getSDKInstance(options); + expect(mockedResetHardwareSDKInstance).toHaveBeenCalledTimes(1); + expect(mockedGetFirmwareManifestSnapshot).toHaveBeenCalledTimes(3); + expect(mockedGetFirmwareManifestSnapshot).toHaveBeenLastCalledWith({ + preRelease: false, + forceRefresh: true, + }); + }); + + it('keeps ordinary hardware available without a manifest and reloads after recovery', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + const recoveredManifest = { bridge: { version: 'recovered' } }; + mockedGetFirmwareManifestSnapshot + .mockRejectedValueOnce(new Error('manifest unavailable')) + .mockResolvedValueOnce( + recoveredManifest as unknown as RemoteConfigResponse, + ); + const loadedConfigs: Array = []; + mockedGetHardwareSDKInstance.mockImplementation(async (params) => { + loadedConfigs.push(await params.loadFirmwareConfig?.()); + return {} as Awaited>; + }); + + const service = new ServiceHardware({ + backgroundApi: { + serviceApp: { + showToast: jest.fn(), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceSetting: { + setHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.connectionManager.getCurrentTransportType = jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn( + service as unknown as { + registerSdkEvents: () => Promise; + }, + 'registerSdkEvents', + ) + .mockResolvedValue(undefined); + + await expect( + service.getSDKInstance({ connectId: undefined }), + ).resolves.toBeDefined(); + expect(loadedConfigs).toEqual([undefined]); + expect(mockedResetHardwareSDKInstance).not.toHaveBeenCalled(); + + await expect( + service.getSDKInstance({ + connectId: undefined, + forceFirmwareManifestRefresh: true, + }), + ).resolves.toBeDefined(); + expect(mockedResetHardwareSDKInstance).toHaveBeenCalledTimes(1); + expect(loadedConfigs).toEqual([undefined, recoveredManifest]); + }); + + it('keeps an explicit firmware manifest refresh fail-closed', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + mockedGetFirmwareManifestSnapshot.mockRejectedValueOnce( + new Error('manifest unavailable'), + ); + + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + + await expect( + service.getSDKInstance({ + connectId: undefined, + forceFirmwareManifestRefresh: true, + }), + ).rejects.toThrow('manifest unavailable'); + expect(mockedGetHardwareSDKInstance).not.toHaveBeenCalled(); + }); + + it('shows BLE permission guidance before Android user hardware calls when permission is missing', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).rejects.toThrow('NeedBluetoothPermissions'); + + expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ + EAppEventBusNames.RequestHardwareUIDialog, + { + uiRequestType: 'ui-location_permission', + }, + ]); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); + }); + + it('shows Bluetooth settings guidance before Android user hardware calls when Bluetooth is off', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(true); + mockedCheckBLEState.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).rejects.toThrow('NeedBluetoothTurnedOn'); + + expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ + EAppEventBusNames.RequestHardwareUIDialog, + { + uiRequestType: 'ui-bluetooth_permission', + }, + ]); + expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); + }); + + it('does not run BLE prechecks for an explicit Android USB call after BLE was active', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).resolves.toBe('ANDROID_USB_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + + it('runs BLE prechecks for an explicit Android BLE call after USB was active', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.WEBUSB, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.BLE, + }), + ).rejects.toThrow('NeedBluetoothPermissions'); + + expect(mockedCheckBLEPermissions).toHaveBeenCalledTimes(1); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + }); + + it('does not run BLE prechecks for Android third-party USB devices', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'USB_ID', + usbConnectId: 'USB_ID', + deviceId: 'FEATURES_DEVICE_ID', + vendor: EHardwareVendor.trezor, + name: 'Trezor Safe 7', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe('USB_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + + it('does not show BLE permission guidance for Android background hardware calls', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + }), + ).resolves.toBe('ANDROID_BLE_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + + it('keeps OneKey standard wallet EVM address lookup on empty passphrase', async () => { + const evmGetAddress = jest.fn().mockResolvedValue({ + success: true, + payload: { + address: '0xOneKeyStandardAddress', + }, + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('ONEKEY_USB'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + evmGetAddress, + } as unknown as Awaited>); + + await expect( + service.getEvmAddressByStandardWallet({ + connectId: 'ONEKEY_USB', + deviceId: 'ONEKEY_DEVICE_ID', + path: "m/44'/60'/0'/0/0", + vendor: EHardwareVendor.onekey, + }), + ).resolves.toBe('0xOneKeyStandardAddress'); + + expect(evmGetAddress).toHaveBeenCalledWith( + 'ONEKEY_USB', + 'ONEKEY_DEVICE_ID', + { + path: "m/44'/60'/0'/0/0", + showOnOneKey: false, + useEmptyPassphrase: true, + passphraseState: undefined, + }, + ); + }); + + 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 packageBase64 = 'AQID'; + + await expect( + service.uploadPortfolioPackage({ + connectId: 'ONEKEY_USB', + packageBase64, + }), + ).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', { + packageBase64, + }); + }); + + it('pins desktop BLE portfolio upload to connected-only reuse', async () => { + const originalDesktopApi = globalThis.desktopApi; + const beginConnectedOnlyScope = jest.fn().mockReturnValue(1); + const endConnectedOnlyScope = jest.fn(); + globalThis.desktopApi = { + nobleBle: { + beginConnectedOnlyScope, + endConnectedOnlyScope, + }, + } as never; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_BLE_ID'); + 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; + + try { + await expect( + service.uploadPortfolioPackage({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + packageBase64: 'AQID', + }), + ).resolves.toEqual({ portfolioUpdated: true }); + + expect(getCompatibleConnectId).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + expect(beginConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID'); + expect(uploadPortfolio).toHaveBeenCalledWith('PRO2_BLE_ID', { + packageBase64: 'AQID', + }); + expect(endConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID', 1); + expect(beginConnectedOnlyScope.mock.invocationCallOrder[0]).toBeLessThan( + uploadPortfolio.mock.invocationCallOrder[0], + ); + expect(uploadPortfolio.mock.invocationCallOrder[0]).toBeLessThan( + endConnectedOnlyScope.mock.invocationCallOrder[0], + ); + } finally { + globalThis.desktopApi = originalDesktopApi; + } + }); + + it('always closes the desktop BLE connected-only scope after an SDK error', async () => { + const originalDesktopApi = globalThis.desktopApi; + const beginConnectedOnlyScope = jest.fn().mockReturnValue(1); + const endConnectedOnlyScope = jest.fn(); + globalThis.desktopApi = { + nobleBle: { + beginConnectedOnlyScope, + endConnectedOnlyScope, + }, + } as never; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_BLE_ID'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + uploadPortfolio: jest.fn().mockRejectedValue(new Error('BLE failed')), + } as unknown as Awaited>); + + try { + await expect( + service.uploadPortfolioPackage({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + packageBase64: 'AQID', + }), + ).rejects.toThrow('BLE failed'); + expect(beginConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID'); + expect(endConnectedOnlyScope).toHaveBeenCalledWith('PRO2_BLE_ID', 1); + } finally { + globalThis.desktopApi = originalDesktopApi; + } + }); + + it('keeps desktop BLE Portfolio upload errors silent', async () => { + const originalDesktopApi = globalThis.desktopApi; + globalThis.desktopApi = { + nobleBle: { + beginConnectedOnlyScope: jest.fn().mockReturnValue(1), + endConnectedOnlyScope: jest.fn(), + }, + } as never; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('PRO2_BLE_ID'); + service.getSDKInstance = jest.fn().mockResolvedValue({ + uploadPortfolio: jest.fn().mockResolvedValue({ + success: false, + payload: { + code: HardwareErrorCode.DeviceNotFound, + error: 'Device not found', + }, + }), + } as unknown as Awaited>); + + try { + await expect( + service.uploadPortfolioPackage({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + packageBase64: 'AQID', + }), + ).rejects.toMatchObject({ code: HardwareErrorCode.DeviceNotFound }); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + } finally { + globalThis.desktopApi = originalDesktopApi; + } + }); + + it('forwards Pro2 NFT JPEG Base64 without decoding it in background', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getCompatibleConnectId = jest.fn().mockResolvedValue('ONEKEY_USB'); + const deviceUploadNft = jest.fn().mockResolvedValue({ + success: true, + payload: { nftUpdated: true }, + }); + service.getSDKInstance = jest.fn().mockResolvedValue({ + deviceUploadNft, + } as unknown as Awaited>); + + await expect( + service.uploadPro2Nft({ + connectId: 'ONEKEY_USB', + imageJpegBase64: 'full-image-base64', + thumbnailJpegBase64: 'thumbnail-base64', + title: 'NFT #1', + subtitle: 'Collection', + timestampMs: 123, + }), + ).resolves.toEqual({ nftUpdated: true }); + + expect(deviceUploadNft).toHaveBeenCalledWith('ONEKEY_USB', { + imageJpegBase64: 'full-image-base64', + thumbnailJpegBase64: 'thumbnail-base64', + title: 'NFT #1', + subtitle: 'Collection', + timestampMs: 123, + }); + }); + + it('resolves transport once before 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>); + const prepareHardwareTransport = jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle); + service.getSDKInstance = getSDKInstance; + service.prepareHardwareTransport = prepareHardwareTransport; + + await expect( + service.searchDevices({ connectProtocol: 'V2' }), + ).resolves.toEqual({ success: true, payload: [] }); + + expect(searchDevices).toHaveBeenCalledWith(); + expect(prepareHardwareTransport).toHaveBeenCalledWith({ + connectProtocol: 'V2', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: undefined, + connectProtocol: 'V2', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + it('marks the hardware channel busy while device discovery is running', async () => { + let resolveSearch: + | ((result: { success: true; payload: SearchDevice[] }) => void) + | undefined; + const sdkSearchDevices = jest.fn( + () => + new Promise<{ success: true; payload: SearchDevice[] }>((resolve) => { + resolveSearch = resolve; + }), + ); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getSDKInstance = jest.fn().mockResolvedValue({ + searchDevices: sdkSearchDevices, + } as unknown as Awaited>); + service.prepareHardwareTransport = jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB); + + const searchTask = service.searchDevices({ transportType: 'usb' }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + await expect(service.isDeviceSearchInProgress()).resolves.toBe(true); + + resolveSearch?.({ success: true, payload: [] }); + await searchTask; + await expect(service.isDeviceSearchInProgress()).resolves.toBe(false); + }); + + it('locks an explicit BLE discovery to the BLE SDK transport', async () => { + const sdkSearchDevices = jest.fn().mockResolvedValue({ + success: true, + payload: [], + }); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const getSDKInstance = jest.fn().mockResolvedValue({ + searchDevices: sdkSearchDevices, + } as unknown as Awaited>); + const prepareHardwareTransport = jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle); + service.getSDKInstance = getSDKInstance; + service.prepareHardwareTransport = prepareHardwareTransport; + + await service.searchDevices({ transportType: 'ble' }); + + expect(prepareHardwareTransport).toHaveBeenCalledWith({ + connectProtocol: undefined, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + requestedTransportType: 'ble', + }); + expect(getSDKInstance).toHaveBeenCalledWith( + expect.objectContaining({ + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }), + ); + }); + + it('rejects a matching USB result while repairing bleConnectId', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const searchDevices = jest + .spyOn(service, 'searchDevices') + .mockResolvedValue({ + success: true, + payload: [ + { + connectId: 'PRB09B0088A', + uuid: 'PRB09B0088A', + deviceId: 'PRO2_DEVICE_ID', + deviceType: 'pro2', + name: 'Pro 2 0088', + commType: 'webusb', + } as SearchDevice, + ], + }); + const connect = jest.spyOn(service, 'connect'); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + service.repairBleConnectIdWithProgress({ + connectId: 'PRB09B0088A', + featuresDeviceId: 'PRO2_DEVICE_ID', + features: { + bleName: 'Pro 2 0088', + deviceId: 'PRO2_DEVICE_ID', + } as IOneKeyDeviceFeaturesWithAppParams, + }), + ).rejects.toThrow(); + } finally { + consoleError.mockRestore(); + } + + expect(searchDevices).toHaveBeenCalledWith({ + transportType: 'ble', + }); + expect(connect).not.toHaveBeenCalled(); + expect(mockedLocalDb.updateDeviceConnectId.mock.calls).toHaveLength(0); + }); + + it('persists only the verified Noble peripheral ID as bleConnectId', async () => { + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const bleDevice = { + connectId: bleConnectId, + uuid: bleConnectId, + deviceId: null, + deviceType: 'pro2', + name: 'Pro 2 0088', + commType: 'electron-ble', + } as SearchDevice; + jest.spyOn(service, 'searchDevices').mockResolvedValue({ + success: true, + payload: [bleDevice], + }); + const connect = jest.spyOn(service, 'connect').mockResolvedValue({ + deviceId: 'PRO2_DEVICE_ID', + } as never); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro2-device', + } as IDBDevice); + + await expect( + service.repairBleConnectIdWithProgress({ + connectId: 'PRB09B0088A', + featuresDeviceId: 'PRO2_DEVICE_ID', + features: { + bleName: 'Pro 2 0088', + deviceId: 'PRO2_DEVICE_ID', + } as IOneKeyDeviceFeaturesWithAppParams, + }), + ).resolves.toBe(bleConnectId); + + expect(connect).toHaveBeenCalledWith({ + device: { + ...bleDevice, + connectId: bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + }, + forceProtocolDetection: true, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + expect(mockedLocalDb.updateDeviceConnectId.mock.calls).toEqual([ + [ + { + dbDeviceId: 'db-pro2-device', + bleConnectId, + }, + ], + ]); + }); + + it('matches compact and spaced Pro2 BLE names when repairing bleConnectId', async () => { + const bleConnectId = 'f7e440001d2c1c79509d55dfdc8201ff'; + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const bleDevice = { + connectId: bleConnectId, + uuid: bleConnectId, + deviceId: null, + deviceType: 'pro2', + name: 'Pro 2 0088', + commType: 'electron-ble', + } as SearchDevice; + jest.spyOn(service, 'searchDevices').mockResolvedValue({ + success: true, + payload: [bleDevice], + }); + const connect = jest.spyOn(service, 'connect').mockResolvedValue({ + deviceId: 'PRO2_DEVICE_ID', + } as never); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-pro2-device', + } as IDBDevice); + + await expect( + service.repairBleConnectIdWithProgress({ + connectId: 'PRB09B0088A', + featuresDeviceId: 'PRO2_DEVICE_ID', + features: { + bleName: 'Pro2 0088', + deviceId: 'PRO2_DEVICE_ID', + } as IOneKeyDeviceFeaturesWithAppParams, + }), + ).resolves.toBe(bleConnectId); + + expect(connect).toHaveBeenCalledWith({ + device: { + ...bleDevice, + connectId: bleConnectId, + deviceId: 'PRO2_DEVICE_ID', + }, + forceProtocolDetection: true, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + it('waits for the desktop Noble scan-stop callback', async () => { + let resolveStopScan: () => void = () => undefined; + const stopScan = jest.fn( + () => + new Promise((resolve) => { + resolveStopScan = resolve; + }), + ); + const globalWithDesktopApi = globalThis as unknown as { + desktopApi: { nobleBle?: { stopScan: () => Promise } } | undefined; + }; + const originalDesktopApi = globalWithDesktopApi.desktopApi; + globalWithDesktopApi.desktopApi = { nobleBle: { stopScan } }; + + try { + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + const stopped = jest.fn(); + const stopPromise = service.stopDeviceScan().then(stopped); + + await Promise.resolve(); + expect(stopScan).toHaveBeenCalledTimes(1); + expect(stopped).not.toHaveBeenCalled(); + + resolveStopScan(); + await stopPromise; + + expect(stopped).toHaveBeenCalledTimes(1); + } finally { + globalWithDesktopApi.desktopApi = originalDesktopApi; + } + }); +}); + +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'); + const getDeviceState = jest + .spyOn(service, 'getDeviceState') + .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', + }); + expect(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'); + const getDeviceState = jest + .spyOn(service, 'getDeviceState') + .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, + }); + expect(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'); + const getDeviceState = jest + .spyOn(service, 'getDeviceState') + .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(); + expect(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..bb4039edade2 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.pro2DeviceManagement.test.ts @@ -0,0 +1,2146 @@ +/* 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, EFirmwareType } 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, + settingsPersistAtom, +} 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: { + HardwareConnectionStateUpdate: 'HardwareConnectionStateUpdate', + 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: { + getExistingDevice: jest.fn(), + getDeviceSafe: jest.fn(), + getDeviceByQuery: jest.fn(), + updateDevice: jest.fn(), + updateDeviceState: jest.fn(), + }, +})); + +jest.mock('../../dbs/simple/simpleDb', () => ({ + __esModule: true, + default: { + appStatus: { + getRawData: jest.fn().mockResolvedValue({}), + setRawData: jest.fn().mockResolvedValue({}), + }, + legacyWalletNames: { + 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: { + get: jest.fn(async () => ({ instanceId: 'INSTANCE_ID' })), + }, + }; +}); + +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.each([ + { deviceType: EDeviceType.Pro2, connectId: 'PRO2_USB' }, + { deviceType: EDeviceType.Neo, connectId: 'NEO_USB' }, + ])( + 'sends the same Pro-style UTF-8 challenge to the device and verify API for $deviceType', + async ({ deviceType, connectId }) => { + const instanceId = '94537ae5-32e9-4417-860a-1d37c8decb3e'; + jest.mocked(settingsPersistAtom.get).mockResolvedValue({ + instanceId, + } as never); + const withHardwareProcessing = jest + .fn() + .mockImplementation(async (callback: () => Promise) => + callback(), + ); + const closeHardwareUiStateDialog = jest.fn(async () => undefined); + const backgroundApi = { + serviceHardwareUI: { + withHardwareProcessing, + closeHardwareUiStateDialog, + }, + serviceHardware: undefined as never as ServiceHardware, + }; + const service = new ServiceHardware({ + backgroundApi: backgroundApi as never as IBackgroundApi, + }); + backgroundApi.serviceHardware = service; + const deviceVerifySpy = jest.fn().mockResolvedValue({ + success: true, + payload: { + cert: 'cert', + signature: 'signature', + }, + }); + const postMock = jest + .fn() + .mockResolvedValue({ data: { code: 0, message: 'OK' } }); + jest.spyOn(service, 'getClient').mockResolvedValue({ + post: postMock, + } as never); + jest.spyOn(service, 'getSDKInstance').mockResolvedValue({ + deviceVerify: deviceVerifySpy, + } as never); + service.getCompatibleConnectId = jest.fn().mockResolvedValue(connectId); + await expect( + service.firmwareAuthenticate({ + device: { + connectId, + deviceType, + } as never, + }), + ).resolves.toMatchObject({ + verified: true, + result: { code: 0, message: 'OK' }, + payload: { + cert: 'cert', + signature: 'signature', + }, + }); + expect(deviceVerifySpy).toHaveBeenCalledTimes(1); + const deviceVerifyArg = deviceVerifySpy.mock.calls[0]?.[1] as { + dataHex: string; + }; + const postArg = postMock.mock.calls[0]?.[1] as { + data: string; + deviceType: string; + }; + const data = postArg?.data ?? ''; + const [uuid, timestamp, random] = data.split('_'); + expect(uuid).toBe(instanceId); + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, + ); + expect(Number.isNaN(Number(timestamp))).toBe(false); + expect(random).toMatch(/^[0-9A-Za-z]+$/); + expect(random).toHaveLength(12); + expect(deviceVerifyArg?.dataHex).toBe( + Buffer.from(data, 'utf8').toString('hex'), + ); + expect(postMock).toHaveBeenCalledWith( + '/wallet/v1/hardware/verify', + expect.objectContaining({ + deviceType, + data: expect.stringMatching( + new RegExp(`^${instanceId}_\\d+_[0-9A-Za-z]{12}$`), + ), + }), + ); + expect(closeHardwareUiStateDialog).toHaveBeenCalled(); + }, + ); + + it('does not require unavailable Pro2 attestation before wallet creation', async () => { + const service = new ServiceHardware({ + backgroundApi: {} as IBackgroundApi, + }); + jest.spyOn(localDb, 'getExistingDevice').mockResolvedValue(undefined); + + await expect( + service.shouldAuthenticateFirmware({ + device: { + connectId: 'PRO2_USB', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Pro2, + } as never, + }), + ).resolves.toBe(true); + }); + + 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); + jest.mocked(localDb.getDeviceSafe).mockResolvedValue({ + id: 'db-device-a', + connectId: 'DEVICE_A_USB', + deviceId: 'DEVICE_A_ID', + } as never); + + listeners.get(DEVICE.CONNECT)?.({ + device: { connectId: 'DEVICE_B_USB' }, + }); + await expect( + service.isHardwareDeviceConnected({ deviceDbId: 'db-device-a' }), + ).resolves.toBe(false); + + listeners.get(DEVICE.CONNECT)?.({ + device: { connectId: 'DEVICE_A_USB' }, + }); + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual( + expect.arrayContaining(['DEVICE_A_USB', 'DEVICE_B_USB']), + ); + await expect( + service.isHardwareDeviceConnected({ deviceDbId: 'db-device-a' }), + ).resolves.toBe(true); + + listeners.get(DEVICE.DISCONNECT)?.({ + device: { connectId: 'DEVICE_A_USB' }, + }); + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual(['DEVICE_B_USB']); + await expect( + service.isHardwareDeviceConnected({ deviceDbId: 'db-device-a' }), + ).resolves.toBe(false); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + }); + + it('forwards all tracked identity keys when a device disconnects', async () => { + const listeners = new Map void>(); + const notifyHardwareDeviceConnected = jest + .fn() + .mockResolvedValue(undefined); + const notifyHardwareDeviceDisconnected = jest + .fn() + .mockResolvedValue(undefined); + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwarePortfolioSync: { + notifyHardwareDeviceConnected, + notifyHardwareDeviceDisconnected, + }, + } as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn((event: string, listener: (payload: unknown) => void) => + listeners.set(event, listener), + ), + } as never); + + listeners.get(DEVICE.CONNECT)?.({ + device: { + connectId: 'PRO2_USB', + serialNo: 'PRO2_SERIAL', + uuid: 'PRO2_UUID', + }, + }); + listeners.get(DEVICE.DISCONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + + expect(notifyHardwareDeviceDisconnected).toHaveBeenCalledWith({ + identityKeys: ['PRO2_USB', 'PRO2_UUID', 'PRO2_SERIAL'], + }); + }); + + it('features 到达后补录设备身份并广播连接状态', 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); + + // DEVICE.CONNECT arrives before features are complete + listeners.get(DEVICE.CONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual(['PRO2_USB']); + jest.mocked(appEventBus.emit).mockClear(); + + listeners.get(DEVICE.SUPPORT_FEATURES)?.({ + device: { + connectId: 'PRO2_USB', + uuid: 'PRO2_UUID', + deviceId: 'PRO2_DEVICE_ID', + features: { device_id: 'PRO2_DEVICE_ID' }, + }, + }); + + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual( + expect.arrayContaining(['PRO2_USB', 'PRO2_UUID', 'PRO2_DEVICE_ID']), + ); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + + // An identical features event must not re-broadcast + jest.mocked(appEventBus.emit).mockClear(); + listeners.get(DEVICE.SUPPORT_FEATURES)?.({ + device: { + connectId: 'PRO2_USB', + uuid: 'PRO2_UUID', + deviceId: 'PRO2_DEVICE_ID', + features: { device_id: 'PRO2_DEVICE_ID' }, + }, + }); + expect(appEventBus.emit).not.toHaveBeenCalledWith( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + }); + + it('SDK 实例替换时清空连接身份并广播连接状态', 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); + listeners.get(DEVICE.CONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual(['PRO2_USB']); + jest.mocked(appEventBus.emit).mockClear(); + + // A replaced SDK instance no longer tracks the previous connections + await service.registerSdkEvents({ on: jest.fn() } as never); + + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual([]); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + }); + + it('resetHardwareSDK 时清空连接身份并广播连接状态', async () => { + const listeners = new Map void>(); + const service = new ServiceHardware({ + backgroundApi: { + serviceHardwareUI: { + runExclusiveOneKeyOperation: (fn: () => Promise) => fn(), + }, + } as unknown as IBackgroundApi, + }); + await service.registerSdkEvents({ + on: jest.fn((event: string, listener: (payload: unknown) => void) => + listeners.set(event, listener), + ), + } as never); + listeners.get(DEVICE.CONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual(['PRO2_USB']); + jest.mocked(appEventBus.emit).mockClear(); + + await service.resetHardwareSDK(); + + await expect( + service.getConnectedHardwareDeviceIdentityKeys(), + ).resolves.toEqual([]); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + }); + + 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('keeps wallets active after a successful device wipe', async () => { + const updateWalletsDeprecatedState = jest.fn().mockResolvedValue(true); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + 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(updateWalletsDeprecatedState).not.toHaveBeenCalled(); + expect(emitMock).not.toHaveBeenCalledWith( + EAppEventBusNames.WalletUpdate, + undefined, + ); + }); + + it('refreshes wallet consumers after a firmware switch deprecates wallets', async () => { + const updateWalletsDeprecatedState = jest.fn().mockResolvedValue(true); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + getAllHwQrWalletWithDevice: jest.fn().mockResolvedValue({ + 'hw-wallet-1': { + wallet: { id: 'hw-wallet-1' }, + device: { connectId: 'CLASSIC_USB' }, + }, + }), + updateWalletsDeprecatedState, + }, + } as unknown as IBackgroundApi, + }); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on this binding. + const emitMock = jest.mocked(appEventBus.emit); + emitMock.mockClear(); + + await service.updateHwWalletsDeprecatedStatus({ + connectId: 'CLASSIC_USB', + }); + + 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('rebinds firmware progress events after the SDK instance changes', async () => { + const createInstance = () => { + const listeners = new Map< + string, + (payload: unknown) => void | Promise + >(); + return { + listeners, + instance: { + on: jest.fn( + ( + event: string, + listener: (payload: unknown) => void | Promise, + ) => { + listeners.set(event, listener); + }, + ), + }, + }; + }; + const firstSdk = createInstance(); + const replacementSdk = createInstance(); + const setHardwareUiStateMock = jest.mocked(hardwareUiStateAtom.set); + setHardwareUiStateMock.mockClear(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + + await service.registerSdkEvents(firstSdk.instance as never); + await service.registerSdkEvents(replacementSdk.instance as never); + await replacementSdk.listeners.get(UI_EVENT)?.({ + type: UI_REQUEST.FIRMWARE_PROGRESS, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + progress: 25, + progressType: 'transferData', + }, + }); + + expect(replacementSdk.instance.on).toHaveBeenCalledWith( + UI_EVENT, + expect.any(Function), + ); + const updater = setHardwareUiStateMock.mock.calls.at(-1)?.[0]; + const state = typeof updater === 'function' ? updater(undefined) : updater; + expect(state).toMatchObject({ + action: EHardwareUiStateAction.FIRMWARE_PROGRESS, + connectId: 'PRO2_USB', + payload: { + firmwareProgress: 25, + firmwareProgressType: 'transferData', + }, + }); + }); + + it('preserves the firmware tip when the next progress event arrives', 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.FIRMWARE_TIP, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + data: { message: 'ConfirmOnDevice' }, + }, + }); + const tipUpdater = setHardwareUiStateMock.mock.calls.at(-1)?.[0]; + const tipState = + typeof tipUpdater === 'function' ? tipUpdater(undefined) : tipUpdater; + + await listeners.get(UI_EVENT)?.({ + type: UI_REQUEST.FIRMWARE_PROGRESS, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + progress: 0, + progressType: 'installingFirmware', + }, + }); + const progressUpdater = setHardwareUiStateMock.mock.calls.at(-1)?.[0]; + const progressState = + typeof progressUpdater === 'function' + ? progressUpdater(tipState) + : progressUpdater; + + expect(progressState).toMatchObject({ + action: EHardwareUiStateAction.FIRMWARE_PROGRESS, + connectId: 'PRO2_USB', + payload: { + firmwareProgress: 0, + firmwareProgressType: 'installingFirmware', + firmwareTipData: { message: 'ConfirmOnDevice' }, + }, + }); + }); + + it('preserves queued firmware progress across the expected install disconnect', 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 uiListener = listeners.get(UI_EVENT); + + const createProgressEvent = (progress: number) => ({ + type: UI_REQUEST.FIRMWARE_PROGRESS, + payload: { + device: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + progress, + progressType: 'transferData', + }, + }); + + await uiListener?.(createProgressEvent(1)); + const progress25 = uiListener?.(createProgressEvent(25)); + const progress50 = uiListener?.(createProgressEvent(50)); + await listeners.get(DEVICE.DISCONNECT)?.({ + device: { connectId: 'PRO2_USB' }, + }); + await Promise.all([progress25, progress50]); + + const firmwareProgressValues = setHardwareUiStateMock.mock.calls + .map(([updater]) => + typeof updater === 'function' ? updater(undefined) : updater, + ) + .filter( + (state) => state?.action === EHardwareUiStateAction.FIRMWARE_PROGRESS, + ) + .map((state) => state?.payload?.firmwareProgress); + expect(firmwareProgressValues).toEqual([1, 25, 50]); + }); + + 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', + expect.objectContaining({ + changedKeys: ['identity.label'], + revision: 2, + source: 'apply-settings', + }), + ); + // Device identifiers must never enter hardwareLog unmasked. + expect(JSON.stringify(hardwareLogSpy.mock.calls)).not.toContain( + 'PRO2_SERIAL', + ); + expect(JSON.stringify(hardwareLogSpy.mock.calls)).not.toContain('PRO2_USB'); + 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('keeps the old wallet active 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 notifyHardwareDeviceIdentityMismatch = jest + .fn() + .mockResolvedValue(undefined); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + updateWalletsDeprecatedState, + }, + serviceHardwarePortfolioSync: { + notifyHardwareDeviceIdentityMismatch, + }, + } 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).not.toHaveBeenCalled(); + expect(notifyHardwareDeviceIdentityMismatch).toHaveBeenCalledWith({ + deviceDbId: 'db-device-1', + expectedDeviceId: 'OLD_DEVICE_ID', + }); + expect(emitMock).not.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.each([EDeviceType.Pro2, EDeviceType.Neo])( + 'writes the %s label back to app state after changing the device label', + async (deviceType) => { + const setWalletNameAndAvatar = jest.fn().mockResolvedValue(undefined); + const currentState = { + protocol: 'V2', + revision: 4, + updatedAt: 100, + identity: { + deviceId: 'DEVICE_ID', + serialNo: 'DEVICE_SERIAL', + deviceType, + label: 'Old label', + }, + status: {}, + settings: {}, + versions: {}, + }; + jest.mocked(localDb.getDeviceSafe).mockResolvedValue({ + id: 'db-device-1', + connectId: 'DEVICE_CONNECT_ID', + deviceStateInfo: currentState, + } as never); + jest.mocked(localDb.updateDeviceState).mockResolvedValue({ + kind: 'updated', + deviceDbId: 'db-device-1', + state: currentState, + } as never); + const service = new ServiceHardware({ + backgroundApi: { + serviceAccount: { + getWalletSafe: jest.fn().mockResolvedValue({ + associatedDevice: 'db-device-1', + name: 'Wallet', + }), + setWalletNameAndAvatar, + }, + } 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: 'hw-wallet-1', + label: 'Renamed Pro 2', + }); + + // 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: 'DEVICE_CONNECT_ID', + revision: 5, + source: 'settings-write', + state: expect.objectContaining({ + revision: 5, + identity: expect.objectContaining({ + deviceType, + label: 'Renamed Pro 2', + }), + }), + }), + ); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.HardwareDeviceStateUpdate, + expect.objectContaining({ + changedKeys: ['identity.label'], + revision: 5, + }), + ); + // oxlint-disable-next-line typescript/unbound-method -- Jest mock does not depend on a bound this + expect(setWalletNameAndAvatar).toHaveBeenCalledWith({ + walletId: 'hw-wallet-1', + name: 'Renamed Pro 2', + shouldCheckDuplicate: false, + }); + expect(appEventBus.emit).not.toHaveBeenCalledWith( + EAppEventBusNames.SyncDeviceLabelToWalletName, + expect.anything(), + ); + }, + ); +}); + +describe('ServiceHardware.fetchHardwareHomeScreen', () => { + it.each([EDeviceType.Pro2, EDeviceType.Neo] as const)( + 'requests %s homescreens with the native device type', + async (deviceType) => { + const get = jest.fn().mockResolvedValue({ + data: { + data: [ + { + id: `${deviceType}-wallpaper`, + wallpaperType: 'default', + resType: 'custom', + url: `https://example.com/${deviceType}-wallpaper.png`, + deviceTypes: [deviceType], + }, + { + 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, + serialNumber: 'PR9999999999', + firmwareVersion: '1.0.0', + }), + ).resolves.toEqual([ + { + id: `${deviceType}-wallpaper`, + wallpaperType: 'default', + resType: 'custom', + url: `https://example.com/${deviceType}-wallpaper.png`, + screenHex: undefined, + nameHex: undefined, + }, + ]); + expect(get).toHaveBeenCalledWith('/utility/v1/wallet-homescreen/list', { + params: { + deviceType, + serialNumber: 'PR9999999999', + firmwareVersion: '1.0.0', + }, + }); + }, + ); +}); + +describe('ServiceHardware.fetchFirmwareVerifyHash', () => { + it.each([EDeviceType.Pro2, EDeviceType.Neo] as const)( + 'requests firmware/detail with the native %s device type', + async (deviceType) => { + const get = jest.fn().mockResolvedValue({ + data: { + data: { + firmwares: [], + }, + }, + }); + const backgroundApi = { + serviceHardware: undefined as never as ServiceHardware, + }; + const service = new ServiceHardware({ + backgroundApi: backgroundApi as never as IBackgroundApi, + }); + backgroundApi.serviceHardware = service; + jest.spyOn(service, 'getClient').mockResolvedValue({ + get, + } as never); + + await service.hardwareVerifyManager.fetchFirmwareVerifyHash({ + deviceType, + firmwareVersion: '1.0.0', + bluetoothVersion: '1.0.0', + bootloaderVersion: '1.0.0', + firmwareType: EFirmwareType.Universal, + }); + + expect(get).toHaveBeenCalledWith('/utility/v1/firmware/detail', { + params: { + deviceType, + system: '1.0.0', + bluetooth: '1.0.0', + bootloader: '1.0.0', + firmwareType: 'universal', + }, + }); + }, + ); +}); + +describe('ServiceHardware.cancel Pro2 operation', () => { + const createCancelService = ({ + deviceType = EDeviceType.Pro2, + }: { + deviceType?: EDeviceType | null; + } = {}) => { + const sdkCancel = jest.fn(); + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + service.getSDKInstance = jest.fn().mockResolvedValue({ + cancel: sdkCancel, + } as unknown as Awaited>); + service.getCompatibleConnectId = jest + .fn() + .mockResolvedValue('PRO2_BLE_CONNECT_ID'); + jest.mocked(localDb.getDeviceByQuery).mockResolvedValue( + deviceType + ? ({ + connectId: 'PRO2_SERIAL', + deviceType, + } as never) + : undefined, + ); + return { sdkCancel, service }; + }; + + it('sends an explicit user Cancel immediately', async () => { + const { sdkCancel, service } = createCancelService(); + + const cancelPromise = service.cancel({ + connectId: 'PRO2_SERIAL', + immediate: true, + }); + + clearTimeout(service.cancelTimer); + await cancelPromise; + + expect(sdkCancel).toHaveBeenCalledTimes(1); + expect(sdkCancel).toHaveBeenCalledWith('PRO2_BLE_CONNECT_ID'); + }); + + it('sends Cancel for Neo as well', async () => { + const { sdkCancel, service } = createCancelService({ + deviceType: EDeviceType.Neo, + }); + + await service.cancel({ + connectId: 'NEO_SERIAL', + immediate: true, + }); + + expect(sdkCancel).toHaveBeenCalledTimes(1); + }); + + it('lets the SDK decide Cancel for Classic or Pro1', async () => { + const { sdkCancel, service } = createCancelService({ + deviceType: EDeviceType.Classic, + }); + + await service.cancel({ + connectId: 'CLASSIC_SERIAL', + immediate: true, + }); + + expect(sdkCancel).toHaveBeenCalledTimes(1); + }); + + it('lets the SDK decide Cancel when the device type is unknown', async () => { + const { sdkCancel, service } = createCancelService({ deviceType: null }); + + await service.cancel({ + connectId: 'UNKNOWN_SERIAL', + immediate: true, + }); + + expect(sdkCancel).toHaveBeenCalledTimes(1); + }); + + it('still cancels when the caller supplies Unknown', async () => { + const { sdkCancel, service } = createCancelService({ + deviceType: EDeviceType.Pro2, + }); + + await service.cancel({ + connectId: 'PRO2_SERIAL', + immediate: true, + deviceType: EDeviceType.Unknown, + }); + + expect(sdkCancel).toHaveBeenCalledTimes(1); + expect(sdkCancel).toHaveBeenCalledWith('PRO2_BLE_CONNECT_ID'); + }); + + it('keeps automatic cleanup cancellation debounced', async () => { + const { sdkCancel, service } = createCancelService(); + + await service.cancel({ connectId: 'PRO2_SERIAL' }); + clearTimeout(service.cancelTimer); + + expect(sdkCancel).not.toHaveBeenCalled(); + }); +}); 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..7722a35a5bf5 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts @@ -1,4 +1,8 @@ -import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; +import { + EDeviceType, + EFirmwareType, + isSameOnekeyBleName, +} from '@onekeyfe/hd-shared'; import { Semaphore } from 'async-mutex'; import { uniq } from 'lodash'; import semver from 'semver'; @@ -11,6 +15,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'; @@ -26,6 +31,11 @@ import { checkBLEPermissions, checkBLEState, } from '@onekeyhq/shared/src/hardware/blePermissions'; +import { + DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS, + DESKTOP_BLE_SILENT_BIND_CONNECTION_TIMEOUT_MS, +} from '@onekeyhq/shared/src/hardware/connectionTimeouts'; +import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { CoreSDKLoader, getHardwareSDKInstance, @@ -33,6 +43,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'; @@ -42,6 +56,8 @@ import deviceHomeScreenUtils, { T1_HOME_SCREEN_DEFAULT_IMAGES, } from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { devOnlyData } from '@onekeyhq/shared/src/utils/devModeUtils'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import numberUtils from '@onekeyhq/shared/src/utils/numberUtils'; import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -54,6 +70,7 @@ import type { IFirmwareReleasePayload, IHardwareCallContext, IOneKeyDeviceFeatures, + IOneKeyDeviceState, } from '@onekeyhq/shared/types/device'; import { EHardwareCallContext, @@ -74,9 +91,16 @@ import { settingsPersistAtom, } from '../../states/jotai/atoms'; import ServiceBase from '../ServiceBase'; +import { getFirmwareManifestSnapshot } from '../ServiceFirmwareUpdate/FirmwareManifestProvider'; 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 +109,6 @@ import type { IThirdPartyHardwareAdapter, } from './adapters/types'; import type { - IBaseDeviceProcessingParams, IChangePinParams, IDeviceHomeScreenConfig, IGetDeviceAdvanceSettingsParams, @@ -93,6 +116,7 @@ import type { IHardwareHomeScreenData, ISetAutoLockDelayMsParams, ISetAutoShutDownDelayMsParams, + ISetBrightnessParams, ISetDeviceHomeScreenParams, ISetDeviceLabelParams, ISetHapticFeedbackParams, @@ -106,6 +130,7 @@ import type { IShouldAuthenticateFirmwareParams, } from './HardwareVerifyManager'; import type { IHardwareHomeScreenResponse } from './ServerType'; +import type { IDBDevice } from '../../dbs/local/types'; import type { ISimpleDBAppStatus } from '../../dbs/simple/entity/SimpleDbEntityAppStatus'; import type { IOffscreenEventMap, @@ -117,32 +142,218 @@ 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, + NEO_DEVICE_TYPE, +]); +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; +}; + +type IProtocolV2NftCoreApi = CoreApi & { + deviceUploadNft?: ( + connectId: string, + params: { + imageJpegBase64: string; + thumbnailJpegBase64: string; + title: string; + subtitle: string; + timestampMs?: number; + }, + ) => ReturnType; +}; + +type IGetSDKInstanceOptions = { + connectId: string | undefined; + connectProtocol?: HardwareConnectProtocol; + forceProtocolDetection?: boolean; + hardwareCallContext?: EHardwareCallContext; + hardwareTransportType?: EHardwareTransportType; + forceFirmwareManifestRefresh?: boolean; +}; + +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; withHardwareProcessing?: boolean; silentMode?: boolean; + /** 与 connectId 同一次解析出的传输类型;传入后不得再次自动选路。 */ + hardwareTransportType?: EHardwareTransportType; params?: CommonParams & { allowEmptyConnectId?: boolean; + forceProtocolDetection?: boolean; }; hardwareCallContext?: IHardwareCallContext; }; +export type IDeviceGetStateOptions = Omit< + IDeviceGetFeaturesOptions, + 'params' +> & { + /** Reuse an existing desktop BLE link without scanning or reconnecting. */ + desktopBleReuseConnectedOnly?: boolean; + params?: GetDeviceStateParams & { + allowEmptyConnectId?: boolean; + }; +}; + +export type IDeviceManagementSnapshot = { + state: IOneKeyDeviceState; +}; + +export type IUploadPro2NftParams = { + connectId: string; + imageJpegBase64: string; + thumbnailJpegBase64: string; + title: string; + subtitle: string; + timestampMs?: number; +}; + +const nullableToUndefined = (value?: string | null) => value ?? undefined; + +function getPersistedDesktopBleConnectId( + device: + | { + connectId?: string | null; + usbConnectId?: string | null; + bleConnectId?: string | null; + } + | undefined, +): string | undefined { + const bleConnectId = device?.bleConnectId?.trim(); + if (!bleConnectId) { + return undefined; + } + const normalizedBleConnectId = bleConnectId.toLowerCase(); + const aliasesUsbConnectId = [device?.connectId, device?.usbConnectId].some( + (candidate) => candidate?.trim().toLowerCase() === normalizedBleConnectId, + ); + return aliasesUsbConnectId ? undefined : bleConnectId; +} + +// Evidence window for treating a caller-held connectId as a live session. +// Receiving device traffic implies the OS pairing already exists, so only +// connectIds stamped this recently may be probed by +// silentlyBindLiveDesktopBleConnectId; probing anything else could summon +// the OS pairing prompt without any app guidance UI. +const LIVE_CONNECT_ID_EVIDENCE_WINDOW_MS = 60_000; + +const isOneKeyLoaderMode = (mode?: string | null) => + mode === EOneKeyDeviceMode.bootloader || mode === EOneKeyDeviceMode.romloader; + +const supportsDedicatedFirmwareFeatures = (deviceType: IDeviceType) => + deviceType === EDeviceType.Touch || + deviceType === EDeviceType.Pro || + deviceType === EDeviceType.Pro2 || + deviceType === NEO_DEVICE_TYPE; + +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,7 +378,228 @@ const LINUX_UDEV_RULES_INSTALL_MAX_ATTEMPTS = 2; @backgroundClass() class ServiceHardware extends ServiceBase { - private bridgeAvailabilityChecked = false; + 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 activeHardwareTransportType: EHardwareTransportType | undefined; + + private sdkInstanceMutex = new Semaphore(1); + + private async runInDesktopBleConnectedOnlyScope({ + connectId, + enabled, + task, + }: { + connectId?: string; + enabled?: boolean; + task: () => Promise; + }): Promise { + if (!enabled) { + return task(); + } + const nobleBle = globalThis.desktopApi?.nobleBle; + if ( + !connectId || + !nobleBle?.beginConnectedOnlyScope || + !nobleBle.endConnectedOnlyScope + ) { + throw new OneKeyLocalError( + 'Desktop BLE connected-only scope is unavailable', + ); + } + const scopeId = nobleBle.beginConnectedOnlyScope(connectId); + try { + return await task(); + } finally { + nobleBle.endConnectedOnlyScope(connectId, scopeId); + } + } + + 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 linuxUdevRulesReadyPromise: Promise | undefined; @@ -191,6 +623,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, @@ -238,6 +781,56 @@ class ServiceHardware extends ServiceBase { }, ); + private async writeBackProtocolV2DeviceLabel({ + dbDeviceId, + label, + }: { + dbDeviceId: string; + label: string; + }) { + try { + const device = await localDb.getDeviceSafe(dbDeviceId); + const currentState = device?.deviceStateInfo; + if ( + !device || + currentState?.protocol !== 'V2' || + currentState.identity.label === label + ) { + return; + } + const revision = currentState.revision + 1; + const updatedAt = Math.max(Date.now(), currentState.updatedAt + 1); + const event: DeviceStateEvent = { + connectId: + device.connectId || + device.usbConnectId || + device.bleConnectId || + null, + changedKeys: ['identity.label'], + revision, + source: 'settings-write', + state: { + ...currentState, + identity: { + ...currentState.identity, + label, + }, + revision, + updatedAt, + }, + }; + const persistResult = await localDb.updateDeviceState(event); + if (persistResult.kind === 'updated') { + appEventBus.emit(EAppEventBusNames.HardwareDeviceStateUpdate, event); + } + } catch (error) { + serviceHardwareUtils.hardwareLog( + 'device label app write-back failed', + devOnlyData(error instanceof Error ? error.message : error), + ); + } + } + handleHardwareAvatarChanged = cacheUtils.memoizee( async ({ walletId, @@ -276,8 +869,124 @@ class ServiceHardware extends ServiceBase { private registeredEvents = false; + private registeredSdkEventsInstance: CoreApi | undefined; + + private registeredSdkDebugLogging = false; + + private sdkInstanceEpoch = 0; + + private hardwareUiEventQueue = new HardwareUiEventQueue(); + + private hardwareUiEventState = createHardwareUiEventState(); + + private firmwareProgressConnectIdsSinceDisconnect = new Set(); + private connectedDeviceTracked = new Set(); + private connectedDeviceIdentityKeysByConnection = new Map< + string, + Set + >(); + + private deviceSearchInProgressCount = 0; + + private getConnectedDeviceIdentityKeys(device: KnownDevice | undefined) { + if (!device) { + return []; + } + const deviceWithSerial = device as KnownDevice & { serialNo?: string }; + let deviceId: string | undefined; + if (device.features) { + try { + deviceId = deviceUtils.getRawDeviceId({ + device: device as any, + features: device.features, + }); + } catch { + // Connect events can arrive before features are complete, so fall back + // to connectId, uuid, or serialNo. + } + } + return uniq( + [ + device.connectId, + device.uuid, + deviceWithSerial.serialNo, + deviceId, + ].filter((value): value is string => Boolean(value)), + ); + } + + private trackConnectedDevice(device: KnownDevice | undefined): { + identityKeys: string[]; + identityKeysChanged: boolean; + } { + const identityKeys = this.getConnectedDeviceIdentityKeys(device); + const connectionKey = device?.connectId || identityKeys[0]; + if (!connectionKey || identityKeys.length === 0) { + return { identityKeys, identityKeysChanged: false }; + } + const existingIdentityKeys = + this.connectedDeviceIdentityKeysByConnection.get(connectionKey); + const identityKeysChanged = + !existingIdentityKeys || + existingIdentityKeys.size !== identityKeys.length || + identityKeys.some((key) => !existingIdentityKeys.has(key)); + if (identityKeysChanged) { + this.connectedDeviceIdentityKeysByConnection.set( + connectionKey, + new Set(identityKeys), + ); + } + return { identityKeys, identityKeysChanged }; + } + + private clearTrackedConnectedDevices() { + if (this.connectedDeviceIdentityKeysByConnection.size === 0) { + return; + } + // The identity map is a bg-runtime JS copy; UI runtimes cache their own + // snapshot, so every clear must broadcast or the green indicator goes + // stale after an SDK reset or transport switch. + this.connectedDeviceIdentityKeysByConnection.clear(); + serviceHardwareUtils.hardwareLog('cleared all tracked connected devices'); + appEventBus.emit( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + } + + private untrackConnectedDevice(device: KnownDevice | undefined) { + const disconnectedKeys = new Set( + this.getConnectedDeviceIdentityKeys(device), + ); + const removedIdentityKeys = new Set(disconnectedKeys); + for (const [connectionKey, identityKeys] of this + .connectedDeviceIdentityKeysByConnection) { + if ( + disconnectedKeys.has(connectionKey) || + [...disconnectedKeys].some((key) => identityKeys.has(key)) + ) { + removedIdentityKeys.add(connectionKey); + for (const identityKey of identityKeys) { + removedIdentityKeys.add(identityKey); + } + this.connectedDeviceIdentityKeysByConnection.delete(connectionKey); + } + } + return [...removedIdentityKeys]; + } + + private resetHardwareUiEventQueue() { + this.hardwareUiEventQueue.reset(); + this.hardwareUiEventState = createHardwareUiEventState(); + this.firmwareProgressConnectIdsSinceDisconnect.clear(); + } + + private firmwareManifestRefreshMutex = new Semaphore(1); + + private loadedFirmwareManifestKey: string | undefined; + checkSdkVersionValid() { if (process.env.NODE_ENV !== 'production') { const { @@ -308,15 +1017,45 @@ class ServiceHardware extends ServiceBase { } } - async getSDKInstance(options: { - connectId: string | undefined; - hardwareCallContext?: EHardwareCallContext; - }) { + async getSDKInstance(options: IGetSDKInstanceOptions) { + return this.sdkInstanceMutex.runExclusive(() => + this.getSDKInstanceWithLifecycleLock(options), + ); + } + + private async getSDKInstanceWithLifecycleLock( + options: IGetSDKInstanceOptions, + ) { + if ( + options.forceFirmwareManifestRefresh && + (platformEnv.isNative || platformEnv.isDesktop) + ) { + return this.firmwareManifestRefreshMutex.runExclusive(() => + this.getSDKInstanceInternal(options), + ); + } + return this.getSDKInstanceInternal(options); + } + + private async getSDKInstanceInternal(options: IGetSDKInstanceOptions) { const { hardwareCallContext = EHardwareCallContext.USER_INTERACTION } = options || {}; 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,39 +1065,107 @@ class ServiceHardware extends ServiceBase { await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( 'showDeviceDebugLogs', ); + const showSdkDebugLogs = + platformEnv.isDev === true && + (platformEnv.isNative === true || platformEnv.isDesktop === true) && + debugMode === true; + const isAppManagedManifest = Boolean( + platformEnv.isNative || platformEnv.isDesktop, + ); + const refreshedFirmwareManifest = + options.forceFirmwareManifestRefresh && isAppManagedManifest + ? await getFirmwareManifestSnapshot({ + preRelease: isPreRelease === true, + forceRefresh: true, + }) + : undefined; + const refreshedFirmwareManifestKey = refreshedFirmwareManifest + ? stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config: refreshedFirmwareManifest, + }) + : undefined; + if ( + refreshedFirmwareManifestKey && + this.loadedFirmwareManifestKey && + refreshedFirmwareManifestKey !== this.loadedFirmwareManifestKey + ) { + await resetHardwareSDKInstance(); + this.clearTrackedConnectedDevices(); + this.registeredEvents = false; + } - let hardwareTransportType = + if ( + this.registeredEvents && + this.registeredSdkDebugLogging !== showSdkDebugLogs + ) { + this.resetHardwareUiEventQueue(); + this.registeredEvents = false; + } + this.registeredSdkDebugLogging = showSdkDebugLogs; + + const currentTransportType = await this.connectionManager.getCurrentTransportType(); + const { forceTransportType } = await hardwareForceTransportAtom.get(); + const isDesktopBackgroundCall = + platformEnv.isSupportDesktopBle && + (hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK || + hardwareCallContext === + EHardwareCallContext.BACKGROUND_NON_INTERACTIVE); + const normalizedForceTransportType = forceTransportType + ? deviceUtils.normalizeHardwareTransportTypeForPlatform({ + transportType: forceTransportType, + connectProtocol: resolvedConnectProtocol, + }) + : undefined; + const effectiveForceTransportType = + isDesktopBackgroundCall && options.hardwareTransportType + ? undefined + : normalizedForceTransportType; + let hardwareTransportType = + effectiveForceTransportType ?? + options.hardwareTransportType ?? + currentTransportType; let shouldSwitch = false; // Desktop Auto switch transport type - if (platformEnv.isSupportDesktopBle) { + if ( + platformEnv.isSupportDesktopBle && + effectiveForceTransportType === undefined && + options.hardwareTransportType === undefined + ) { // 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; hardwareTransportType = result.targetType; - // If transport type needs to be switched, update it - if (shouldSwitch) { - const currentTransportType = - await this.connectionManager.getCurrentTransportType(); - console.log( - `🔄 TRANSPORT SWITCH: ${ - currentTransportType ?? 'null' - } → ${hardwareTransportType}`, - ); - // Reset SDK instance to use new transport type - await resetHardwareSDKInstance(); - this.registeredEvents = false; + } - console.log('✅ TRANSPORT SWITCH: SDK reset completed'); - } + // connectionManager 会在 UI 展示前提交选路结果,因此不能再用它的 + // currentTransportType 判断 SDK 是否需要重建。单独记录实际 SDK transport, + // 保证显式传入的 transport + connectId 不会复用上一个 transport 实例。 + const sdkTransportChanged = + this.activeHardwareTransportType !== undefined && + this.activeHardwareTransportType !== hardwareTransportType; + if (shouldSwitch || sdkTransportChanged) { + console.log( + `🔄 TRANSPORT SWITCH: ${ + this.activeHardwareTransportType ?? currentTransportType ?? 'null' + } → ${hardwareTransportType}`, + ); + this.resetHardwareUiEventQueue(); + await resetHardwareSDKInstance(); + this.clearTrackedConnectedDevices(); + this.registeredEvents = false; + this.activeHardwareSDKInstance = undefined; + console.log('✅ TRANSPORT SWITCH: SDK reset completed'); } // Update the connection manager's current transport type AFTER switch logic - this.connectionManager.setCurrentTransportType(hardwareTransportType); + await this.connectionManager.setCurrentTransportType(hardwareTransportType); try { const instance = await getHardwareSDKInstance({ @@ -368,25 +1175,73 @@ class ServiceHardware extends ServiceBase { isPreRelease: isPreRelease === true, hardwareConnectSrc, debugMode, + loadFirmwareConfig: async () => { + let config = refreshedFirmwareManifest; + if (!config) { + try { + config = await getFirmwareManifestSnapshot({ + preRelease: isPreRelease === true, + }); + } catch { + this.loadedFirmwareManifestKey = stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config: null, + }); + defaultLogger.hardware.sdkLog.log( + 'firmware_manifest_unavailable', + isPreRelease === true ? 'pre-release' : 'stable', + ); + return undefined; + } + } + this.loadedFirmwareManifestKey = stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config, + }); + return config; + }, }); + this.activeHardwareTransportType = hardwareTransportType; + // TODO re-register events when hardwareConnectSrc or isPreRelease changed - await this.checkBridgeAndFallbackToWebUSB({ - hardwareSDKInstance: 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, }); - await this.registerSdkEvents(instance); 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 +1259,11 @@ class ServiceHardware extends ServiceBase { private async specialProcessingEvent({ originEvent, usedPayload, + isCurrent, }: { originEvent: UiEvent; usedPayload: IHardwareUiPayload; + isCurrent: () => boolean; }): Promise<{ uiRequestType: EHardwareUiStateAction; payload: IHardwareUiPayload; @@ -420,13 +1277,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 +1304,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 +1314,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 +1332,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 +1359,28 @@ class ServiceHardware extends ServiceBase { }; } - async registerSdkEvents(instance: CoreApi) { + async registerSdkEvents( + instance: CoreApi, + { + showSdkDebugLogs = false, + }: { + showSdkDebugLogs?: boolean; + } = {}, + ) { + if (this.registeredSdkEventsInstance !== instance) { + this.resetHardwareUiEventQueue(); + this.clearTrackedConnectedDevices(); + this.registeredEvents = false; + } + if (!this.registeredEvents) { + this.resetHardwareUiEventQueue(); this.registeredEvents = true; + this.registeredSdkEventsInstance = instance; + this.registeredSdkDebugLogging = showSdkDebugLogs; + this.sdkInstanceEpoch += 1; + const sdkInstanceEpoch = this.sdkInstanceEpoch; + let deviceStateEventSequence = 0; const { UI_EVENT, DEVICE, @@ -489,100 +1389,342 @@ 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, - }); + instance.on(UI_EVENT, (e) => { + if (e.type === EHardwareUiStateAction.FIRMWARE_PROGRESS) { + const connectId = e.payload?.device?.connectId; + if (connectId) { + this.firmwareProgressConnectIdsSinceDisconnect.add(connectId); + } + } + return 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; + } - // >>> mock hardware forceInputOnDevice - // if (usedPayload) { - // usedPayload.supportInputPinOnSoftware = false; - // } + 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, + }); - // 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, + 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( + (previousState): IHardwareUiState => { + const isSameFirmwareDevice = + previousState?.connectId === appliedConnectId; + let firmwarePayload = appliedPayload; + if ( + isSameFirmwareDevice && + appliedUiRequestType === + EHardwareUiStateAction.FIRMWARE_PROGRESS && + previousState?.payload?.firmwareTipData + ) { + firmwarePayload = { + ...appliedPayload, + firmwareTipData: previousState.payload.firmwareTipData, + }; + } else if ( + isSameFirmwareDevice && + appliedUiRequestType === + EHardwareUiStateAction.FIRMWARE_TIP + ) { + firmwarePayload = { + ...appliedPayload, + firmwareProgress: + previousState?.payload?.firmwareProgress, + firmwareProgressType: + previousState?.payload?.firmwareProgressType, + }; + } + return { + action: appliedUiRequestType, + connectId: appliedConnectId, + payload: firmwarePayload, + }; + }, + ); + if (!isCurrent()) { + return; + } + } + } + if (!isCurrent()) { + return; + } + await hardwareUiStateCompletedAtom.set({ + action: appliedUiRequestType, + connectId: appliedConnectId, + payload: appliedPayload, + }); + }) + .catch((error: unknown) => { + defaultLogger.hardware.sdkLog.log( + 'hardware-ui-event-queue', + error instanceof Error ? error.message : 'Unknown event error', + ); }); + }); - if (NEW_DIALOG_EVENTS.has(newUiRequestType)) { - appEventBus.emit(EAppEventBusNames.RequestHardwareUIDialog, { - uiRequestType: newUiRequestType, + instance.on(DEVICE.STATE, async (event: DeviceStateEvent) => { + this.recordLiveConnectIdEvidence(event.connectId); + deviceStateEventSequence += 1; + const sdkEventSequence = deviceStateEventSequence; + serviceHardwareUtils.hardwareLog('device state update', { + revision: event.revision, + source: event.source, + changedKeys: event.changedKeys, + // Device identifiers must stay masked in persisted logs (see the + // PRO2_SERIAL contract in ServiceHardware.pro2DeviceManagement + // tests); the suffix is enough to correlate multi-device sessions. + connectId: serviceHardwareUtils.maskLogIdentifier(event.connectId), + serialNo: serviceHardwareUtils.maskLogIdentifier( + event.state?.identity?.serialNo, + ), + // The device-reported language is the key evidence for language + // sync issues (OK-60121); keep it visible in persisted logs. + language: event.state?.settings?.language, + updatedAt: event.state?.updatedAt, + }); + 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', + devOnlyData(error instanceof Error ? error.message : error), + ); + } + serviceHardwareUtils.hardwareLog('device state persist result', { + kind: persistenceResult?.kind ?? 'unknown', + reason: + persistenceResult?.kind === 'ignored' + ? persistenceResult.reason + : undefined, + revision: event.revision, + source: event.source, + eventLanguage: event.state?.settings?.language, + persistedLanguage: + persistenceResult?.kind === 'updated' + ? persistenceResult.state.settings?.language + : undefined, }); - } 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, - ); - } else { - if (newUiRequestType === ('ui-device_progress' as any)) { - console.log('ui-device_progress', originEvent); + if (persistenceResult?.kind === 'identity-mismatch') { + await this.backgroundApi.serviceHardwarePortfolioSync + ?.notifyHardwareDeviceIdentityMismatch({ + deviceDbId: persistenceResult.deviceDbId, + expectedDeviceId: persistenceResult.currentDeviceId, + }) + .catch(() => undefined); + 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', + devOnlyData(error instanceof Error ? error.message : 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; + } + + // DEVICE.CONNECT can fire before features are complete, so the + // tracked identity may miss the raw deviceId; re-track once features + // arrive so deviceId-based consumers see the device as connected. + const { identityKeysChanged } = this.trackConnectedDevice( + message.device ?? undefined, + ); + if (identityKeysChanged) { + appEventBus.emit( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + } // TODO: save features to dbDevice - serviceHardwareUtils.hardwareLog('features update', features); + // Full features dumps are dev-only; production logs keep the event + // name without the device blob. + serviceHardwareUtils.hardwareLog( + 'features update', + devOnlyData(features), + ); void localDb.updateDevice({ features, @@ -591,9 +1733,38 @@ class ServiceHardware extends ServiceBase { ); instance.on(DEVICE.CONNECT, (message: { device: KnownDevice }) => { + this.recordLiveConnectIdEvidence(message.device?.connectId); + const { identityKeys: connectedIdentityKeys } = + this.trackConnectedDevice(message.device); + if (connectedIdentityKeys.length > 0) { + appEventBus.emit( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + void this.backgroundApi.serviceHardwarePortfolioSync + ?.notifyHardwareDeviceConnected({ + identityKeys: connectedIdentityKeys, + }) + .catch(() => undefined); + } + 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 +1804,58 @@ class ServiceHardware extends ServiceBase { })(); }); + instance.on(DEVICE.DISCONNECT, (message: { device: KnownDevice }) => { + // A disconnect ends the "connected and OS-paired right now" proof: + // factory reset and OS-level unpair both surface as a disconnect + // first, so the silent BLE bind probe must not trust this endpoint + // again until new traffic re-stamps it. + this.clearLiveConnectIdEvidence(message.device?.connectId); + const disconnectedIdentityKeys = this.untrackConnectedDevice( + message.device, + ); + // The whole eviction path used to be silent, so a disconnect that + // never arrived and one that simply left no trace looked identical in + // collected logs (OK-60486). + serviceHardwareUtils.hardwareLog('device disconnected, untracked', { + // Persisted logs ship with user feedback, so the identifier stays + // masked; the suffix is enough to correlate a multi-device session. + connectId: serviceHardwareUtils.maskLogIdentifier( + message.device?.connectId, + ), + removedIdentityKeyCount: disconnectedIdentityKeys.length, + }); + if (disconnectedIdentityKeys.length > 0) { + appEventBus.emit( + EAppEventBusNames.HardwareConnectionStateUpdate, + undefined, + ); + void this.backgroundApi.serviceHardwarePortfolioSync + ?.notifyHardwareDeviceDisconnected({ + identityKeys: disconnectedIdentityKeys, + }) + .catch(() => undefined); + } + const activeConnectId = message.device?.connectId; + if (activeConnectId) { + if (this.hardwareUiEventState.connectId === activeConnectId) { + if ( + !this.firmwareProgressConnectIdsSinceDisconnect.delete( + 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,16 +1891,22 @@ 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); } }, ); @@ -695,6 +1921,20 @@ class ServiceHardware extends ServiceBase { }); } + @backgroundMethod() + async resetHardwareSDK() { + await this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation(() => + this.sdkInstanceMutex.runExclusive(async () => { + this.resetHardwareUiEventQueue(); + this.clearTrackedConnectedDevices(); + this.registeredEvents = false; + await resetHardwareSDKInstance(); + this.activeHardwareSDKInstance = undefined; + this.activeHardwareTransportType = undefined; + }), + ); + } + @backgroundMethod() async passHardwareEventsFromOffscreenToBackground(eventMessage: CoreMessage) { const sdk = await this.getSDKInstance({ @@ -773,51 +2013,157 @@ class ServiceHardware extends ServiceBase { // startDeviceScan // TODO use convertDeviceResponse() + @backgroundMethod() + async stopDeviceScan() { + if (!platformEnv.isSupportDesktopBle) { + return; + } + await globalThis.desktopApi?.nobleBle?.stopScan(); + } + + @backgroundMethod() + async isDeviceSearchInProgress() { + return this.deviceSearchInProgressCount > 0; + } + + @backgroundMethod() + async getConnectedHardwareDeviceIdentityKeys() { + return [ + ...new Set( + [...this.connectedDeviceIdentityKeysByConnection.values()].flatMap( + (identityKeys) => [...identityKeys], + ), + ), + ]; + } + + @backgroundMethod() + async isHardwareDeviceConnected({ + deviceDbId, + connectId, + }: { + deviceDbId?: string; + connectId?: string; + }) { + const dbDevice = deviceDbId + ? await localDb.getDeviceSafe(deviceDbId) + : undefined; + const targetIdentityKeys = new Set( + uniq( + [ + connectId, + dbDevice?.connectId, + dbDevice?.usbConnectId, + dbDevice?.bleConnectId, + dbDevice?.deviceId, + dbDevice?.uuid, + ].filter((value): value is string => Boolean(value)), + ), + ); + if (targetIdentityKeys.size === 0) { + return false; + } + + const isTrackedAsConnected = [ + ...this.connectedDeviceIdentityKeysByConnection.values(), + ].some((connectedIdentityKeys) => + [...targetIdentityKeys].some((key) => connectedIdentityKeys.has(key)), + ); + if (isTrackedAsConnected) { + return true; + } + + // Match the wallet-list connection dot: WebUSB must enumerate the target + // device itself, not just "any OneKey device", otherwise connecting device + // B would wrongly authorize device A. + if (platformEnv.isSupportWebUSB) { + try { + const usb = globalThis?.navigator?.usb; + if (usb && typeof usb.getDevices === 'function') { + const devices = await usb.getDevices(); + return devices.some( + (device) => + Boolean(device.serialNumber) && + targetIdentityKeys.has(device.serialNumber as string), + ); + } + } catch { + return false; + } + } + + return false; + } + @backgroundMethod() async searchDevices(params?: { + connectProtocol?: HardwareConnectProtocol; vendor?: EHardwareVendor; resetSession?: boolean; waitForAllTransports?: boolean; transportType?: 'usb' | 'ble'; }) { - const vendorProfile = params?.vendor - ? getVendorProfile(params.vendor) - : undefined; - if (params?.vendor && vendorProfile?.isThirdParty) { - // Third-party (Trezor / Ledger) discovery lives in ServiceThirdPartyHardware. - return this.backgroundApi.serviceThirdPartyHardware.searchDevices({ - vendor: params.vendor, - resetSession: params.resetSession, - waitForAllTransports: params.waitForAllTransports, - transportType: params.transportType, + this.deviceSearchInProgressCount += 1; + try { + const vendorProfile = params?.vendor + ? getVendorProfile(params.vendor) + : undefined; + if (params?.vendor && vendorProfile?.isThirdParty) { + // Third-party (Trezor / Ledger) discovery lives in ServiceThirdPartyHardware. + return await this.backgroundApi.serviceThirdPartyHardware.searchDevices( + { + vendor: params.vendor, + resetSession: params.resetSession, + waitForAllTransports: params.waitForAllTransports, + transportType: params.transportType, + }, + ); + } + + // OneKey device discovery must also resolve the transport through the + // unified connection manager. searchDevices enumerates only the current + // SDK transport and does not switch from USB to BLE, so probe USB first + // and fall back to BLE before creating the SDK instance. + const hardwareTransportType = await this.prepareHardwareTransport({ + connectProtocol: params?.connectProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + ...(params?.transportType + ? { requestedTransportType: params.transportType } + : {}), + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: undefined, + connectProtocol: params?.connectProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType, }); - } - - // Original OneKey SDK path - const hardwareSDK = await this.getSDKInstance({ - connectId: undefined, - }); - const response = await hardwareSDK?.searchDevices(); - defaultLogger.hardware.sdkLog.log( - 'searchDevices response: ', - JSON.stringify(response), - ); + const response = await hardwareSDK?.searchDevices(); + defaultLogger.hardware.sdkLog.log( + 'searchDevices response: ', + JSON.stringify(response), + ); - // Linux may surface missing udev rules either through libusb or Chromium - // WebUSB errors, depending on the active transport path. - if (response?.success === false) { - // Normal Linux desktop (AppImage/.deb): install the rules via PolicyKit - // and retry once, so the user doesn't have to restart the app. - if (await this.recoverLinuxWebUsbAccessDeniedError(response.payload)) { - const retryResponse = await hardwareSDK?.searchDevices(); - defaultLogger.hardware.sdkLog.log( - 'searchDevices response after udev rules: ', - JSON.stringify(retryResponse), - ); - return retryResponse; + // Linux may surface missing udev rules either through libusb or Chromium + // WebUSB errors, depending on the active transport path. + if (response?.success === false) { + // Normal Linux desktop (AppImage/.deb): install the rules via PolicyKit + // and retry once, so the user doesn't have to restart the app. + if (await this.recoverLinuxWebUsbAccessDeniedError(response.payload)) { + const retryResponse = await hardwareSDK?.searchDevices(); + defaultLogger.hardware.sdkLog.log( + 'searchDevices response after udev rules: ', + JSON.stringify(retryResponse), + ); + return retryResponse; + } } + return response; + } finally { + this.deviceSearchInProgressCount = Math.max( + this.deviceSearchInProgressCount - 1, + 0, + ); } - return response; } private async ensureLinuxUdevRules() { @@ -1085,6 +2431,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 +2532,18 @@ class ServiceHardware extends ServiceBase { async connect({ device, hardwareCallContext, + connectProtocol, + forceProtocolDetection, + forceFeaturesRefresh, + hardwareTransportType, }: { device: SearchDevice; hardwareCallContext?: EHardwareCallContext; + connectProtocol?: HardwareConnectProtocol; + forceProtocolDetection?: boolean; + /** Bypass SearchDevice.features after a firmware reboot and read the live device state. */ + forceFeaturesRefresh?: boolean; + hardwareTransportType?: EHardwareTransportType; }): Promise { const vendor = (device as SearchDevice & { vendor?: string }).vendor; if (vendor && vendor !== EHardwareVendor.onekey) { @@ -1126,43 +2565,103 @@ 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); + let resolvedHardwareTransportType = hardwareTransportType; + if (!resolvedHardwareTransportType && isDesktopBleSearchDevice) { + resolvedHardwareTransportType = EHardwareTransportType.DesktopWebBle; + } else if ( + !resolvedHardwareTransportType && + deviceUtils.isBluetoothSearchDevice(device) + ) { + resolvedHardwareTransportType = EHardwareTransportType.BLE; + } + const compatibleConnectId = isDesktopBleSearchDevice + ? connectId || undefined + : await this.getCompatibleConnectId({ + connectId: connectId || undefined, + featuresDeviceId: device.deviceId, + hardwareCallContext: + hardwareCallContext || EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: resolvedHardwareTransportType, + }); + 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 ( + !forceFeaturesRefresh && + !platformEnv.isNative && + knownFeatures && + !isDesktopBleSearchDevice + ) { + // 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, + hardwareTransportType: resolvedHardwareTransportType, }); } 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, + hardwareTransportType: resolvedHardwareTransportType, + }); } } @backgroundMethod() @toastIfError() - async unlockDevice({ connectId }: { connectId: string }) { + async unlockDevice({ + connectId, + pinType, + }: { + connectId: string; + pinType?: DeviceSessionPinType; + }) { const hardwareSDK = await this.getSDKInstance({ connectId, }); @@ -1170,31 +2669,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 = {}; @@ -1212,10 +2694,13 @@ class ServiceHardware extends ServiceBase { async cancel({ connectId, walletId, + immediate, }: { connectId?: string; walletId?: string; forceDeviceResetToHome?: boolean; + immediate?: boolean; + deviceType?: string; }) { // TODO skip cancel if device is canceling, save last cancel time @@ -1263,6 +2748,10 @@ class ServiceHardware extends ServiceBase { }; clearTimeout(this.cancelTimer); + if (immediate) { + await fn(); + return; + } this.cancelTimer = setTimeout(fn, 100); } @@ -1289,10 +2778,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; // } @@ -1323,22 +2812,103 @@ class ServiceHardware extends ServiceBase { } _getFeaturesLowLevel = async (options: IDeviceGetFeaturesOptions) => { - const { connectId, params, silentMode, hardwareCallContext } = options; - serviceHardwareUtils.hardwareLog('call getFeatures()', connectId); - if (!params?.allowEmptyConnectId && !connectId) { + const { + connectId, + params, + silentMode, + hardwareCallContext, + hardwareTransportType, + } = options; + 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, + hardwareTransportType, }); - 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({ @@ -1346,6 +2916,14 @@ class ServiceHardware extends ServiceBase { // todo remove: sdk guarantees not to block this method timeout: timerUtils.getTimeDurationMs({ seconds: 60 }), timeoutRejectError: new deviceErrors.DeviceMethodCallTimeout(), + onTimeout: (options) => { + if (options.connectId) { + void this.cancel({ + connectId: options.connectId, + immediate: true, + }); + } + }, }); getFeaturesMutex = new Semaphore(1); @@ -1376,18 +2954,172 @@ class ServiceHardware extends ServiceBase { }, ); + _getDeviceStateLowLevel = async (options: IDeviceGetStateOptions) => { + const { + connectId, + desktopBleReuseConnectedOnly, + params, + silentMode, + hardwareCallContext, + hardwareTransportType, + } = 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, + hardwareTransportType, + }); + const state = await this.runInDesktopBleConnectedOnlyScope({ + connectId, + enabled: desktopBleReuseConnectedOnly, + task: () => + 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(), + onTimeout: (options) => { + if (options.connectId) { + void this.cancel({ + connectId: options.connectId, + immediate: true, + }); + } + }, + }); + + _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, + hardwareTransportType: options.hardwareTransportType, + }) + : 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 +3129,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({ @@ -1431,21 +3181,30 @@ class ServiceHardware extends ServiceBase { connectId: params.connectId, }); if (!dbDevice) { - // Onboarding / bootloader-mode flows hit this with a freshly-discovered - // device that has no local DB record yet — skip pre-flight and let the - // update modal proceed. - return; + // 首次连接或 Bootloader 模式下,本地数据库可能还没有设备记录。 + // 此时跳过预检,把新发现的连接 ID 原样交给升级流程。 + return params.connectId; } - const compatibleConnectId = await this.getCompatibleConnectId({ + const resolvedTransport = await this.resolveHardwareTransport({ connectId: params.connectId, featuresDeviceId: dbDevice.deviceId, hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, }); - return this.backgroundApi.serviceHardwareUI.withHardwareProcessing( + const { connectId: compatibleConnectId, transportType } = resolvedTransport; + const forceProtocolDetection = + transportType === EHardwareTransportType.DesktopWebBle; + await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( () => this.getFeaturesWithoutCache({ connectId: compatibleConnectId, - params: { retryCount: 1 }, + params: { + retryCount: 1, + forceProtocolDetection, + ...(forceProtocolDetection + ? { timeout: DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS } + : {}), + }, + hardwareTransportType: transportType, }), { deviceParams: { @@ -1453,6 +3212,7 @@ class ServiceHardware extends ServiceBase { }, }, ); + return compatibleConnectId; } @backgroundMethod() @@ -1476,14 +3236,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 +3321,7 @@ class ServiceHardware extends ServiceBase { @backgroundMethod() @toastIfError() - async setBrightness(p: IBaseDeviceProcessingParams) { + async setBrightness(p: ISetBrightnessParams) { return this.deviceSettingsManager.setBrightness(p); } @@ -1533,29 +3340,7 @@ class ServiceHardware extends ServiceBase { @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,18 +3371,11 @@ class ServiceHardware extends ServiceBase { const walletName = wallet?.name; const dbDeviceId = wallet?.associatedDevice; if (dbDeviceId) { - // update db features label - await localDb.updateDeviceFeaturesLabel({ + await this.writeBackProtocolV2DeviceLabel({ 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 - appEventBus.emit(EAppEventBusNames.SyncDeviceLabelToWalletName, { + await this.handleHardwareLabelChanged({ walletId: p.walletId, dbDeviceId, label: p.label, @@ -1669,22 +3447,40 @@ class ServiceHardware extends ServiceBase { if (isT1Model) { names = T1_HOME_SCREEN_DEFAULT_IMAGES; } - let size = getHomeScreenSize({ - deviceType: device.deviceType, - homeScreenType, - thumbnail: false, - }); + const size = + getHomeScreenSize({ + deviceType: device.deviceType, + homeScreenType, + thumbnail: false, + }) ?? (isT1Model ? DEFAULT_T1_HOME_SCREEN_INFORMATION : undefined); const thumbnailSize = getHomeScreenSize({ deviceType: device.deviceType, homeScreenType, thumbnail: true, }); - 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, + }); + const thumbnailSize = getNftSize({ + deviceType: device.deviceType, + thumbnail: true, + }); + + return { names: [], size, thumbnailSize }; + } + @backgroundMethod() async shouldAuthenticateFirmware(p: IShouldAuthenticateFirmwareParams) { return this.hardwareVerifyManager.shouldAuthenticateFirmware(p); @@ -1730,6 +3526,85 @@ class ServiceHardware extends ServiceBase { ); } + @backgroundMethod() + async uploadPro2Nft({ + connectId, + imageJpegBase64, + thumbnailJpegBase64, + title, + subtitle, + timestampMs, + }: IUploadPro2NftParams) { + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); + const protocolV2NftSDK = hardwareSDK as IProtocolV2NftCoreApi; + const uploadNft = protocolV2NftSDK.deviceUploadNft; + if (!uploadNft) { + throw new OneKeyLocalError( + 'Hardware SDK does not support Protocol V2 NFT upload', + ); + } + return convertDeviceResponse(() => + uploadNft(compatibleConnectId, { + imageJpegBase64, + thumbnailJpegBase64, + title, + subtitle, + timestampMs, + }), + ); + } + + @backgroundMethod() + async uploadPortfolioPackage({ + connectId, + desktopBleReuseConnectedOnly, + hardwareTransportType, + packageBase64, + }: { + connectId: string; + desktopBleReuseConnectedOnly?: boolean; + hardwareTransportType?: EHardwareTransportType; + packageBase64: string; + }) { + if ( + desktopBleReuseConnectedOnly && + hardwareTransportType !== EHardwareTransportType.DesktopWebBle + ) { + throw new OneKeyLocalError( + 'Desktop BLE connected-only reuse requires a pinned BLE transport', + ); + } + const compatibleConnectId = await this.getCompatibleConnectId({ + connectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + ...(hardwareTransportType ? { hardwareTransportType } : {}), + }); + const hardwareSDK = await this.getSDKInstance({ + connectId: compatibleConnectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + ...(hardwareTransportType ? { hardwareTransportType } : {}), + }); + return this.runInDesktopBleConnectedOnlyScope({ + connectId: compatibleConnectId, + enabled: desktopBleReuseConnectedOnly, + task: () => + convertDeviceResponse( + () => + hardwareSDK.uploadPortfolio(compatibleConnectId, { + packageBase64, + }), + { silentMode: true }, + ), + }); + } + @backgroundMethod() async getLogs(): Promise { const logs: string[] = ['===== device logs =====']; @@ -1746,7 +3621,7 @@ class ServiceHardware extends ServiceBase { } @backgroundMethod() - async getOneKeyFeatures({ + async getFirmwareVerificationFeatures({ connectId, deviceType, }: { @@ -1757,21 +3632,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 +3724,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 = {}; @@ -1921,9 +3799,13 @@ class ServiceHardware extends ServiceBase { } } - await this.backgroundApi.serviceAccount.updateWalletsDeprecatedState({ - willUpdateDeprecateMap, - }); + const result = + await this.backgroundApi.serviceAccount.updateWalletsDeprecatedState({ + willUpdateDeprecateMap, + }); + if (result && Object.keys(willUpdateDeprecateMap).length > 0) { + appEventBus.emit(EAppEventBusNames.WalletUpdate, undefined); + } } /** @@ -2196,69 +4078,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; - } - } - - private async _needCheckBridgeStatus() { - const hardwareTransportType = - await this.backgroundApi.serviceSetting.getHardwareTransportType(); - if (hardwareTransportType === EHardwareTransportType.WEBUSB) { - return false; - } - return platformEnv.isSupportWebUSB; - } - - @backgroundMethod() - async checkBridgeAndFallbackToWebUSB({ - hardwareSDKInstance, - }: { - hardwareSDKInstance: CoreApi; - }) { - try { - if (this.bridgeAvailabilityChecked) { - return; - } - if (!(await this._needCheckBridgeStatus())) { - return; - } - this.bridgeAvailabilityChecked = true; - const isBridgeAvailable = await new Promise((resolve) => { - convertDeviceResponse(() => hardwareSDKInstance?.checkBridgeStatus()) - .then((bridgeStatus) => { - console.log('bridgeStatus ===>>>:: ', bridgeStatus); - resolve(!!bridgeStatus); - }) - .catch((error) => { - console.error('Bridge status check failed:', error); - resolve(false); - }); - }); - - if (!isBridgeAvailable) { - await hardwareSDKInstance.switchTransport('webusb'); - await this.fallbackToWebUSBTransport(); - } - } catch (error) { - console.error('checkBridgeAndFallbackToWebUSB error', error); } - } - - private async fallbackToWebUSBTransport() { - await this.backgroundApi.serviceSetting.setHardwareTransportType( - EHardwareTransportType.WEBUSB, - ); - await timerUtils.wait(0); + 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; } @backgroundMethod() @@ -2287,32 +4139,31 @@ class ServiceHardware extends ServiceBase { }: { transportType: EHardwareTransportType; }) { - try { - // 1. Update transport type setting - await this.backgroundApi.serviceSetting.setHardwareTransportType( - transportType, - ); - - // Reset event registration flag to allow re-registration - this.registeredEvents = false; - - // 3. Reset SDK instance (clears memoizee cache and cleans up SDK instance) - await resetHardwareSDKInstance(); + return this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + async () => { + try { + // 1. Update transport type setting + await this.backgroundApi.serviceSetting.setHardwareTransportType( + transportType, + ); - // 4. Get new SDK instance with new transport type - const newInstance = await this.getSDKInstance({ - connectId: undefined, - }); + // Recreate the SDK under the lifecycle lock when the transport changes. + const newInstance = await this.getSDKInstance({ + connectId: undefined, + hardwareTransportType: transportType, + }); - console.log( - `Successfully switched hardware transport type to: ${transportType}`, - ); + console.log( + `Successfully switched hardware transport type to: ${transportType}`, + ); - return newInstance; - } catch (error) { - console.error('Failed to switch hardware transport type:', error); - throw error; - } + return newInstance; + } catch (error) { + console.error('Failed to switch hardware transport type:', error); + throw error; + } + }, + ); } @backgroundMethod() @@ -2321,13 +4172,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, }); } @@ -2349,6 +4204,11 @@ class ServiceHardware extends ServiceBase { return state.forceTransportType; } + @backgroundMethod() + async getCurrentTransportType() { + return this.connectionManager.getCurrentTransportType(); + } + private shouldPrecheckNativeBleForHardwareCall({ hardwareCallContext, }: { @@ -2363,16 +4223,19 @@ class ServiceHardware extends ServiceBase { private async ensureNativeBleReadyForHardwareCall({ connectId, hardwareCallContext, + hardwareTransportType, }: { connectId: string; hardwareCallContext: EHardwareCallContext; + hardwareTransportType?: EHardwareTransportType; }) { if (!this.shouldPrecheckNativeBleForHardwareCall({ hardwareCallContext })) { return; } - const currentTransportType = await this.getCurrentTransportType(); - if (currentTransportType !== EHardwareTransportType.BLE) { + const transportType = + hardwareTransportType ?? (await this.getCurrentTransportType()); + if (transportType !== EHardwareTransportType.BLE) { return; } @@ -2382,9 +4245,7 @@ class ServiceHardware extends ServiceBase { uiRequestType: EHardwareUiStateAction.LOCATION_PERMISSION, }); throw new deviceErrors.NeedBluetoothPermissions({ - payload: { - connectId, - }, + payload: { connectId }, }); } @@ -2394,21 +4255,20 @@ class ServiceHardware extends ServiceBase { uiRequestType: EHardwareUiStateAction.BLUETOOTH_PERMISSION, }); throw new deviceErrors.NeedBluetoothTurnedOn({ - payload: { - connectId, - }, + payload: { connectId }, }); } } @backgroundMethod() - async getCurrentTransportType() { - return this.connectionManager.getCurrentTransportType(); - } - - @backgroundMethod() - async detectUSBDeviceAvailability() { - return this.connectionManager.detectUSBDeviceAvailability(); + async detectUSBDeviceAvailability(params?: { + connectId?: string; + connectProtocol?: HardwareConnectProtocol; + }) { + return this.connectionManager.detectUSBDeviceAvailability( + params?.connectId, + params?.connectProtocol, + ); } @backgroundMethod() @@ -2432,8 +4292,9 @@ class ServiceHardware extends ServiceBase { } try { - // Step 1: Search for available BLE devices - const searchResult = await this.searchDevices(); + // Step 1: 绑定流程必须锁定 BLE,不得因为 USB 在扫描期间重新出现 + // 而枚举 USB 设备,否则 USB serial 可能被误写入 bleConnectId。 + const searchResult = await this.searchDevices({ transportType: 'ble' }); if (!searchResult?.success || !searchResult?.payload?.length) { throw new deviceErrors.DeviceNotFound({ payload: { @@ -2445,13 +4306,15 @@ class ServiceHardware extends ServiceBase { } // Step 2: Get expected device name from features - const expectedDeviceName = features.ble_name; + const expectedDeviceName = features.bleName || features.ble_name; // Step 3: Find matching device by name - const matchingDevice = searchResult.payload.find((device) => { - const nameMatch = device.name === expectedDeviceName; - return nameMatch; - }); + const matchingDevice = searchResult.payload.find( + (device) => + Boolean(device.connectId) && + deviceUtils.isBluetoothSearchDevice(device) && + isSameOnekeyBleName(device.name, expectedDeviceName), + ); if (!matchingDevice) { throw new deviceErrors.DeviceNotFound({ @@ -2463,16 +4326,38 @@ class ServiceHardware extends ServiceBase { }); } - // Step 4: Try to connect and verify + const expectedDeviceId = + featuresDeviceId || + deviceUtils.getRawDeviceId({ + device: matchingDevice as any, + features, + }); + + const bleConnectId = matchingDevice.connectId; + if (!bleConnectId) { + throw new deviceErrors.DeviceNotFound({ + payload: { + connectId, + deviceId: featuresDeviceId || undefined, + inBluetoothCommunication: true, + }, + }); + } + + // Step 4: 使用同一个 BLE transport 连接并验证,不在候选设备上重新选路。 const connectResult = await this.connect({ device: { ...matchingDevice, - connectId: matchingDevice.connectId || '', - deviceId: features.device_id, + connectId: bleConnectId, + deviceId: expectedDeviceId, }, + forceProtocolDetection: true, + hardwareCallContext: + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, }); - 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, @@ -2484,10 +4369,10 @@ class ServiceHardware extends ServiceBase { // Update device with BLE connectId using the dedicated function await localDb.updateDeviceConnectId({ dbDeviceId: device.id, - bleConnectId: matchingDevice.connectId || undefined, + bleConnectId, }); - return matchingDevice.connectId || ''; + return bleConnectId; } } @@ -2523,16 +4408,18 @@ class ServiceHardware extends ServiceBase { // burning a BLE connect timeout before the fallback ladder recovers. private async resolveTrezorPreferredBleConnectId({ device, + bleConnectId, targetType, }: { - device: { vendor?: string; bleConnectId?: string }; + device: { vendor?: string }; + bleConnectId?: string; targetType: EHardwareTransportType; }): Promise { - if (!device.bleConnectId) { + if (!bleConnectId) { return undefined; } if (targetType === EHardwareTransportType.DesktopWebBle) { - return device.bleConnectId; + return bleConnectId; } if (device.vendor !== EHardwareVendor.trezor) { return undefined; @@ -2542,7 +4429,142 @@ class ServiceHardware extends ServiceBase { if (trezorUsbPresent) { return undefined; } - return device.bleConnectId; + return bleConnectId; + } + + // connectId (lowercased) -> timestamp of the last DEVICE.STATE / + // DEVICE.CONNECT event observed on it. Real traffic implies the endpoint + // is connected and OS-paired at that moment; DEVICE.DISCONNECT deletes + // the entry because factory reset and OS-level unpair surface as a + // disconnect first, invalidating that proof. + private liveConnectIdEvidence = new Map(); + + recordLiveConnectIdEvidence(connectId?: string | null) { + const normalized = connectId?.trim().toLowerCase(); + if (!normalized) { + return; + } + this.liveConnectIdEvidence.set(normalized, Date.now()); + } + + clearLiveConnectIdEvidence(connectId?: string | null) { + const normalized = connectId?.trim().toLowerCase(); + if (!normalized) { + return; + } + this.liveConnectIdEvidence.delete(normalized); + } + + private hasRecentLiveConnectIdEvidence(connectId: string): boolean { + const stampedAt = this.liveConnectIdEvidence.get( + connectId.trim().toLowerCase(), + ); + return ( + stampedAt !== undefined && + Date.now() - stampedAt <= LIVE_CONNECT_ID_EVIDENCE_WINDOW_MS + ); + } + + /** + * Silently bind a live desktop BLE connectId held by the caller onto a + * device record that lacks a BLE binding, so an in-progress BLE session + * never raises the Bluetooth pairing dialog (OK-60091). + * + * Only attempted when the incoming connectId differs from the record's USB + * identifiers (connectId/usbConnectId) — a USB serial input means a genuine + * USB→BLE switch, which must keep the scan + pairing-dialog repair flow — + * AND the connectId carried real device traffic within + * LIVE_CONNECT_ID_EVIDENCE_WINDOW_MS, which proves the endpoint is + * connected and OS-paired, so the probe can never summon the OS pairing + * prompt. The endpoint is then verified with a bounded silent getFeatures + * probe that must report the expected raw deviceId; on an active session + * this reuses the live connection and answers in a few seconds. Any + * failure returns undefined so the caller falls back to the existing + * pairing-dialog flow. + */ + private async silentlyBindLiveDesktopBleConnectId({ + device, + connectId, + featuresDeviceId, + features, + }: { + device: IDBDevice; + connectId: string; + featuresDeviceId?: string | undefined | null; + features?: IOneKeyDeviceFeatures; + }): Promise { + const normalizedConnectId = connectId.trim().toLowerCase(); + if (!normalizedConnectId) { + return undefined; + } + const isUsbAliasInput = [device.connectId, device.usbConnectId].some( + (candidate) => candidate?.trim().toLowerCase() === normalizedConnectId, + ); + if (isUsbAliasInput) { + return undefined; + } + // Probe only endpoints that demonstrably carried device traffic moments + // ago. Anything else (e.g. a stale UUID kept by the UI across a device + // reboot or an unpair) might be an unpaired peripheral, and the probe's + // characteristic subscription would summon the OS pairing prompt with + // no app guidance UI — those cases must keep the pairing-dialog flow. + if (!this.hasRecentLiveConnectIdEvidence(connectId)) { + return undefined; + } + const expectedDeviceId = + featuresDeviceId || + deviceUtils.getRawDeviceId({ + device: deviceUtils.dbDeviceToSearchDevice(device), + features: features || device.featuresInfo, + }); + if (!expectedDeviceId) { + return undefined; + } + // A live session always has a remembered protocol (rememberDeviceProtocol + // runs on every DEVICE.STATE event). Pin it: forcing re-detection here + // sends a Protocol V2 Ping into an active V1 session, which the device + // may not answer (observed as SDK error 713), while a protocol-pinned + // getFeatures is exactly the same shape as the session's healthy calls. + const knownProtocol = await this.getKnownDeviceProtocol(connectId); + if (!knownProtocol) { + return undefined; + } + try { + // Probe the caller's connectId directly over the pinned BLE transport; + // no connectId re-resolution happens here, so this cannot re-enter + // getCompatibleConnectId. silentMode must reach convertDeviceResponse: + // a failed probe would otherwise emit the global DeviceNotFound error + // dialog from the error constructor. The short SDK timeout keeps the + // pairing-dialog fallback fast when the endpoint is stale. + const connectResult = await this.getFeaturesWithoutCache({ + connectId, + silentMode: true, + hardwareCallContext: + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + params: { + retryCount: 1, + connectProtocol: knownProtocol, + timeout: DESKTOP_BLE_SILENT_BIND_CONNECTION_TIMEOUT_MS, + }, + }); + // The probe identity must come from the probe result itself. V1 + // features carry the SDK-normalized `deviceId`; V2 state projections + // (projectLegacyDeviceFeaturesFromState) only carry the raw + // `device_id` field. + const probedDeviceId = + connectResult?.deviceId || connectResult?.device_id || ''; + if (probedDeviceId && probedDeviceId === expectedDeviceId) { + await localDb.updateDeviceConnectId({ + dbDeviceId: device.id, + bleConnectId: connectId, + }); + return connectId; + } + } catch (error) { + console.error('Silent BLE connectId bind failed:', error); + } + return undefined; } @backgroundMethod() @@ -2552,18 +4574,14 @@ class ServiceHardware extends ServiceBase { featuresDeviceId, features, vendor, + hardwareTransportType, }: { hardwareCallContext: EHardwareCallContext; connectId?: string; featuresDeviceId?: string | undefined | null; // rawDeviceId features?: IOneKeyDeviceFeatures; - // Optional: passed ONLY by callers that already know the device's vendor - // (e.g. from a loaded DB record). Without it the lookup below defaults to - // OneKey, which cannot find a third-party device, so its bleConnectId branch - // never runs and the raw (deviceId) connectId leaks to the BLE transport. - // Passing it does NOT broaden the default — it's opt-in per caller, so the - // "don't pull Ledger in unintentionally" guarantee holds. vendor?: EHardwareVendor; + hardwareTransportType?: EHardwareTransportType; }) { // Allow connectId to be null in the following EHardwareCallContext cases if ( @@ -2579,17 +4597,22 @@ 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, vendor }); + if (!device && featuresDeviceId) { + device = await localDb.getDeviceByQuery({ + featuresDeviceId, + vendor, + }); + } + // 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, vendor }); + } + const persistedDesktopBleConnectId = + getPersistedDesktopBleConnectId(device); // 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,76 +4624,116 @@ class ServiceHardware extends ServiceBase { if (!platformEnv.isSupportDesktopBle) { return device.connectId || connectId; } - if (hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK) { - const currentTransportType = await this.getCurrentTransportType(); + if ( + hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK || + hardwareCallContext === + EHardwareCallContext.BACKGROUND_NON_INTERACTIVE + ) { + const currentTransportType = + hardwareTransportType ?? (await this.getCurrentTransportType()); const preferredBle = await this.resolveTrezorPreferredBleConnectId({ device, + bleConnectId: persistedDesktopBleConnectId, targetType: currentTransportType, }); - const picked = preferredBle || device.connectId || connectId; - return picked; + return preferredBle || device.connectId || connectId; } - const result = await this.connectionManager.shouldSwitchTransportType({ + const result = await this.connectionManager.resolveTransportType({ connectId: device.connectId || connectId, hardwareCallContext, }); const preferredBle = await this.resolveTrezorPreferredBleConnectId({ device, + bleConnectId: persistedDesktopBleConnectId, targetType: result.targetType, }); - const picked = preferredBle || device.connectId || connectId; - return picked; + return preferredBle || device.connectId || connectId; } } await this.ensureNativeBleReadyForHardwareCall({ connectId, hardwareCallContext, + hardwareTransportType, }); 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 currentTransportType = await this.getCurrentTransportType(); - if ( - currentTransportType === EHardwareTransportType.DesktopWebBle && - device?.bleConnectId - ) { - return device.bleConnectId; + const connectProtocol = await this.getKnownDeviceProtocol( + device?.connectId || connectId, + ); + + if ( + hardwareCallContext === EHardwareCallContext.BACKGROUND_TASK || + hardwareCallContext === EHardwareCallContext.BACKGROUND_NON_INTERACTIVE + ) { + const currentTransportType = + hardwareTransportType ?? (await this.getCurrentTransportType()); + if (currentTransportType === EHardwareTransportType.DesktopWebBle) { + if (persistedDesktopBleConnectId) { + return persistedDesktopBleConnectId; + } } + // 后台任务不能发起 BLE 配对,也不应把缺少 BLE 绑定误判为设备离线。 + // 移除硬件钱包等纯本地操作仍需要读取设备参数,因此沿用已持久化的 + // USB connectId;真正需要连接的硬件调用会在后续传输层完成可达性校验。 return device?.connectId || connectId; } - const result = await this.connectionManager.shouldSwitchTransportType({ + const result = await this.connectionManager.resolveTransportType({ connectId: device?.connectId || connectId, + connectProtocol, hardwareCallContext, }); const targetTransportType = result.targetType; - const forceTransportType = (await hardwareForceTransportAtom.get()) - .forceTransportType; - // Handle connection logic based on transport type if (targetTransportType === EHardwareTransportType.DesktopWebBle) { - if (device?.bleConnectId) { + if (persistedDesktopBleConnectId) { // Device found in DB and has BLE connectId, use it - return device.bleConnectId; + return persistedDesktopBleConnectId; } if (!device) { return connectId; } - // onboarding flow - if ( - device.connectId && - forceTransportType === EHardwareTransportType.DesktopWebBle - ) { - return device.connectId; - } - if (device && !device.bleConnectId) { + if (device && !persistedDesktopBleConnectId) { if (hardwareCallContext === EHardwareCallContext.SILENT_CALL) { - return connectId; + return device.usbConnectId || device.connectId || connectId; + } + // The caller may already hold a live BLE connectId (e.g. onboarding + // communicates over an active Noble session while the device record + // was created via USB and lacks bleConnectId). Verify and persist it + // silently before falling back to the pairing dialog (OK-60091). + const silentlyBoundBleConnectId = + await this.silentlyBindLiveDesktopBleConnectId({ + device, + connectId, + featuresDeviceId, + features, + }); + if (silentlyBoundBleConnectId) { + return silentlyBoundBleConnectId; + } + if ( + hardwareCallContext === + EHardwareCallContext.USER_INTERACTION_NO_BLE_DIALOG + ) { + throw new deviceErrors.DeviceNotFound({ + payload: { + connectId, + deviceId: featuresDeviceId || device.deviceId || undefined, + inBluetoothCommunication: true, + }, + }); } // Use servicePromise to wait for UI dialog to complete BLE pairing const bleConnectId = await new Promise((resolve, reject) => { @@ -2684,8 +4747,14 @@ class ServiceHardware extends ServiceBase { { device, deviceId: - featuresDeviceId || device.featuresInfo?.device_id || '', - usbConnectId: connectId, + featuresDeviceId || + deviceUtils.getRawDeviceId({ + device: deviceUtils.dbDeviceToSearchDevice(device), + features: device.featuresInfo, + }) || + '', + usbConnectId: + device.usbConnectId || device.connectId || connectId, features: features || device.featuresInfo, promiseId, }, @@ -2710,6 +4779,61 @@ class ServiceHardware extends ServiceBase { return device?.connectId || connectId; } + /** + * 统一解析一次硬件调用所使用的传输类型与 connectId。 + * + * 调用方不应先单独选传输、再自行在 USB/BLE ID 之间转换;那会在固件升级 + * 等多阶段流程中把 BLE UUID 再次替换成 USB serial。该方法保证两者来自 + * 同一次探测结果,并且在 UI 显示前提交运行时传输状态。 + */ + @backgroundMethod() + async resolveHardwareTransport(params: { + hardwareCallContext: EHardwareCallContext; + connectId?: string; + featuresDeviceId?: string | undefined | null; + features?: IOneKeyDeviceFeatures; + }): Promise<{ + connectId: string; + transportType: EHardwareTransportType; + }> { + const resolvedConnectId = await this.getCompatibleConnectId(params); + return { + connectId: resolvedConnectId, + transportType: await this.getCurrentTransportType(), + }; + } + + /** + * 在通用硬件弹窗显示前确定并提交传输类型。 + * connectId 的 USB/BLE 映射仍由 resolveHardwareTransport 统一完成。 + */ + @backgroundMethod() + async prepareHardwareTransport(params: { + connectId?: string; + connectProtocol?: HardwareConnectProtocol; + hardwareCallContext: EHardwareCallContext; + requestedTransportType?: 'usb' | 'ble'; + }): Promise { + const connectProtocol = + params.connectProtocol ?? + (await this.getKnownDeviceProtocol(params.connectId)); + if (params.requestedTransportType) { + const targetType = + await this.connectionManager.getTransportTypeForChannel({ + transportType: params.requestedTransportType, + connectProtocol, + }); + await this.connectionManager.setCurrentTransportType(targetType); + return targetType; + } + const result = await this.connectionManager.resolveTransportType({ + connectId: params.connectId, + hardwareCallContext: params.hardwareCallContext, + connectProtocol, + }); + return result.targetType; + } + @backgroundMethod() async isBtcOnlyWallet({ walletId }: { walletId: string }) { if ( 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..cb197c8ca10e --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.test.ts @@ -0,0 +1,319 @@ +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('accepts firmware status from a reconnected device', () => { + const state = reduceHardwareUiEventState(createHardwareUiEventState(), { + type: EHardwareUiStateAction.REQUEST_BUTTON, + renderAction: EHardwareUiStateAction.REQUEST_BUTTON, + connectId: 'PRO2_USB_BEFORE_REBOOT', + }).state; + + const result = reduceHardwareUiEventState(state, { + type: EHardwareUiStateAction.FIRMWARE_PROGRESS, + renderAction: EHardwareUiStateAction.FIRMWARE_PROGRESS, + connectId: 'PRO2_USB_AFTER_REBOOT', + payload: { progress: 25, progressType: 'installingFirmware' }, + }); + + expect(result.applied).toBe(true); + expect(result.connectId).toBe('PRO2_USB_AFTER_REBOOT'); + expect(result.state.connectId).toBe('PRO2_USB_AFTER_REBOOT'); + }); + + 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..6e04c585449a --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/hardwareUiEventStateMachine.ts @@ -0,0 +1,274 @@ +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 isFirmwareStatusEvent = + event.type === EHardwareUiStateAction.FIRMWARE_TIP || + event.type === EHardwareUiStateAction.FIRMWARE_PROGRESS; + const isDifferentDevice = + Boolean(state.connectId) && + Boolean(connectId) && + state.connectId !== connectId; + const canSwitchDevice = + isDifferentDevice && + (state.phase === 'closed' || + Boolean(requestedPhase) || + isFirmwareStatusEvent); + const currentState = canSwitchDevice + ? { ...createHardwareUiEventState(), connectId } + : 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/resourceBase64StartupGraph.test.ts b/packages/kit-bg/src/services/ServiceHardware/resourceBase64StartupGraph.test.ts new file mode 100644 index 000000000000..4e5344fb11b6 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/resourceBase64StartupGraph.test.ts @@ -0,0 +1,24 @@ +import fs from 'fs'; +import path from 'path'; + +describe('hardware resource Base64 startup graph', () => { + test.each([ + ['ServiceHardware.ts', ['imageJpegBase64', 'packageBase64']], + ['DeviceSettingsManager.ts', ['jpegBase64: screenBase64']], + [ + '../../offscreens/OffscreenApiProxyBase.ts', + ['requestToOffscreen(message)'], + ], + ])( + '%s keeps resource calls JSON-safe without a global codec', + (file, tokens) => { + const source = fs.readFileSync(path.resolve(__dirname, file), 'utf8'); + + expect(source).not.toContain('jpegRgbaUtils'); + expect(source).not.toContain('offscreenApiBinaryCodec'); + for (const token of tokens) { + expect(source).toContain(token); + } + }, + ); +}); 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..b8a8389bf61d --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.ts @@ -0,0 +1,2282 @@ +import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; +import { debounce, uniq } from 'lodash'; + +import { + backgroundClass, + backgroundMethod, +} from '@onekeyhq/shared/src/background/backgroundDecorators'; +import { + BluetoothUnavailableWhileUsbConnectedError, + OneKeyLocalError, +} from '@onekeyhq/shared/src/errors'; +import { isHardwareErrorByCode } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; +import { PORTFOLIO_ARCHIVE_MAX_BYTES } from '@onekeyhq/shared/src/utils/portfolioArchive'; +import { + EAccountSelectorSceneName, + EHardwareTransportType, +} from '@onekeyhq/shared/types'; +import { + EHardwareCallContext, + EHardwareVendor, + EOneKeyDeviceMode, +} 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' + | 'identity-unavailable' + | 'identity-mismatch' + | 'inactive' + | 'disconnected' + | 'desktop-suspended' + | 'ble-suspended' + | 'device-locked' + | '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'] +>; + +type IDesktopBleIdleLease = { + bleConnectId: string; + expiresAt: number; + generation: number; + lastInteractionAt: number; +}; + +type IDesktopBleSyncExecution = Pick< + IDesktopBleIdleLease, + 'bleConnectId' | 'generation' +>; + +const LOG_PREFIX = '[PRO2-PORTFOLIO-SYNC]'; +const PORTFOLIO_SYNC_HARDWARE_BUSY_RETRY_MS = 1000; +const PORTFOLIO_SYNC_RESUME_AFTER_INTERACTION_MS = 5000; +const DESKTOP_BLE_IDLE_DELAY_MS = 30_000; +const DESKTOP_BLE_REUSE_WINDOW_MS = 150_000; +const DESKTOP_BLE_TRANSFER_COOLDOWN_MS = 5 * 60_000; +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 validatePortfolioPackageBase64(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'); + } + + let paddingLength = 0; + if (packageBase64.endsWith('==')) { + paddingLength = 2; + } else if (packageBase64.endsWith('=')) { + paddingLength = 1; + } + const packageBytesLength = (packageBase64.length / 4) * 3 - paddingLength; + if (packageBytesLength > PORTFOLIO_PACKAGE_MAX_BYTES) { + throw new OneKeyLocalError('Portfolio pack response is too large'); + } + return { packageBase64, packageBytesLength }; +} + +function stringifyLogValue(value: unknown) { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ + stringifyError: error instanceof Error ? error.message : String(error), + }); + } +} + +const DEVICE_LOCKED_MESSAGE = /device(?: is)? locked/i; +const DEVICE_RESETTING_MESSAGE = /device is resetting/i; + +function collectErrorText(error: unknown): string { + if (!error || typeof error !== 'object') { + return typeof error === 'string' ? error : ''; + } + const record = error as { + message?: unknown; + payload?: { message?: unknown; firmwareMessage?: unknown }; + }; + return [ + record.message, + record.payload?.message, + record.payload?.firmwareMessage, + ] + .filter((value): value is string => typeof value === 'string') + .join('\n'); +} + +function isSilentUploadBlockedByDevice(error: unknown): boolean { + if ( + isHardwareErrorByCode({ + error: error as never, + code: HardwareErrorCode.DeviceLocked, + }) + ) { + return true; + } + const text = collectErrorText(error); + return ( + DEVICE_LOCKED_MESSAGE.test(text) || DEVICE_RESETTING_MESSAGE.test(text) + ); +} + +function isDeviceStateLocked(state: { + status?: { unlocked?: boolean | null }; +}): boolean { + return state.status?.unlocked !== true; +} + +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 pendingDisconnectedPayloadByTargetKey = new Map< + string, + IPortfolioSyncSettledPayload + >(); + + private pendingLockedPayloadByTargetKey = new Map< + string, + IPortfolioSyncSettledPayload + >(); + + // Only cache identities for transports with reliable connection-session + // events. WebUSB always performs a live identity check before each upload. + private verifiedDeviceIdByTargetKey = new Map(); + + private mismatchedDeviceIdByTargetKey = new Map(); + + private mobileBleSilentSyncDisabledTargetKeys = new Set(); + + private pendingMobileBlePayloadByTargetKey = new Map< + string, + IPortfolioSyncSettledPayload + >(); + + private pendingMobileBleResumeTimerByTargetKey = new Map< + string, + ReturnType + >(); + + private mobileBleResumeInProgressTargetKeys = new Set(); + + private pendingDesktopBlePayloadByTargetKey = new Map< + string, + IPortfolioSyncSettledPayload + >(); + + private desktopBleIdleLeaseByTargetKey = new Map< + string, + IDesktopBleIdleLease + >(); + + private desktopBleIdleTimerByTargetKey = new Map< + string, + ReturnType + >(); + + private desktopBleLeaseGenerationByTargetKey = new Map(); + + private desktopBleHardwareAttemptGenerationByTargetKey = new Map< + string, + number + >(); + + private desktopInteractiveGenerationByTargetKey = new Map(); + + 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.isWalletDeprecatedOrMocked(wallet) || + !accountUtils.isHwWallet({ walletId: wallet.id }) || + accountUtils.isQrWallet({ 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 || + !isProtocolV2ProductType(device.deviceType) || + !isProtocolV2 || + vendor !== EHardwareVendor.onekey + ) { + return undefined; + } + + const authorizedConnectIds = uniq( + [ + device.connectId, + device.usbConnectId, + device.bleConnectId, + device.deviceId, + device.uuid, + ].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, + }); + } + + private setMobileBleSuspendedResult( + eventPayload: IPortfolioSyncSettledPayload, + ) { + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: 'ble-suspended', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + private setDesktopSuspendedResult( + eventPayload: IPortfolioSyncSettledPayload, + ) { + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: 'desktop-suspended', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + private rememberPendingDesktopBlePayload({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + this.pendingDesktopBlePayloadByTargetKey.set(targetKey, eventPayload); + } + + private scheduleDesktopBleBusyRetry({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + this.rememberPendingDesktopBlePayload({ eventPayload, targetKey }); + this.scheduleDesktopBleIdleSync({ + minimumDelayMs: PORTFOLIO_SYNC_HARDWARE_BUSY_RETRY_MS, + targetKey, + }); + } + + private cancelDesktopBleIdleTimer(targetKey: string) { + const timer = this.desktopBleIdleTimerByTargetKey.get(targetKey); + if (timer) { + clearTimeout(timer); + this.desktopBleIdleTimerByTargetKey.delete(targetKey); + } + } + + private getActiveDesktopBleIdleLease({ + generation, + targetKey, + }: { + generation?: number; + targetKey: string; + }) { + const lease = this.desktopBleIdleLeaseByTargetKey.get(targetKey); + if ( + !lease || + (generation !== undefined && lease.generation !== generation) || + lease.expiresAt <= Date.now() + ) { + if (lease?.expiresAt && lease.expiresAt <= Date.now()) { + this.desktopBleIdleLeaseByTargetKey.delete(targetKey); + this.cancelDesktopBleIdleTimer(targetKey); + } + return undefined; + } + return lease; + } + + private invalidateDesktopBleIdleLease({ + generation, + reason, + targetKey, + }: { + generation?: number; + reason: string; + targetKey: string; + }) { + const lease = this.desktopBleIdleLeaseByTargetKey.get(targetKey); + if (generation !== undefined && lease?.generation !== generation) { + return; + } + this.desktopBleIdleLeaseByTargetKey.delete(targetKey); + this.cancelDesktopBleIdleTimer(targetKey); + debugPortfolioSyncLog('desktop-ble-idle-lease-invalidated', { + reason, + targetKey, + }); + } + + private isDesktopBleSyncExecutionCurrent({ + execution, + targetKey, + }: { + execution: IDesktopBleSyncExecution | undefined; + targetKey: string; + }) { + if (!execution) { + return false; + } + const lease = this.getActiveDesktopBleIdleLease({ + generation: execution.generation, + targetKey, + }); + return Boolean(lease && lease.bleConnectId === execution.bleConnectId); + } + + private scheduleDesktopBleIdleSync({ + minimumDelayMs = 0, + targetKey, + }: { + minimumDelayMs?: number; + targetKey: string; + }) { + this.cancelDesktopBleIdleTimer(targetKey); + const lease = this.getActiveDesktopBleIdleLease({ targetKey }); + if (!lease || !this.pendingDesktopBlePayloadByTargetKey.has(targetKey)) { + return; + } + const now = Date.now(); + const idleRemainingMs = Math.max( + lease.lastInteractionAt + DESKTOP_BLE_IDLE_DELAY_MS - now, + 0, + ); + const delayMs = Math.max(idleRemainingMs, minimumDelayMs); + if (now + delayMs >= lease.expiresAt) { + this.invalidateDesktopBleIdleLease({ + generation: lease.generation, + reason: 'reuse-window-expired-before-next-attempt', + targetKey, + }); + return; + } + + const timer = setTimeout(() => { + if (this.desktopBleIdleTimerByTargetKey.get(targetKey) === timer) { + this.desktopBleIdleTimerByTargetKey.delete(targetKey); + } + void this.tryRunDesktopBleIdleSync({ + generation: lease.generation, + targetKey, + }).catch((error) => { + debugPortfolioSyncLog('desktop-ble-idle-attempt-error', { + message: error instanceof Error ? error.message : String(error), + targetKey, + }); + }); + }, delayMs); + this.desktopBleIdleTimerByTargetKey.set(targetKey, timer); + } + + private async tryRunDesktopBleIdleSync({ + generation, + targetKey, + }: { + generation: number; + targetKey: string; + }) { + const lease = this.getActiveDesktopBleIdleLease({ + generation, + targetKey, + }); + const eventPayload = + this.pendingDesktopBlePayloadByTargetKey.get(targetKey); + if (!lease || !eventPayload) { + return; + } + + const transportType = + await this.backgroundApi.serviceHardware.getCurrentTransportType(); + if (transportType !== EHardwareTransportType.DesktopWebBle) { + this.invalidateDesktopBleIdleLease({ + generation, + reason: 'transport-changed', + targetKey, + }); + return; + } + + const cooldownRemainingMs = await this.getHardwareCooldownRemainingMs({ + cooldownMs: DESKTOP_BLE_TRANSFER_COOLDOWN_MS, + now: Date.now(), + targetKey, + }); + if (cooldownRemainingMs > 0) { + this.setLastResult({ + cooldownRemainingMs, + deviceConnectId: lease.bleConnectId, + status: 'cooldown', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + this.scheduleDesktopBleIdleSync({ + minimumDelayMs: cooldownRemainingMs, + targetKey, + }); + return; + } + + const syncGeneration = this.syncGenerationByTargetKey.get(targetKey); + if (syncGeneration === undefined) { + this.invalidateDesktopBleIdleLease({ + generation, + reason: 'missing-sync-generation', + targetKey, + }); + return; + } + + try { + await this.syncSettledPortfolio(eventPayload, syncGeneration, { + desktopBleExecution: { + bleConnectId: lease.bleConnectId, + generation: lease.generation, + }, + }); + } finally { + if ( + this.desktopBleHardwareAttemptGenerationByTargetKey.get(targetKey) === + generation + ) { + this.desktopBleHardwareAttemptGenerationByTargetKey.delete(targetKey); + this.invalidateDesktopBleIdleLease({ + generation, + reason: 'hardware-attempt-finished', + targetKey, + }); + } + } + } + + private rememberPendingMobileBlePayload({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + this.pendingMobileBlePayloadByTargetKey.set(targetKey, eventPayload); + } + + private async isMobileBleSilentSyncDisabled(targetKey: string) { + if (!platformEnv.isNative) { + return false; + } + if (this.mobileBleSilentSyncDisabledTargetKeys.has(targetKey)) { + return true; + } + const state = await this.portfolioSyncDb.getTargetState(targetKey); + if (state?.bleSilentSyncDisabled) { + this.mobileBleSilentSyncDisabledTargetKeys.add(targetKey); + return true; + } + return false; + } + + private async suspendMobileBleSilentSync({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + if (!platformEnv.isNative) { + return; + } + this.mobileBleSilentSyncDisabledTargetKeys.add(targetKey); + this.rememberPendingMobileBlePayload({ eventPayload, targetKey }); + const resumeTimer = + this.pendingMobileBleResumeTimerByTargetKey.get(targetKey); + if (resumeTimer) { + clearTimeout(resumeTimer); + this.pendingMobileBleResumeTimerByTargetKey.delete(targetKey); + } + await this.portfolioSyncDb.updateTargetState(targetKey, { + bleSilentSyncDisabled: true, + bleSilentSyncDisabledAt: Date.now(), + bleSilentSyncDisabledReason: 'link-disabled', + }); + debugPortfolioSyncLog('suspend-mobile-ble-link-disabled', { targetKey }); + this.setMobileBleSuspendedResult(eventPayload); + } + + @backgroundMethod() + async notifyInteractiveHardwareOperationStarted({ + connectId, + deviceDbId, + }: { + connectId?: string; + deviceDbId?: string; + }) { + if (!platformEnv.isDesktop) { + return undefined; + } + const targetKey = + deviceDbId || + (connectId ? this.targetKeyByConnectId.get(connectId) : undefined); + if (!targetKey) { + return undefined; + } + const interactionGeneration = + (this.desktopInteractiveGenerationByTargetKey.get(targetKey) ?? 0) + 1; + this.desktopInteractiveGenerationByTargetKey.set( + targetKey, + interactionGeneration, + ); + this.invalidateDesktopBleIdleLease({ + reason: 'interactive-operation-started', + targetKey, + }); + return interactionGeneration; + } + + @backgroundMethod() + async notifyInteractiveHardwareOperationSucceeded({ + connectId, + deviceDbId, + interactionGeneration, + transportType, + }: { + connectId?: string; + deviceDbId?: string; + interactionGeneration?: number; + transportType?: EHardwareTransportType; + }) { + const targetKey = + deviceDbId || + (connectId ? this.targetKeyByConnectId.get(connectId) : undefined); + if (!targetKey) { + return false; + } + if (platformEnv.isDesktop) { + if ( + interactionGeneration === undefined || + this.desktopInteractiveGenerationByTargetKey.get(targetKey) !== + interactionGeneration + ) { + return false; + } + if (transportType !== EHardwareTransportType.DesktopWebBle) { + this.invalidateDesktopBleIdleLease({ + reason: 'interactive-operation-used-non-ble-transport', + targetKey, + }); + return false; + } + const device = await localDb.getDeviceSafe(targetKey); + if ( + this.desktopInteractiveGenerationByTargetKey.get(targetKey) !== + interactionGeneration + ) { + return false; + } + const bleConnectId = device?.bleConnectId; + if (!bleConnectId) { + return false; + } + this.targetKeyByConnectId.set(bleConnectId, targetKey); + const generation = + (this.desktopBleLeaseGenerationByTargetKey.get(targetKey) ?? 0) + 1; + this.desktopBleLeaseGenerationByTargetKey.set(targetKey, generation); + const now = Date.now(); + this.desktopBleIdleLeaseByTargetKey.set(targetKey, { + bleConnectId, + expiresAt: now + DESKTOP_BLE_REUSE_WINDOW_MS, + generation, + lastInteractionAt: now, + }); + debugPortfolioSyncLog('desktop-ble-idle-lease-created', { + generation, + targetKey, + }); + this.scheduleDesktopBleIdleSync({ targetKey }); + this.replayLockedPortfolioSnapshot(targetKey); + return true; + } + if (!platformEnv.isNative) { + return false; + } + const state = await this.portfolioSyncDb.getTargetState(targetKey); + if ( + !state?.bleSilentSyncDisabled && + !this.mobileBleSilentSyncDisabledTargetKeys.has(targetKey) + ) { + return false; + } + + this.mobileBleResumeInProgressTargetKeys.add(targetKey); + try { + await this.portfolioSyncDb.updateTargetState(targetKey, { + bleSilentSyncDisabled: false, + bleSilentSyncDisabledAt: undefined, + bleSilentSyncDisabledReason: undefined, + }); + this.mobileBleSilentSyncDisabledTargetKeys.delete(targetKey); + debugPortfolioSyncLog('resume-mobile-ble-after-interaction', { + targetKey, + }); + + this.replayLockedPortfolioSnapshot(targetKey); + const pendingPayload = + this.pendingMobileBlePayloadByTargetKey.get(targetKey); + if (!pendingPayload) { + return true; + } + const existingTimer = + this.pendingMobileBleResumeTimerByTargetKey.get(targetKey); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + this.pendingMobileBleResumeTimerByTargetKey.delete(targetKey); + const latestPendingPayload = + this.pendingMobileBlePayloadByTargetKey.get(targetKey); + this.pendingMobileBlePayloadByTargetKey.delete(targetKey); + if (latestPendingPayload) { + this.handleAllNetworksTokenListSettled(latestPendingPayload); + } + }, PORTFOLIO_SYNC_RESUME_AFTER_INTERACTION_MS); + this.pendingMobileBleResumeTimerByTargetKey.set(targetKey, timer); + return true; + } finally { + this.mobileBleResumeInProgressTargetKeys.delete(targetKey); + } + } + + private async isPreparedUploadStillAuthorized({ + deviceConnectId, + eventPayload, + targetKey, + }: { + deviceConnectId: string; + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + const authorizedPayload = + await this.resolveAuthorizedPortfolioPayload(eventPayload); + return Boolean( + authorizedPayload && + this.getSyncTargetKey(authorizedPayload) === targetKey && + authorizedPayload.deviceConnectId === deviceConnectId, + ); + } + + private async getPreparedUploadDeviceIdentityStatus({ + desktopBleExecution, + deviceConnectId, + eventPayload, + hardwareTransportType, + targetKey, + }: { + desktopBleExecution?: IDesktopBleSyncExecution; + deviceConnectId: string; + eventPayload: IPortfolioSyncSettledPayload; + hardwareTransportType?: EHardwareTransportType; + targetKey: string; + }): Promise<'verified' | 'unavailable' | 'mismatch' | 'locked'> { + const deviceDbId = eventPayload.deviceDbId; + if (!deviceDbId) { + return 'mismatch'; + } + const device = await localDb.getDeviceSafe(deviceDbId); + const expectedDeviceId = + device?.deviceStateInfo?.identity.deviceId || device?.deviceId; + if (!expectedDeviceId) { + this.mismatchedDeviceIdByTargetKey.set(targetKey, ''); + return 'mismatch'; + } + const mismatchedDeviceId = + this.mismatchedDeviceIdByTargetKey.get(targetKey); + if (mismatchedDeviceId === expectedDeviceId) { + return 'mismatch'; + } + if (mismatchedDeviceId !== undefined) { + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + } + const currentTransportType = + hardwareTransportType ?? + (desktopBleExecution + ? EHardwareTransportType.DesktopWebBle + : await this.backgroundApi.serviceHardware.getCurrentTransportType()); + const canCacheVerifiedDeviceId = + currentTransportType !== EHardwareTransportType.WEBUSB; + if (!canCacheVerifiedDeviceId) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + } else if ( + this.verifiedDeviceIdByTargetKey.get(targetKey) === expectedDeviceId + ) { + return this.getSilentUploadLockStatus({ + desktopBleExecution, + deviceConnectId, + hardwareTransportType, + }); + } + + let state; + try { + state = await this.backgroundApi.serviceHardware.getDeviceState({ + connectId: deviceConnectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + ...(desktopBleExecution + ? { + desktopBleReuseConnectedOnly: true, + } + : {}), + ...(hardwareTransportType ? { hardwareTransportType } : {}), + params: { scope: 'firmware' }, + silentMode: true, + }); + } catch (error) { + if (isSilentUploadBlockedByDevice(error)) { + return 'locked'; + } + throw error; + } + const liveDeviceId = state.identity?.deviceId; + const isPro2LoaderIdentityUnavailable = + device?.deviceType === EDeviceType.Pro2 && + !liveDeviceId && + (state.status?.mode === EOneKeyDeviceMode.bootloader || + state.status?.mode === EOneKeyDeviceMode.romloader); + if (isPro2LoaderIdentityUnavailable) { + // Pro2 bootloader mode does not expose DeviceStatus.device_id. An empty + // value means the identity is unavailable in this mode and must not + // override or invalidate the persisted identity confirmed in firmware. + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + return 'unavailable'; + } + if (!liveDeviceId || liveDeviceId !== expectedDeviceId) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.mismatchedDeviceIdByTargetKey.set(targetKey, expectedDeviceId); + return 'mismatch'; + } + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + if (canCacheVerifiedDeviceId) { + this.verifiedDeviceIdByTargetKey.set(targetKey, expectedDeviceId); + } + return isDeviceStateLocked(state) ? 'locked' : 'verified'; + } + + private async getSilentUploadLockStatus({ + desktopBleExecution, + deviceConnectId, + hardwareTransportType, + }: { + desktopBleExecution?: IDesktopBleSyncExecution; + deviceConnectId: string; + hardwareTransportType?: EHardwareTransportType; + }): Promise<'verified' | 'locked'> { + try { + const state = await this.backgroundApi.serviceHardware.getDeviceState({ + connectId: deviceConnectId, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + ...(desktopBleExecution + ? { + desktopBleReuseConnectedOnly: true, + } + : {}), + ...(hardwareTransportType ? { hardwareTransportType } : {}), + params: { scope: 'runtime' }, + silentMode: true, + }); + return isDeviceStateLocked(state) ? 'locked' : 'verified'; + } catch (error) { + if (isSilentUploadBlockedByDevice(error)) { + return 'locked'; + } + throw error; + } + } + + private handleDeviceLockedSkip({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + if (eventPayload.deviceConnectId) { + this.cancelHardwareBusyRetry(eventPayload.deviceConnectId); + } + this.pendingLockedPayloadByTargetKey.set(targetKey, eventPayload); + debugPortfolioSyncLog('skip-device-locked', { + deviceConnectId: eventPayload.deviceConnectId, + targetKey, + walletId: eventPayload.walletId, + }); + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: 'device-locked', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + private replayLockedPortfolioSnapshot(targetKey: string) { + const pendingPayload = this.pendingLockedPayloadByTargetKey.get(targetKey); + if (!pendingPayload) { + return; + } + this.pendingLockedPayloadByTargetKey.delete(targetKey); + this.handleAllNetworksTokenListSettled(pendingPayload); + } + + private async isDeviceIdentityMismatchPending({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + const mismatchedDeviceId = + this.mismatchedDeviceIdByTargetKey.get(targetKey); + if (mismatchedDeviceId === undefined) { + return false; + } + const device = eventPayload.deviceDbId + ? await localDb.getDeviceSafe(eventPayload.deviceDbId) + : undefined; + const expectedDeviceId = + device?.deviceStateInfo?.identity.deviceId || device?.deviceId || ''; + if (expectedDeviceId !== mismatchedDeviceId) { + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + return false; + } + return true; + } + + private async getPortfolioSyncEligibility( + eventPayload: IPortfolioSyncSettledPayload, + ): Promise<'eligible' | 'inactive' | 'disconnected'> { + const selectedAccount = + await this.backgroundApi.simpleDb.accountSelector.getSelectedAccount({ + sceneName: EAccountSelectorSceneName.home, + num: 0, + }); + if ( + !eventPayload.walletId || + selectedAccount?.walletId !== eventPayload.walletId || + selectedAccount.indexedAccountId !== eventPayload.indexedAccountId + ) { + return 'inactive'; + } + + const isConnected = + await this.backgroundApi.serviceHardware.isHardwareDeviceConnected({ + connectId: eventPayload.deviceConnectId, + deviceDbId: eventPayload.deviceDbId, + }); + return isConnected ? 'eligible' : 'disconnected'; + } + + private handleIneligibleSync({ + eligibility, + eventPayload, + targetKey, + }: { + eligibility: 'inactive' | 'disconnected'; + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + if (eligibility === 'disconnected') { + this.pendingDisconnectedPayloadByTargetKey.set(targetKey, eventPayload); + } else { + this.pendingDisconnectedPayloadByTargetKey.delete(targetKey); + } + if (eventPayload.deviceConnectId) { + this.cancelHardwareBusyRetry(eventPayload.deviceConnectId); + } + debugPortfolioSyncLog(`skip-${eligibility}`, { + deviceConnectId: eventPayload.deviceConnectId, + targetKey, + walletId: eventPayload.walletId, + }); + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: eligibility, + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + private handleDeviceIdentityMismatch({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.pendingDisconnectedPayloadByTargetKey.set(targetKey, eventPayload); + if (eventPayload.deviceConnectId) { + this.cancelHardwareBusyRetry(eventPayload.deviceConnectId); + } + debugPortfolioSyncLog('skip-identity-mismatch', { + deviceConnectId: eventPayload.deviceConnectId, + targetKey, + walletId: eventPayload.walletId, + }); + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: 'identity-mismatch', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + private handleDeviceIdentityUnavailable({ + eventPayload, + targetKey, + }: { + eventPayload: IPortfolioSyncSettledPayload; + targetKey: string; + }) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + // Keep the latest snapshot until the device exits bootloader mode and + // reconnects with its full identity available. + this.pendingDisconnectedPayloadByTargetKey.set(targetKey, eventPayload); + if (eventPayload.deviceConnectId) { + this.cancelHardwareBusyRetry(eventPayload.deviceConnectId); + } + debugPortfolioSyncLog('skip-identity-unavailable', { + deviceConnectId: eventPayload.deviceConnectId, + targetKey, + walletId: eventPayload.walletId, + }); + this.setLastResult({ + deviceConnectId: eventPayload.deviceConnectId, + status: 'identity-unavailable', + totalTokenCount: eventPayload.tokens.length, + updatedAt: Date.now(), + walletId: eventPayload.walletId, + }); + } + + @backgroundMethod() + async notifyHardwareDeviceConnected({ + identityKeys, + }: { + identityKeys: string[]; + }) { + const targetKeys = uniq( + identityKeys + .map((identityKey) => this.targetKeyByConnectId.get(identityKey)) + .filter((targetKey): targetKey is string => Boolean(targetKey)), + ); + for (const targetKey of targetKeys) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.mismatchedDeviceIdByTargetKey.delete(targetKey); + const pendingPayload = + this.pendingDisconnectedPayloadByTargetKey.get(targetKey); + if (pendingPayload) { + this.pendingDisconnectedPayloadByTargetKey.delete(targetKey); + this.handleAllNetworksTokenListSettled(pendingPayload); + } + this.replayLockedPortfolioSnapshot(targetKey); + } + } + + @backgroundMethod() + async notifyHardwareDeviceDisconnected({ + identityKeys, + }: { + identityKeys: string[]; + }) { + const targetKeys = uniq( + identityKeys + .map((identityKey) => this.targetKeyByConnectId.get(identityKey)) + .filter((targetKey): targetKey is string => Boolean(targetKey)), + ); + for (const targetKey of targetKeys) { + this.verifiedDeviceIdByTargetKey.delete(targetKey); + this.invalidateDesktopBleIdleLease({ + reason: 'hardware-disconnected', + targetKey, + }); + } + } + + @backgroundMethod() + async notifyHardwareDeviceIdentityMismatch({ + deviceDbId, + expectedDeviceId, + }: { + deviceDbId: string; + expectedDeviceId: string; + }) { + this.verifiedDeviceIdByTargetKey.delete(deviceDbId); + this.mismatchedDeviceIdByTargetKey.set(deviceDbId, expectedDeviceId); + this.advanceSyncGeneration(deviceDbId); + debugPortfolioSyncLog('device-identity-mismatch', { deviceDbId }); + this.setLastResult({ + status: 'identity-mismatch', + updatedAt: Date.now(), + }); + } + + @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); + if (this.pendingDesktopBlePayloadByTargetKey.has(targetKey)) { + this.rememberPendingDesktopBlePayload({ eventPayload, targetKey }); + } + if (this.mobileBleResumeInProgressTargetKeys.has(targetKey)) { + this.rememberPendingMobileBlePayload({ eventPayload, targetKey }); + this.advanceSyncGeneration(targetKey); + return; + } + const pendingResumeTimer = + this.pendingMobileBleResumeTimerByTargetKey.get(targetKey); + if (pendingResumeTimer) { + clearTimeout(pendingResumeTimer); + this.pendingMobileBleResumeTimerByTargetKey.delete(targetKey); + this.pendingMobileBlePayloadByTargetKey.delete(targetKey); + } + 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 async handleSyncError({ + contentHash, + error, + eventPayload, + generation, + targetKey, + }: { + contentHash?: string; + error: unknown; + eventPayload?: IPortfolioSyncSettledPayload; + generation: number; + targetKey: string; + }) { + if (contentHash) { + this.releaseInFlightReservation({ contentHash, generation, targetKey }); + } + if ( + eventPayload && + error instanceof BluetoothUnavailableWhileUsbConnectedError && + platformEnv.isNative + ) { + await this.suspendMobileBleSilentSync({ eventPayload, targetKey }); + return; + } + if (eventPayload && isSilentUploadBlockedByDevice(error)) { + this.handleDeviceLockedSkip({ eventPayload, targetKey }); + return; + } + 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, + attemptAt, + generation, + targetKey, + transferAt, + walletId, + }: { + artifacts: IPortfolioSyncArtifacts; + attemptAt: number; + 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, { + lastAttemptAt: attemptAt, + 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, + eventPayload, + generation, + retry, + targetKey, + }: { + contentHash: string; + deviceConnectId: string; + eventPayload?: IPortfolioSyncSettledPayload; + 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) => { + void this.handleSyncError({ + contentHash, + error, + eventPayload, + generation, + targetKey, + }); + }); + }, PORTFOLIO_SYNC_HARDWARE_BUSY_RETRY_MS); + this.pendingHardwareRetryTimerByConnectId.set(deviceConnectId, timer); + } + + private async getHardwareCooldownRemainingMs({ + cooldownMs, + targetKey, + now, + }: { + cooldownMs?: number; + targetKey: string; + now: number; + }) { + const state = await this.portfolioSyncDb.getTargetState(targetKey); + return getPortfolioSyncCooldownRemainingMs({ + lastAttemptAt: state?.lastAttemptAt, + lastTransferAt: state?.lastTransferAt, + cooldownMs, + 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<{ + serverPackageBase64: string; + 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 validatedPackage = validatePortfolioPackageBase64(packageBase64); + + debugPortfolioSyncLog('server-submit-packed', { + bytesLength: portfolioJsonBytes.byteLength, + contentHash, + serverPackageBase64Length: packageBase64.length, + serverPackageBytesLength: validatedPackage.packageBytesLength, + }); + + return { + serverPackageBase64: validatedPackage.packageBase64, + serverSubmit: { + bytesLength: portfolioJsonBytes.byteLength, + contentHash, + serverPackageBase64Length: packageBase64.length, + serverPackageBytesLength: validatedPackage.packageBytesLength, + }, + }; + } + + private async uploadPreparedHardwarePortfolio({ + artifacts, + desktopBleExecution, + deviceConnectId, + eventPayload, + generation, + serverPackageBase64, + serverSubmit, + targetKey, + updatedAt, + }: { + artifacts: IPortfolioSyncArtifacts; + desktopBleExecution?: IDesktopBleSyncExecution; + deviceConnectId: string; + eventPayload: IPortfolioSyncSettledPayload; + generation: number; + serverPackageBase64: string; + 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 hardwareConnectId = + desktopBleExecution?.bleConnectId ?? deviceConnectId; + const activeUpload = this.activeUploadByTargetKey.get(targetKey); + if (activeUpload) { + if (desktopBleExecution) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.scheduleDesktopBleBusyRetry({ eventPayload, targetKey }); + this.setLastResult( + this.buildResultBase({ + artifacts, + eventPayload, + serverSubmit, + status: 'hardware-busy', + updatedAt, + }), + ); + return; + } + await activeUpload.catch(() => undefined); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + } + const runUpload = async () => { + const isExecutionCurrent = () => + this.isCurrentSyncGeneration(targetKey, generation) && + (!desktopBleExecution || + this.isDesktopBleSyncExecutionCurrent({ + execution: desktopBleExecution, + targetKey, + })); + if (!isExecutionCurrent()) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + const isStillAuthorized = await this.isPreparedUploadStillAuthorized({ + deviceConnectId, + eventPayload, + targetKey, + }); + if (!isExecutionCurrent()) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if (!isStillAuthorized) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.cancelHardwareBusyRetry(deviceConnectId); + this.setRejectedPayloadResult(eventPayload); + return; + } + const eligibility = await this.getPortfolioSyncEligibility(eventPayload); + if (!isExecutionCurrent()) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if (eligibility !== 'eligible') { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.handleIneligibleSync({ + eligibility, + eventPayload, + targetKey, + }); + return; + } + let hardwareTransportType: EHardwareTransportType | undefined; + if (platformEnv.isDesktop) { + hardwareTransportType = desktopBleExecution + ? EHardwareTransportType.DesktopWebBle + : await this.backgroundApi.serviceHardware.getCurrentTransportType(); + } + if (!isExecutionCurrent()) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if ( + !desktopBleExecution && + hardwareTransportType === EHardwareTransportType.DesktopWebBle + ) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.rememberPendingDesktopBlePayload({ eventPayload, targetKey }); + this.setDesktopSuspendedResult(eventPayload); + this.scheduleDesktopBleIdleSync({ targetKey }); + return; + } + const hardwareBusy = + await this.backgroundApi.serviceHardwareUI.isHardwareChannelBusy({ + connectId: hardwareConnectId, + }); + if (!isExecutionCurrent()) { + 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, + }); + if (desktopBleExecution) { + this.scheduleDesktopBleBusyRetry({ eventPayload, targetKey }); + } else { + this.scheduleHardwareBusyRetry({ + contentHash: artifacts.contentHash, + deviceConnectId, + eventPayload, + generation, + retry: () => + this.uploadPreparedHardwarePortfolio({ + artifacts, + deviceConnectId, + eventPayload, + generation, + serverPackageBase64, + serverSubmit, + targetKey, + updatedAt: Date.now(), + }), + targetKey, + }); + } + return; + } + + if (await this.isMobileBleSilentSyncDisabled(targetKey)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + this.rememberPendingMobileBlePayload({ eventPayload, targetKey }); + this.setMobileBleSuspendedResult(eventPayload); + return; + } + + if (desktopBleExecution) { + this.desktopBleHardwareAttemptGenerationByTargetKey.set( + targetKey, + desktopBleExecution.generation, + ); + } + const deviceIdentityStatus = + await this.getPreparedUploadDeviceIdentityStatus({ + desktopBleExecution, + deviceConnectId: hardwareConnectId, + eventPayload, + hardwareTransportType, + targetKey, + }); + if (!isExecutionCurrent()) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + if (deviceIdentityStatus !== 'verified') { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + if (deviceIdentityStatus === 'unavailable') { + this.handleDeviceIdentityUnavailable({ eventPayload, targetKey }); + } else if (deviceIdentityStatus === 'locked') { + this.handleDeviceLockedSkip({ eventPayload, targetKey }); + } else { + this.handleDeviceIdentityMismatch({ eventPayload, targetKey }); + } + return; + } + + const lastAttemptAt = Date.now(); + // Start both operations in the same event loop turn so lastAttemptAt + // always corresponds to a hardware upload that has actually started. + const [uploadResult, attemptStateResult] = await Promise.allSettled([ + this.backgroundApi.serviceHardware.uploadPortfolioPackage({ + connectId: hardwareConnectId, + ...(desktopBleExecution + ? { + desktopBleReuseConnectedOnly: true, + } + : {}), + ...(hardwareTransportType ? { hardwareTransportType } : {}), + packageBase64: serverPackageBase64, + }), + this.portfolioSyncDb.updateTargetState(targetKey, { + lastAttemptAt, + }), + ]); + // Keep the global hardware lock until the device call settles, even if + // persisting the attempt state fails first. + if (uploadResult.status === 'rejected') { + throw uploadResult.reason; + } + if (attemptStateResult.status === 'rejected') { + debugPortfolioSyncLog('persist-last-attempt-failed', { + message: + attemptStateResult.reason instanceof Error + ? attemptStateResult.reason.message + : String(attemptStateResult.reason), + targetKey, + }); + } + const upload: { portfolioUpdated: boolean } = uploadResult.value; + 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: serverSubmit.serverPackageBytesLength, + contentHash: artifacts.contentHash, + }); + if (!eventPayload.walletId) { + throw new OneKeyLocalError( + 'Authorized portfolio payload is missing walletId', + ); + } + await this.commitProcessedArtifacts({ + artifacts, + attemptAt: lastAttemptAt, + generation, + targetKey, + transferAt: Date.now(), + walletId: eventPayload.walletId, + }); + if ( + desktopBleExecution && + this.isCurrentSyncGeneration(targetKey, generation) + ) { + this.pendingDesktopBlePayloadByTargetKey.delete(targetKey); + } + return upload; + }; + const uploadPromise = desktopBleExecution + ? (async () => { + const attempt = + await this.backgroundApi.serviceHardwareUI.tryRunExclusiveOneKeyOperation( + runUpload, + { deviceKey: targetKey }, + ); + if (!attempt.acquired) { + 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.scheduleDesktopBleBusyRetry({ eventPayload, targetKey }); + } + return attempt.acquired ? attempt.result : undefined; + })() + : this.backgroundApi.serviceHardwareUI.runExclusiveOneKeyOperation( + runUpload, + { 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, + options?: { desktopBleExecution?: IDesktopBleSyncExecution }, + ) { + 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; + } + + const eligibility = await this.getPortfolioSyncEligibility(eventPayload); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + if (eligibility !== 'eligible') { + this.handleIneligibleSync({ + eligibility, + eventPayload, + targetKey, + }); + return; + } + this.pendingDisconnectedPayloadByTargetKey.delete(targetKey); + + if ( + await this.isDeviceIdentityMismatchPending({ + eventPayload, + targetKey, + }) + ) { + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + this.handleDeviceIdentityMismatch({ eventPayload, targetKey }); + return; + } + + if (await this.isMobileBleSilentSyncDisabled(targetKey)) { + this.rememberPendingMobileBlePayload({ eventPayload, targetKey }); + this.setMobileBleSuspendedResult(eventPayload); + return; + } + + const desktopBleExecution = options?.desktopBleExecution; + if (platformEnv.isDesktop) { + const currentTransportType = desktopBleExecution + ? EHardwareTransportType.DesktopWebBle + : await this.backgroundApi.serviceHardware.getCurrentTransportType(); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + const shouldSuspendDesktopBle = desktopBleExecution + ? !this.isDesktopBleSyncExecutionCurrent({ + execution: desktopBleExecution, + targetKey, + }) + : currentTransportType === EHardwareTransportType.DesktopWebBle; + if (shouldSuspendDesktopBle) { + this.cancelHardwareBusyRetry(deviceConnectId); + this.rememberPendingDesktopBlePayload({ eventPayload, targetKey }); + this.setDesktopSuspendedResult(eventPayload); + this.scheduleDesktopBleIdleSync({ targetKey }); + return; + } + if (!desktopBleExecution) { + this.pendingDesktopBlePayloadByTargetKey.delete(targetKey); + this.invalidateDesktopBleIdleLease({ + reason: 'non-ble-transport-active', + targetKey, + }); + } + } + + // Empty standard-wallet snapshots intentionally continue through the + // signed package flow so the device atomically overwrites stale data. + const cooldownRemainingMs = await this.getHardwareCooldownRemainingMs({ + cooldownMs: desktopBleExecution + ? DESKTOP_BLE_TRANSFER_COOLDOWN_MS + : undefined, + targetKey, + now: updatedAt, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + return; + } + if (cooldownRemainingMs > 0) { + if (desktopBleExecution) { + this.rememberPendingDesktopBlePayload({ eventPayload, targetKey }); + this.scheduleDesktopBleIdleSync({ + minimumDelayMs: cooldownRemainingMs, + targetKey, + }); + } else { + 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) { + if (desktopBleExecution) { + this.pendingDesktopBlePayloadByTargetKey.delete(targetKey); + } + 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, + }), + ); + if (desktopBleExecution) { + this.scheduleDesktopBleBusyRetry({ eventPayload, targetKey }); + } else { + this.scheduleHardwareBusyRetry({ + contentHash: artifacts.contentHash, + deviceConnectId, + eventPayload, + generation, + retry: () => this.syncSettledPortfolio(eventPayload, generation), + targetKey, + }); + } + return; + } + + const { serverPackageBase64, serverSubmit } = + await this.submitPortfolioJsonToServer({ + artifacts, + }); + if (!this.isCurrentSyncGeneration(targetKey, generation)) { + this.releaseInFlightReservation({ + contentHash: artifacts.contentHash, + generation, + targetKey, + }); + return; + } + + await this.uploadPreparedHardwarePortfolio({ + artifacts, + desktopBleExecution, + deviceConnectId, + eventPayload, + generation, + serverPackageBase64, + serverSubmit, + targetKey, + updatedAt, + }); + } catch (error) { + await this.handleSyncError({ + contentHash: reservedContentHash, + error, + eventPayload, + 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..95d111233d4f --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts @@ -0,0 +1,2310 @@ +/* eslint-disable @typescript-eslint/unbound-method -- Jest mock functions do not use this binding. */ +import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; + +import { BluetoothUnavailableWhileUsbConnectedError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import { + EHardwareCallContext, + EHardwareVendor, + EOneKeyDeviceMode, +} from '@onekeyhq/shared/types/device'; + +import localDb from '../../../dbs/local/localDb'; + +import ServiceHardwarePortfolioSync, { + validatePortfolioPackageBase64, +} 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, + isDesktop: false, + isJest: true, + isNative: true, + isSupportDesktopBle: false, + }, +})); + +const mutablePlatformEnv = platformEnv as unknown as { + isDesktop: boolean; + isNative: boolean; + isSupportDesktopBle: boolean; +}; + +jest.mock('@onekeyhq/shared/src/utils/accountUtils', () => ({ + __esModule: true, + default: { + isHwWallet: jest.fn(), + isQrWallet: jest.fn(), + isWalletDeprecatedOrMocked: jest.fn( + ( + wallet: { deprecated?: boolean; isMocked?: boolean } | null | undefined, + ) => Boolean(wallet?.deprecated || wallet?.isMocked), + ), + shortenAddress: jest.fn(({ address }: { address: string }) => address), + }, +})); + +jest.mock('../../../dbs/local/localDb', () => ({ + __esModule: true, + default: { + getAccountSafe: jest.fn(), + getDeviceSafe: 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('validatePortfolioPackageBase64', () => { + test('preserves valid Base64 and reports the decoded size', () => { + expect(validatePortfolioPackageBase64('AQID')).toEqual({ + packageBase64: 'AQID', + packageBytesLength: 3, + }); + }); + + test.each(['not-base64', 'AQI', 'AQID\n'])( + 'rejects an invalid package response: %s', + (packageBase64) => { + expect(() => validatePortfolioPackageBase64(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(() => validatePortfolioPackageBase64(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, + totalFiatCurrency: 'usd', + 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', + totalFiatCurrency: 'usd', + 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.clearAllMocks(); + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + } as never); + jest.mocked(localDb.getWalletDeviceSafe).mockResolvedValue({ + bleConnectId: 'PRO2_BLE_ID', + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + connectProtocol: 'V2', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Pro2, + vendor: EHardwareVendor.onekey, + } as never); + jest.mocked(localDb.getDeviceSafe).mockResolvedValue({ + bleConnectId: 'PRO2_BLE_ID', + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Pro2, + } 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-'), + ); + (accountUtils.isQrWallet as jest.Mock).mockImplementation( + ({ walletId }: { walletId?: string }) => walletId?.startsWith('qr-'), + ); + }); + + 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' }), + ); + }); + + test('rejects a QR wallet even if it is also classified as hardware', async () => { + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + id: 'qr-1', + name: 'OneKey QR Wallet', + type: 'qr', + } as never); + (accountUtils.isHwWallet as jest.Mock).mockReturnValue(true); + (accountUtils.isQrWallet as jest.Mock).mockReturnValue(true); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: {} as IBackgroundApi, + }); + const handleSettled = jest.fn(); + ( + service as unknown as { + handleAllNetworksTokenListSettled: typeof handleSettled; + } + ).handleAllNetworksTokenListSettled = handleSettled; + + await service.notifyAllNetworksTokenListSettled({ + ...buildHardwarePayload(), + walletId: 'qr-1', + walletType: 'qr', + }); + + expect(handleSettled).not.toHaveBeenCalled(); + expect(localDb.getWalletDeviceSafe).not.toHaveBeenCalled(); + }); + + function buildHardwarePayload() { + return { + accountAddress: '0x1234567890abcdef', + accountId: 'account-1', + aggregateTokenMap: {}, + deviceConnectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + indexedAccountId: 'indexed-account-1', + totalFiat: '0.00007276', + totalFiatCurrency: 'usd', + 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, + cooldownRemainingMs = 0, + hardwareTransportType = EHardwareTransportType.BLE, + isConnected = true, + selectedIndexedAccountId = 'indexed-account-1', + selectedWalletId = 'hw-1', + targetState, + tryAcquire = true, + }: { + busyResults: boolean[]; + cooldownRemainingMs?: number; + hardwareTransportType?: EHardwareTransportType; + isConnected?: boolean; + selectedIndexedAccountId?: string; + selectedWalletId?: string; + targetState?: { + bleSilentSyncDisabled?: boolean; + lastAttemptAt?: number; + lastContentHash?: string; + lastTransferAt?: number; + lastWalletId?: string; + }; + tryAcquire?: boolean | boolean[]; + }) { + let operationLeaseHeld = false; + const getDeviceState = jest.fn().mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: true }, + }); + const getCurrentTransportType = jest + .fn() + .mockResolvedValue(hardwareTransportType); + const isHardwareDeviceConnected = jest.fn().mockResolvedValue(isConnected); + const uploadPortfolioPackage = jest.fn( + async (_params: { connectId: string; packageBase64: string }) => { + 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 tryRunExclusiveOneKeyOperation = jest.fn( + async (operation: (lease: object) => Promise) => { + const acquired = Array.isArray(tryAcquire) + ? (tryAcquire.shift() ?? true) + : tryAcquire; + if (!acquired) { + return { acquired: false } as const; + } + operationLeaseHeld = true; + try { + return { + acquired: true, + result: await operation({ + deviceKey: 'db-device-1', + owner: Symbol('test'), + }), + } as const; + } finally { + operationLeaseHeld = false; + } + }, + ); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: { + serviceHardware: { + getDeviceState, + getCurrentTransportType, + isHardwareDeviceConnected, + uploadPortfolioPackage, + }, + serviceHardwareUI: { + isHardwareChannelBusy, + runExclusiveOneKeyOperation, + tryRunExclusiveOneKeyOperation, + }, + simpleDb: { + accountSelector: { + getSelectedAccount: jest.fn().mockResolvedValue({ + indexedAccountId: selectedIndexedAccountId, + walletId: selectedWalletId, + }), + }, + 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(cooldownRemainingMs); + serviceInternals.getCurrencyMapForBuild = jest.fn().mockResolvedValue({ + currencyMap: {}, + displayCurrency: { id: 'usd', symbol: '$' }, + }); + serviceInternals.submitPortfolioJsonToServer = jest.fn().mockResolvedValue({ + serverPackageBase64: 'AQID', + serverSubmit: { + bytesLength: 3, + contentHash: 'server-content-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 3, + }, + }); + (accountUtils.isHwWallet as jest.Mock).mockReturnValue(true); + return { + getDeviceState, + getCurrentTransportType, + isHardwareDeviceConnected, + isHardwareChannelBusy, + runExclusiveOneKeyOperation, + service, + serviceInternals, + tryRunExclusiveOneKeyOperation, + updateTargetState, + uploadPortfolioPackage, + }; + } + + async function armDesktopBleIdleLease(service: ServiceHardwarePortfolioSync) { + const interactionGeneration = + await service.notifyInteractiveHardwareOperationStarted({ + connectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + }); + expect(typeof interactionGeneration).toBe('number'); + await service.notifyInteractiveHardwareOperationSucceeded({ + connectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + interactionGeneration: interactionGeneration as number, + transportType: EHardwareTransportType.DesktopWebBle, + }); + } + + 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('skips silent USB upload when the device is locked and does not retry', async () => { + jest.useFakeTimers(); + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: false }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await jest.advanceTimersByTimeAsync(1000); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'device-locked', + walletId: 'hw-1', + }), + ); + + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: true }, + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('replays the locked snapshot after the device reconnects unlocked', async () => { + jest.useFakeTimers(); + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: false }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await jest.advanceTimersByTimeAsync(1000); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: true }, + }); + await service.notifyHardwareDeviceConnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + await jest.advanceTimersByTimeAsync(1000); + + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('skips silent desktop BLE upload when the device is locked', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false, false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: false }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + await jest.advanceTimersByTimeAsync(30_000); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect( + (service as unknown as { lastResult: unknown }).lastResult, + ).toEqual(expect.objectContaining({ status: 'device-locked' })); + } finally { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + jest.useRealTimers(); + } + }); + + test('does not retry a firmware DeviceLocked refusal', async () => { + jest.useFakeTimers(); + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false, false] }); + const lockedError = Object.assign(new Error('Device locked'), { + code: HardwareErrorCode.DeviceLocked, + payload: { firmwareMessage: 'Device locked' }, + }); + uploadPortfolioPackage.mockRejectedValueOnce(lockedError); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await jest.advanceTimersByTimeAsync(1000); + + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'device-locked', + walletId: 'hw-1', + }), + ); + jest.useRealTimers(); + }); + + test('does not pack or upload when the target device is disconnected', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false], isConnected: false }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect(getDeviceState).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'disconnected', + walletId: 'hw-1', + }), + ); + }); + + test('does not pack or upload for a wallet that is not selected on Home', async () => { + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ + busyResults: [false], + selectedWalletId: 'hw-2', + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'inactive', + walletId: 'hw-1', + }), + ); + }); + + test('uploads the latest pending snapshot after the target device reconnects', async () => { + jest.useFakeTimers(); + const { + isHardwareDeviceConnected, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false, false], + isConnected: false, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + + isHardwareDeviceConnected.mockResolvedValue(true); + await service.notifyHardwareDeviceConnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + await jest.advanceTimersByTimeAsync(1000); + + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('does not upload when the connected device identity differs from the wallet device', async () => { + const { + getDeviceState, + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: 'OTHER_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: true }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(getDeviceState).toHaveBeenCalledWith({ + connectId: 'PRO2_CONNECT_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + params: { scope: 'firmware' }, + silentMode: true, + }); + expect(updateTargetState).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'identity-mismatch', + walletId: 'hw-1', + }), + ); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + + expect(getDeviceState).toHaveBeenCalledTimes(1); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + }); + + test('defers Pro2 identity verification when Bootloader omits deviceId', async () => { + jest.useFakeTimers(); + const { + getDeviceState, + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValueOnce({ + identity: { deviceId: null }, + protocol: 'V2', + status: { mode: EOneKeyDeviceMode.bootloader }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(updateTargetState).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'identity-unavailable', + walletId: 'hw-1', + }), + ); + + getDeviceState.mockResolvedValueOnce({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { mode: EOneKeyDeviceMode.normal, unlocked: true }, + }); + await service.notifyHardwareDeviceConnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + await jest.advanceTimersByTimeAsync(1000); + + expect(getDeviceState).toHaveBeenCalledTimes(2); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('keeps treating a missing Pro2 deviceId in normal mode as a mismatch', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: null }, + protocol: 'V2', + status: { mode: EOneKeyDeviceMode.normal }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'identity-mismatch', + walletId: 'hw-1', + }), + ); + }); + + test('does not apply the Pro2 Bootloader exception to Neo', async () => { + jest.mocked(localDb.getDeviceSafe).mockResolvedValue({ + id: 'db-device-1', + connectId: 'PRO2_CONNECT_ID', + deviceId: 'PRO2_DEVICE_ID', + deviceType: EDeviceType.Neo, + } as never); + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + getDeviceState.mockResolvedValue({ + identity: { deviceId: null }, + protocol: 'V2', + status: { mode: EOneKeyDeviceMode.bootloader }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ status: 'identity-mismatch' }), + ); + }); + + test('verifies device identity once per connection session', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '1', + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + + expect(getDeviceState).toHaveBeenCalledTimes(2); + expect(getDeviceState).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { scope: 'firmware' } }), + ); + expect(getDeviceState).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { scope: 'runtime' } }), + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(2); + + await service.notifyHardwareDeviceConnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '3', + }); + + expect(getDeviceState).toHaveBeenCalledTimes(3); + expect(getDeviceState).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ params: { scope: 'firmware' } }), + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(3); + }); + + test('rechecks WebUSB identity and blocks a changed device without caching it', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false, false], + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '1', + }); + expect( + ( + service as unknown as { + verifiedDeviceIdByTargetKey: Map; + } + ).verifiedDeviceIdByTargetKey.size, + ).toBe(0); + + getDeviceState.mockResolvedValueOnce({ + identity: { deviceId: 'CHANGED_DEVICE_ID' }, + protocol: 'V2', + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + + expect(getDeviceState).toHaveBeenCalledTimes(2); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ status: 'identity-mismatch' }), + ); + }); + + test('clears the verified identity cache when the device disconnects', async () => { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false, false] }); + + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '1', + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + expect(getDeviceState).toHaveBeenCalledTimes(2); + + await service.notifyHardwareDeviceDisconnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '3', + }); + + expect(getDeviceState).toHaveBeenCalledTimes(3); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(3); + }); + + test('suspends mobile BLE sync after link disabled and resumes after an interactive success', async () => { + jest.useFakeTimers(); + const { + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false] }); + uploadPortfolioPackage.mockRejectedValueOnce( + new BluetoothUnavailableWhileUsbConnectedError(), + ); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ + bleSilentSyncDisabled: true, + bleSilentSyncDisabledReason: 'link-disabled', + }), + ); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ status: 'ble-suspended' }), + ); + + const latestPayload = { + ...buildHardwarePayload(), + totalFiat: '2', + }; + await serviceInternals.syncSettledPortfolio(latestPayload); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + + const resumedPayloadHandler = jest.fn(); + ( + service as unknown as { + handleAllNetworksTokenListSettled: typeof resumedPayloadHandler; + } + ).handleAllNetworksTokenListSettled = resumedPayloadHandler; + await expect( + service.notifyInteractiveHardwareOperationSucceeded({ + connectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + }), + ).resolves.toBe(true); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ bleSilentSyncDisabled: false }), + ); + + await jest.advanceTimersByTimeAsync(5000); + expect(resumedPayloadHandler).toHaveBeenCalledWith( + expect.objectContaining({ + deviceDbId: 'db-device-1', + totalFiat: '2', + walletId: 'hw-1', + }), + ); + jest.useRealTimers(); + }); + + test('skips desktop BLE sync without waiting for a later USB connection', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(getDeviceState).not.toHaveBeenCalled(); + expect( + serviceInternals.submitPortfolioJsonToServer, + ).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect( + ( + service as unknown as { + pendingDisconnectedPayloadByTargetKey: Map; + } + ).pendingDisconnectedPayloadByTargetKey.has('db-device-1'), + ).toBe(false); + expect( + (service as unknown as { lastResult: unknown }).lastResult, + ).toEqual(expect.objectContaining({ status: 'desktop-suspended' })); + + const resumedPayloadHandler = jest.fn(); + ( + service as unknown as { + handleAllNetworksTokenListSettled: typeof resumedPayloadHandler; + } + ).handleAllNetworksTokenListSettled = resumedPayloadHandler; + await service.notifyHardwareDeviceConnected({ + identityKeys: ['PRO2_CONNECT_ID'], + }); + expect(resumedPayloadHandler).not.toHaveBeenCalled(); + } finally { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('uploads the desktop Portfolio snapshot through the active USB transport', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getDeviceState, + runExclusiveOneKeyOperation, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(runExclusiveOneKeyOperation).toHaveBeenCalledTimes(1); + expect(getDeviceState).toHaveBeenCalledWith( + expect.objectContaining({ + connectId: 'PRO2_CONNECT_ID', + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ); + expect( + serviceInternals.submitPortfolioJsonToServer, + ).toHaveBeenCalledTimes(1); + expect(uploadPortfolioPackage).toHaveBeenCalledWith({ + connectId: 'PRO2_CONNECT_ID', + hardwareTransportType: EHardwareTransportType.WEBUSB, + packageBase64: 'AQID', + }); + } finally { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('suspends a prepared desktop upload if the transport changes to BLE', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getCurrentTransportType, + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + getCurrentTransportType + .mockResolvedValueOnce(EHardwareTransportType.WEBUSB) + .mockResolvedValue(EHardwareTransportType.DesktopWebBle); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect( + serviceInternals.submitPortfolioJsonToServer, + ).toHaveBeenCalledTimes(1); + expect(getDeviceState).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect( + (service as unknown as { lastResult: unknown }).lastResult, + ).toEqual(expect.objectContaining({ status: 'desktop-suspended' })); + } finally { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('silently uploads through the still-connected desktop BLE link after the idle delay', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getDeviceState, + runExclusiveOneKeyOperation, + service, + serviceInternals, + tryRunExclusiveOneKeyOperation, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false, false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + + await jest.advanceTimersByTimeAsync(29_999); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + + expect(runExclusiveOneKeyOperation).not.toHaveBeenCalled(); + expect(tryRunExclusiveOneKeyOperation).toHaveBeenCalledTimes(1); + expect(getDeviceState).toHaveBeenCalledWith( + expect.objectContaining({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareCallContext: EHardwareCallContext.BACKGROUND_NON_INTERACTIVE, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }), + ); + expect(uploadPortfolioPackage).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + desktopBleReuseConnectedOnly: true, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + packageBase64: 'AQID', + }); + expect( + ( + service as unknown as { + pendingDesktopBlePayloadByTargetKey: Map; + } + ).pendingDesktopBlePayloadByTargetKey.has('db-device-1'), + ).toBe(false); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('keeps the desktop BLE snapshot pending when the non-queued lock is busy', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + service, + serviceInternals, + tryRunExclusiveOneKeyOperation, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + tryAcquire: [false, true], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + await jest.advanceTimersByTimeAsync(30_000); + + expect(tryRunExclusiveOneKeyOperation).toHaveBeenCalledTimes(1); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect( + ( + service as unknown as { + pendingDesktopBlePayloadByTargetKey: Map; + } + ).pendingDesktopBlePayloadByTargetKey.has('db-device-1'), + ).toBe(true); + + await jest.advanceTimersByTimeAsync(1000); + expect(tryRunExclusiveOneKeyOperation).toHaveBeenCalledTimes(2); + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('replaces the pending desktop BLE snapshot before debounce completes', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { service, serviceInternals } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + ( + service as unknown as { + handleAllNetworksTokenListSettled: ( + payload: IPortfolioSyncSettledPayload, + ) => void; + } + ).handleAllNetworksTokenListSettled({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + + expect( + ( + service as unknown as { + pendingDesktopBlePayloadByTargetKey: Map< + string, + IPortfolioSyncSettledPayload + >; + } + ).pendingDesktopBlePayloadByTargetKey.get('db-device-1')?.totalFiat, + ).toBe('2'); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('does not arm desktop BLE reuse without a persisted BLE connectId', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { service } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + jest.mocked(localDb.getDeviceSafe).mockResolvedValueOnce({ + id: 'db-device-1', + uuid: 'PRO2_SERIAL_NUMBER', + } as never); + const interactionGeneration = + await service.notifyInteractiveHardwareOperationStarted({ + deviceDbId: 'db-device-1', + }); + + await expect( + service.notifyInteractiveHardwareOperationSucceeded({ + deviceDbId: 'db-device-1', + interactionGeneration: interactionGeneration as number, + transportType: EHardwareTransportType.DesktopWebBle, + }), + ).resolves.toBe(false); + } finally { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('does not rearm an idle lease from an older BLE operation completion', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + const olderGeneration = + await service.notifyInteractiveHardwareOperationStarted({ + deviceDbId: 'db-device-1', + }); + await service.notifyInteractiveHardwareOperationStarted({ + deviceDbId: 'db-device-1', + }); + + await expect( + service.notifyInteractiveHardwareOperationSucceeded({ + deviceDbId: 'db-device-1', + interactionGeneration: olderGeneration as number, + transportType: EHardwareTransportType.DesktopWebBle, + }), + ).resolves.toBe(false); + await jest.advanceTimersByTimeAsync(30_000); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('cancels the desktop BLE idle attempt when the physical link disconnects', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + await service.notifyHardwareDeviceDisconnected({ + identityKeys: ['PRO2_BLE_ID'], + }); + await jest.advanceTimersByTimeAsync(30_000); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('abandons desktop BLE reuse if the active transport changes to USB', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + getCurrentTransportType, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + getCurrentTransportType.mockResolvedValue(EHardwareTransportType.WEBUSB); + await jest.advanceTimersByTimeAsync(30_000); + + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect( + serviceInternals.submitPortfolioJsonToServer, + ).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('does not extend a desktop BLE lease beyond the low-frequency cooldown', async () => { + jest.useFakeTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: true, + isNative: false, + isSupportDesktopBle: true, + }); + try { + const { + service, + serviceInternals, + tryRunExclusiveOneKeyOperation, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false], + cooldownRemainingMs: 5 * 60_000, + hardwareTransportType: EHardwareTransportType.DesktopWebBle, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await armDesktopBleIdleLease(service); + await jest.advanceTimersByTimeAsync(30_000); + await jest.advanceTimersByTimeAsync(5 * 60_000); + + expect(tryRunExclusiveOneKeyOperation).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isNative: true, + isSupportDesktopBle: false, + }); + } + }); + + test('keeps a fresh snapshot that arrives during the BLE resume delay', async () => { + jest.useFakeTimers(); + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false] }); + uploadPortfolioPackage.mockRejectedValueOnce( + new BluetoothUnavailableWhileUsbConnectedError(), + ); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + await serviceInternals.syncSettledPortfolio({ + ...buildHardwarePayload(), + totalFiat: '2', + }); + await service.notifyInteractiveHardwareOperationSucceeded({ + connectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + }); + + const resumedSync = jest.fn().mockResolvedValue(undefined); + const resumeInternals = service as unknown as { + handleAllNetworksTokenListSettled: ( + payload: IPortfolioSyncSettledPayload, + ) => void; + syncSettledPortfolio: typeof resumedSync; + }; + resumeInternals.syncSettledPortfolio = resumedSync; + const freshPayload = { + ...buildHardwarePayload(), + totalFiat: '3', + }; + resumeInternals.handleAllNetworksTokenListSettled(freshPayload); + + await jest.advanceTimersByTimeAsync(1000); + expect(resumedSync).toHaveBeenCalledTimes(1); + expect(resumedSync).toHaveBeenCalledWith(freshPayload, expect.any(Number)); + + await jest.advanceTimersByTimeAsync(5000); + expect(resumedSync).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + test('keeps a fresh snapshot that arrives while BLE resume state is saving', async () => { + jest.useFakeTimers(); + const { + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false] }); + uploadPortfolioPackage.mockRejectedValueOnce( + new BluetoothUnavailableWhileUsbConnectedError(), + ); + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + let resolveResumeState: (() => void) | undefined; + const resumeStateSaved = new Promise((resolve) => { + resolveResumeState = resolve; + }); + updateTargetState.mockImplementation( + async (_targetKey, state: { bleSilentSyncDisabled?: boolean }) => { + if (state.bleSilentSyncDisabled === false) { + await resumeStateSaved; + } + }, + ); + + const resumedSync = jest.fn().mockResolvedValue(undefined); + const resumeInternals = service as unknown as { + handleAllNetworksTokenListSettled: ( + payload: IPortfolioSyncSettledPayload, + ) => void; + syncSettledPortfolio: typeof resumedSync; + }; + resumeInternals.syncSettledPortfolio = resumedSync; + const resumePromise = service.notifyInteractiveHardwareOperationSucceeded({ + connectId: 'PRO2_CONNECT_ID', + deviceDbId: 'db-device-1', + }); + await Promise.resolve(); + + const freshPayload = { + ...buildHardwarePayload(), + totalFiat: '3', + }; + resumeInternals.handleAllNetworksTokenListSettled(freshPayload); + resolveResumeState?.(); + await expect(resumePromise).resolves.toBe(true); + + await jest.advanceTimersByTimeAsync(6000); + expect(resumedSync).toHaveBeenCalledTimes(1); + expect(resumedSync).toHaveBeenCalledWith(freshPayload, expect.any(Number)); + jest.useRealTimers(); + }); + + test('restores the mobile BLE suspension after a bg runtime restart', async () => { + const { service, serviceInternals, uploadPortfolioPackage } = + prepareHardwareSync({ + busyResults: [false], + targetState: { bleSilentSyncDisabled: true }, + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ status: 'ble-suspended' }), + ); + }); + + 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.find((call) => + Boolean((call[1] as { lastContentHash?: string }).lastContentHash), + )?.[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([ + ['deprecated', { deprecated: true }], + ['mocked', { isMocked: true }], + ])('does not sync a %s hardware wallet', async (_label, walletState) => { + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + ...walletState, + } as never); + jest.mocked(localDb.getWalletDeviceSafe).mockClear(); + const { + getDeviceState, + service, + serviceInternals, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false] }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + + expect(localDb.getWalletDeviceSafe).not.toHaveBeenCalled(); + expect(getDeviceState).not.toHaveBeenCalled(); + expect(serviceInternals.submitPortfolioJsonToServer).not.toHaveBeenCalled(); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'disabled', + walletId: 'hw-1', + }), + ); + }); + + 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('uploads when the current Home target is connected', async () => { + const uploadPortfolioPackage = jest + .fn() + .mockResolvedValue({ portfolioUpdated: true }); + const updateTargetState = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHardwarePortfolioSync({ + backgroundApi: { + serviceHardware: { + getDeviceState: jest.fn().mockResolvedValue({ + identity: { deviceId: 'PRO2_DEVICE_ID' }, + protocol: 'V2', + status: { unlocked: true }, + }), + getCurrentTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.BLE), + isHardwareDeviceConnected: jest.fn().mockResolvedValue(true), + uploadPortfolioPackage, + }, + serviceHardwareUI: { + isHardwareChannelBusy: jest.fn().mockResolvedValue(false), + runExclusiveOneKeyOperation: jest.fn( + async (operation: (lease: object) => Promise) => + operation({ deviceKey: 'db-device-1', owner: Symbol('test') }), + ), + }, + simpleDb: { + accountSelector: { + getSelectedAccount: jest.fn().mockResolvedValue({ + indexedAccountId: 'indexed-account-1', + walletId: 'hw-1', + }), + }, + 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<{ + serverPackageBase64: string; + 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({ + serverPackageBase64: 'AQID', + 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', + packageBase64: 'AQID', + }); + 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(2); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ lastAttemptAt: expect.any(Number) }), + ); + jest.useRealTimers(); + }); + + test('drops an already-packed retry when its wallet is no longer authorized', async () => { + jest.useFakeTimers(); + const { + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ + busyResults: [false, true, false], + }); + + await serviceInternals.syncSettledPortfolio(buildHardwarePayload()); + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + + jest.mocked(localDb.getWalletSafe).mockResolvedValue({ + deprecated: true, + id: 'hw-1', + name: 'OneKey Wallet', + type: 'hw', + } as never); + + await jest.advanceTimersByTimeAsync(1000); + + expect(serviceInternals.submitPortfolioJsonToServer).toHaveBeenCalledTimes( + 1, + ); + expect(uploadPortfolioPackage).not.toHaveBeenCalled(); + expect(updateTargetState).not.toHaveBeenCalled(); + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ status: 'disabled', walletId: 'hw-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: { + serverPackageBase64: string; + 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<{ + serverPackageBase64: string; + serverSubmit: { + bytesLength: number; + contentHash: string; + serverPackageBase64Length: number; + serverPackageBytesLength: number; + }; + }>((resolve) => { + resolveOlderSubmit = resolve; + }); + serviceInternals.submitPortfolioJsonToServer + .mockImplementationOnce(() => { + notifyOlderSubmitStarted?.(); + return olderSubmit; + }) + .mockResolvedValueOnce({ + serverPackageBase64: 'Ag==', + 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?.({ + serverPackageBase64: 'AQ==', + serverSubmit: { + bytesLength: 1, + contentHash: 'older-hash', + serverPackageBase64Length: 4, + serverPackageBytesLength: 1, + }, + }); + await olderTask; + + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + expect(uploadPortfolioPackage.mock.calls[0][0].packageBase64).toBe('Ag=='); + expect(updateTargetState).toHaveBeenCalledTimes(2); + expect(updateTargetState).toHaveBeenCalledWith( + 'db-device-1', + expect.objectContaining({ lastContentHash: expect.any(String) }), + ); + }); + + test('does not record a phantom attempt when a newer generation arrives during persistence', async () => { + const { serviceInternals, updateTargetState, uploadPortfolioPackage } = + prepareHardwareSync({ busyResults: [false, false] }); + let notifyAttemptWriteStarted!: () => void; + const attemptWriteStarted = new Promise((resolve) => { + notifyAttemptWriteStarted = resolve; + }); + let releaseAttemptWrite!: () => void; + const attemptWriteGate = new Promise((resolve) => { + releaseAttemptWrite = resolve; + }); + updateTargetState.mockImplementationOnce(async () => { + notifyAttemptWriteStarted(); + await attemptWriteGate; + }); + + const syncTask = serviceInternals.syncSettledPortfolio( + buildHardwarePayload(), + ); + await attemptWriteStarted; + + // The hardware call and lastAttemptAt persistence start together. Evicting + // the generation while storage is pending must not leave a phantom cooldown. + expect(uploadPortfolioPackage).toHaveBeenCalledTimes(1); + ( + serviceInternals as typeof serviceInternals & { + advanceSyncGeneration: (targetKey: string) => number; + } + ).advanceSyncGeneration('db-device-1'); + releaseAttemptWrite(); + await syncTask; + + expect(updateTargetState).toHaveBeenCalledTimes(1); + expect(updateTargetState).toHaveBeenCalledWith('db-device-1', { + lastAttemptAt: expect.any(Number), + }); + }); + + test('keeps the operation lock until upload settles when attempt persistence fails', async () => { + const { + service, + serviceInternals, + updateTargetState, + uploadPortfolioPackage, + } = prepareHardwareSync({ busyResults: [false, false] }); + let notifyUploadStarted!: () => void; + const uploadStarted = new Promise((resolve) => { + notifyUploadStarted = resolve; + }); + let resolveUpload!: (value: { portfolioUpdated: boolean }) => void; + uploadPortfolioPackage.mockImplementationOnce( + () => + new Promise<{ portfolioUpdated: boolean }>((resolve) => { + resolveUpload = resolve; + notifyUploadStarted(); + }), + ); + updateTargetState.mockRejectedValueOnce(new Error('storage failed')); + let syncSettled = false; + + const syncTask = serviceInternals + .syncSettledPortfolio(buildHardwarePayload()) + .finally(() => { + syncSettled = true; + }); + await uploadStarted; + await Promise.resolve(); + await Promise.resolve(); + + expect(syncSettled).toBe(false); + resolveUpload({ portfolioUpdated: true }); + await syncTask; + expect((service as unknown as { lastResult: unknown }).lastResult).toEqual( + expect.objectContaining({ + status: 'uploaded', + }), + ); + expect(updateTargetState).toHaveBeenLastCalledWith( + 'db-device-1', + expect.objectContaining({ + lastAttemptAt: expect.any(Number), + lastContentHash: expect.any(String), + lastTransferAt: expect.any(Number), + }), + ); + }); + + 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..e6d76aad8ab2 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts @@ -0,0 +1,351 @@ +/* +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 60s hardware sync 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({ + lastAttemptAt: 4000, + lastTransferAt: 1000, + now: 6000, + }), + ).toBe(PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS - 2000); + + 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: '357.2222', + totalFiatCurrency: 'usd', + 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', + totalFiatCurrency: 'usd', + 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', + totalFiatCurrency: 'usd', + 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..77d8602b5607 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts @@ -0,0 +1,149 @@ +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 = 60_000; + +export function getPortfolioDisplayTimestamp({ + timestamp, + timezoneOffsetMinutes = new Date(timestamp).getTimezoneOffset(), +}: { + timestamp: number; + timezoneOffsetMinutes?: number; +}): number { + return timestamp - timezoneOffsetMinutes * 60_000; +} + +export function getPortfolioSyncCooldownRemainingMs({ + lastAttemptAt, + cooldownMs = PORTFOLIO_SYNC_TRANSFER_COOLDOWN_MS, + lastTransferAt, + now, +}: { + lastAttemptAt?: number; + cooldownMs?: number; + lastTransferAt?: number; + now: number; +}): number { + const lastHardwareSyncAt = Math.max(lastAttemptAt ?? 0, lastTransferAt ?? 0); + if (!lastHardwareSyncAt) { + return 0; + } + return Math.max(lastHardwareSyncAt + 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, + totalFiatCurrency: eventPayload.totalFiatCurrency, + 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..26e1b3a6bb05 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.test.ts @@ -0,0 +1,10 @@ +import serviceHardwareUtils from './serviceHardwareUtils'; + +describe('serviceHardwareUtils', () => { + it('keeps identifier suffixes for logs', () => { + expect(serviceHardwareUtils.maskLogIdentifier('PR1234567890')).toBe( + '***7890', + ); + expect(serviceHardwareUtils.maskLogIdentifier(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts index c4ea5a943fc7..633daf46f870 100644 --- a/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwareUtils.ts @@ -1,7 +1,36 @@ +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import { loggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + function hardwareLog(name: string, ...args: any[]) { - console.log(`ServiceHardwareLog@${name}`, ...args); + try { + defaultLogger.hardware.sdkLog.serviceEvent( + name, + args.length <= 1 ? args[0] : args, + ); + } catch { + // Logging must never break hardware flows. + } + // Keep the always-on dev console trace: the scene-gated transport above + // mirrors to the console itself when enabled, so only fill the gap when + // the hardware scene is off. + if (platformEnv.isDev && !loggerConfig.shouldLog('hardware', 'sdkLog')) { + console.log(`ServiceHardwareLog@${name}`, ...args); + } +} + +/** + * Device identifiers (serial numbers, connect ids) must never enter + * persisted logs in full; keep a short suffix for multi-device correlation. + */ +function maskLogIdentifier(value?: string | null): string | undefined { + if (!value) { + return undefined; + } + return `***${value.slice(-4)}`; } export default { hardwareLog, + maskLogIdentifier, }; 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..c7ffc23e2bab --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts @@ -0,0 +1,110 @@ +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', + ]); + }); + + it('does not queue a best-effort operation when the channel is occupied', async () => { + const manager = new HardwareProcessingManager(); + const activeOperation = createDeferred(); + const bestEffortOperation = jest.fn(); + + const active = manager.runExclusiveOneKeyOperation({ + deviceKey: 'device-1', + operation: async () => activeOperation.promise, + }); + await Promise.resolve(); + + await expect( + manager.tryRunExclusiveOneKeyOperation({ + deviceKey: 'device-2', + operation: bestEffortOperation, + }), + ).resolves.toEqual({ acquired: false }); + + activeOperation.resolve(); + await active; + expect(bestEffortOperation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts index f50d2469e31f..e716306153bd 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts @@ -1,8 +1,109 @@ +import { E_ALREADY_LOCKED, Semaphore, tryAcquire } from 'async-mutex'; + import { UserCancelFromOutside } from '@onekeyhq/shared/src/errors'; +export type IOneKeyHardwareOperationLease = { + readonly deviceKey: string | undefined; + readonly owner: symbol; +}; + +export type ITryRunExclusiveOneKeyOperationResult = + | { + acquired: true; + result: T; + } + | { + acquired: false; + }; + export class HardwareProcessingManager { private cancelCallbacks: Map void> = new Map(); + private oneKeyOperationSemaphore = new Semaphore(1); + + private activeOneKeyOperationLease: IOneKeyHardwareOperationLease | undefined; + + private async runWithNewOneKeyOperationLease({ + deviceKey, + operation, + }: { + deviceKey?: string; + operation: (lease: IOneKeyHardwareOperationLease) => Promise; + }): Promise { + 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; + } + } + } + + 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(() => + this.runWithNewOneKeyOperationLease({ + deviceKey, + operation, + }), + ); + } + + async tryRunExclusiveOneKeyOperation({ + deviceKey, + lease, + operation, + }: { + deviceKey?: string; + lease?: IOneKeyHardwareOperationLease; + operation: (lease: IOneKeyHardwareOperationLease) => Promise; + }): Promise> { + if (lease && lease === this.activeOneKeyOperationLease) { + return { + acquired: true, + result: await operation(lease), + }; + } + + let release: (() => void) | undefined; + try { + [, release] = await tryAcquire(this.oneKeyOperationSemaphore).acquire(); + } catch (error) { + if (error === E_ALREADY_LOCKED) { + return { acquired: false }; + } + throw error; + } + + try { + return { + acquired: true, + result: await this.runWithNewOneKeyOperationLease({ + deviceKey, + operation, + }), + }; + } finally { + release(); + } + } + 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..b831b5c4bdbe --- /dev/null +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts @@ -0,0 +1,852 @@ +import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; + +import { + BluetoothUnavailableWhileUsbConnectedError, + DeviceBondError, + DeviceNotFound, + OneKeyLocalError, + UserCancel, +} from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import { firmwareUpdateWorkflowRunningAtom } from '../../states/jotai/atoms'; + +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('@onekeyhq/shared/src/locale/appLocale', () => ({ + appLocale: { + intl: { + formatMessage: jest.fn(() => 'Hardware is busy'), + }, + onLocaleChange: jest.fn(), + }, +})); + +jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ + __esModule: true, + default: { isDesktop: false, isJest: true, isNative: false }, +})); + +jest.mock('../../states/jotai/atoms', () => ({ + EHardwareUiStateAction: {}, + firmwareUpdateWorkflowRunningAtom: { + get: jest.fn(), + }, + hardwareUiStateAtom: { + get: jest.fn(), + set: jest.fn(), + }, + thirdPartyAppInstallAtom: { + set: jest.fn(), + }, + thirdPartyHardwareUiStateAtom: { + set: 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(); + }); +}); + +describe('ServiceHardwareUI.withHardwareProcessing firmware update guard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects a regular OneKey operation before it enters the hardware queue', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(true); + const operation = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHardwareUI({ backgroundApi: {} }); + + await expect( + service.withHardwareProcessing(operation, { + deviceParams: undefined, + }), + ).rejects.toMatchObject({ + message: 'Hardware is busy', + autoToast: false, + }); + expect(operation).not.toHaveBeenCalled(); + expect(service.processingNestedNum).toBe(0); + }); + + it.each([EHardwareVendor.ledger, EHardwareVendor.trezor])( + 'rejects a %s operation while firmware update exclusivity is active', + async (vendor) => { + jest + .mocked(firmwareUpdateWorkflowRunningAtom.get) + .mockResolvedValue(true); + const operation = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHardwareUI({ backgroundApi: {} }); + + await expect( + service.withHardwareProcessing(operation, { + deviceParams: { + dbDevice: { vendor }, + } as never, + }), + ).rejects.toMatchObject({ + message: 'Hardware is busy', + autoToast: false, + }); + expect(operation).not.toHaveBeenCalled(); + }, + ); + + it('allows the firmware workflow to acquire the existing hardware lease', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(true); + const operation = jest.fn().mockResolvedValue('updated'); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + }, + }); + + await expect( + service.withHardwareProcessing(operation, { + allowDuringFirmwareUpdate: true, + deviceParams: undefined, + }), + ).resolves.toBe('updated'); + expect(operation).toHaveBeenCalledTimes(1); + expect(service.processingNestedNum).toBe(0); + }); + + it('rejects regular operations while a firmware workflow is waiting for the hardware lease', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + let releaseActiveOperation: (() => void) | undefined; + let markActiveOperationStarted: (() => void) | undefined; + const activeOperation = new Promise((resolve) => { + releaseActiveOperation = resolve; + }); + const activeOperationStarted = new Promise((resolve) => { + markActiveOperationStarted = resolve; + }); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + }, + }); + const activePromise = service.withHardwareProcessing( + async () => { + markActiveOperationStarted?.(); + return activeOperation; + }, + { + deviceParams: undefined, + }, + ); + await activeOperationStarted; + + const firmwarePromise = service.withHardwareProcessing( + async () => 'updated', + { + allowDuringFirmwareUpdate: true, + deviceParams: undefined, + }, + ); + const regularOperation = jest.fn().mockResolvedValue(undefined); + const regularPromise = service.withHardwareProcessing(regularOperation, { + deviceParams: undefined, + }); + let rejectionBeforeLeaseRelease: unknown; + void regularPromise.catch((error: unknown) => { + rejectionBeforeLeaseRelease = error; + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + const observedRejection = rejectionBeforeLeaseRelease; + releaseActiveOperation?.(); + await Promise.allSettled([activePromise, firmwarePromise, regularPromise]); + + expect(observedRejection).toMatchObject({ + message: 'Hardware is busy', + autoToast: false, + }); + expect(regularOperation).not.toHaveBeenCalled(); + }); + + it('keeps rejecting regular operations while a firmware retry is waiting', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + let releaseFirmwareOperation: ((value: string) => void) | undefined; + let markFirmwareOperationStarted: (() => void) | undefined; + const firmwareOperationStarted = new Promise((resolve) => { + markFirmwareOperationStarted = resolve; + }); + const firmwareOperation = jest.fn( + () => + new Promise((resolve) => { + releaseFirmwareOperation = resolve; + markFirmwareOperationStarted?.(); + }), + ); + const regularOperation = jest.fn().mockResolvedValue(undefined); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + }, + }); + + const firmwarePromise = service.withHardwareProcessing(firmwareOperation, { + allowDuringFirmwareUpdate: true, + deviceParams: undefined, + }); + await firmwareOperationStarted; + + await expect( + service.withHardwareProcessing(regularOperation, { + deviceParams: undefined, + }), + ).rejects.toMatchObject({ + message: 'Hardware is busy', + autoToast: false, + }); + expect(regularOperation).not.toHaveBeenCalled(); + + releaseFirmwareOperation?.('updated'); + await expect(firmwarePromise).resolves.toBe('updated'); + expect(service.processingNestedNum).toBe(0); + }); +}); + +describe('ServiceHardwareUI.withHardwareProcessing USB-priority cleanup', () => { + it('does not send a follow-up cancel after BLE is disabled by USB priority', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + serviceAccount: { + generateHwWalletsMissingXfp: jest.fn(), + }, + serviceFirmwareUpdate: { + delayShouldDetectTimeCheck: jest.fn(), + delayShouldDetectTimeCheckWithDelay: jest.fn(), + }, + }, + }); + const closeHardwareUiStateDialog = jest + .spyOn(service, 'closeHardwareUiStateDialog') + .mockResolvedValue(undefined); + const serviceInternals = service as unknown as { + withHardwareProcessingInternal: ( + operation: () => Promise, + options: { + deviceParams: { + dbDevice: { + connectId: string; + }; + }; + hideCheckingDeviceLoading: boolean; + }, + ) => Promise; + }; + + await expect( + serviceInternals.withHardwareProcessingInternal( + async () => { + throw new BluetoothUnavailableWhileUsbConnectedError(); + }, + { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + }, + }, + hideCheckingDeviceLoading: true, + }, + ), + ).rejects.toBeInstanceOf(BluetoothUnavailableWhileUsbConnectedError); + + expect(closeHardwareUiStateDialog).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + deviceResetToHome: false, + skipDeviceCancel: true, + deviceType: undefined, + }); + }); + + it('does not send a follow-up cancel after Bluetooth pairing fails', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + serviceAccount: { + generateHwWalletsMissingXfp: jest.fn(), + }, + serviceFirmwareUpdate: { + delayShouldDetectTimeCheck: jest.fn(), + delayShouldDetectTimeCheckWithDelay: jest.fn(), + }, + }, + }); + const closeHardwareUiStateDialog = jest + .spyOn(service, 'closeHardwareUiStateDialog') + .mockResolvedValue(undefined); + const serviceInternals = service as unknown as { + withHardwareProcessingInternal: ( + operation: () => Promise, + options: { + deviceParams: { + dbDevice: { + connectId: string; + deviceType: EDeviceType; + }; + }; + hideCheckingDeviceLoading: boolean; + }, + ) => Promise; + }; + + await expect( + serviceInternals.withHardwareProcessingInternal( + async () => { + throw new DeviceNotFound({ + silentMode: true, + payload: { + connectId: 'PRO2_USB', + code: HardwareErrorCode.DeviceNotFound, + inBluetoothCommunication: true, + }, + }); + }, + { + deviceParams: { + dbDevice: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + }, + hideCheckingDeviceLoading: true, + }, + ), + ).rejects.toBeInstanceOf(DeviceNotFound); + + expect(closeHardwareUiStateDialog).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + deviceResetToHome: false, + skipDeviceCancel: true, + deviceType: EDeviceType.Pro2, + }); + }); + + it('does not send a follow-up cancel after a BLE bond error', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + serviceAccount: { + generateHwWalletsMissingXfp: jest.fn(), + }, + serviceFirmwareUpdate: { + delayShouldDetectTimeCheck: jest.fn(), + delayShouldDetectTimeCheckWithDelay: jest.fn(), + }, + }, + }); + const closeHardwareUiStateDialog = jest + .spyOn(service, 'closeHardwareUiStateDialog') + .mockResolvedValue(undefined); + const serviceInternals = service as unknown as { + withHardwareProcessingInternal: ( + operation: () => Promise, + options: { + deviceParams: { + dbDevice: { + connectId: string; + deviceType: EDeviceType; + }; + }; + hideCheckingDeviceLoading: boolean; + }, + ) => Promise; + }; + + await expect( + serviceInternals.withHardwareProcessingInternal( + async () => { + throw new DeviceBondError({ + payload: { + connectId: 'PRO2_BLE_ID', + code: HardwareErrorCode.BleDeviceBondError, + }, + }); + }, + { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + deviceType: EDeviceType.Pro2, + }, + }, + hideCheckingDeviceLoading: true, + }, + ), + ).rejects.toBeInstanceOf(DeviceBondError); + + expect(closeHardwareUiStateDialog).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + deviceResetToHome: false, + skipDeviceCancel: true, + deviceType: EDeviceType.Pro2, + }); + }); + + it('still sends cancel after the user dismisses a Pro2 hardware prompt', async () => { + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + cancelTimer: undefined, + getFeaturesMutex: { + isLocked: jest.fn(() => false), + waitForUnlock: jest.fn(), + }, + }, + serviceAccount: { + generateHwWalletsMissingXfp: jest.fn(), + }, + serviceFirmwareUpdate: { + delayShouldDetectTimeCheck: jest.fn(), + delayShouldDetectTimeCheckWithDelay: jest.fn(), + }, + }, + }); + const closeHardwareUiStateDialog = jest + .spyOn(service, 'closeHardwareUiStateDialog') + .mockResolvedValue(undefined); + const serviceInternals = service as unknown as { + withHardwareProcessingInternal: ( + operation: () => Promise, + options: { + deviceParams: { + dbDevice: { + connectId: string; + deviceType: EDeviceType; + }; + }; + hideCheckingDeviceLoading: boolean; + }, + ) => Promise; + }; + + await expect( + serviceInternals.withHardwareProcessingInternal( + async () => { + throw new UserCancel({ + payload: { + connectId: 'PRO2_USB', + code: HardwareErrorCode.ActionCancelled, + }, + }); + }, + { + deviceParams: { + dbDevice: { + connectId: 'PRO2_USB', + deviceType: EDeviceType.Pro2, + }, + }, + hideCheckingDeviceLoading: true, + }, + ), + ).rejects.toBeInstanceOf(UserCancel); + + expect(closeHardwareUiStateDialog).toHaveBeenCalledWith({ + connectId: 'PRO2_USB', + deviceResetToHome: false, + skipDeviceCancel: false, + deviceType: EDeviceType.Pro2, + }); + }); +}); + +describe('ServiceHardwareUI Portfolio BLE resume notification', () => { + beforeEach(() => { + Object.assign(platformEnv, { isDesktop: false, isNative: true }); + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(false); + }); + + afterEach(() => { + Object.assign(platformEnv, { isDesktop: false, isNative: false }); + }); + + function prepareService() { + const notifyInteractiveHardwareOperationStarted = jest + .fn() + .mockResolvedValue(1); + const notifyInteractiveHardwareOperationSucceeded = jest + .fn() + .mockResolvedValue(true); + const service = new ServiceHardwareUI({ + backgroundApi: { + serviceHardware: { + getCurrentTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.DesktopWebBle), + }, + serviceHardwarePortfolioSync: { + notifyInteractiveHardwareOperationStarted, + notifyInteractiveHardwareOperationSucceeded, + }, + }, + }); + const serviceInternals = service as unknown as { + runExclusiveOneKeyOperation: ( + operation: (lease: object) => Promise, + ) => Promise; + withHardwareProcessingInternal: ( + operation: () => Promise, + ) => Promise; + }; + serviceInternals.runExclusiveOneKeyOperation = async (operation) => + operation({ owner: Symbol('test') }); + serviceInternals.withHardwareProcessingInternal = async (operation) => + operation(); + return { + notifyInteractiveHardwareOperationStarted, + notifyInteractiveHardwareOperationSucceeded, + service, + serviceInternals, + }; + } + + it('resumes Portfolio only after a successful native user operation', async () => { + const { notifyInteractiveHardwareOperationSucceeded, service } = + prepareService(); + + await expect( + service.withHardwareProcessing(async () => 'address', { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }), + ).resolves.toBe('address'); + + expect(notifyInteractiveHardwareOperationSucceeded).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + deviceDbId: 'db-device-1', + }); + }); + + it('keeps Portfolio suspended when the native user operation fails', async () => { + const { notifyInteractiveHardwareOperationSucceeded, service } = + prepareService(); + + await expect( + service.withHardwareProcessing( + async () => { + throw new OneKeyLocalError('link disabled'); + }, + { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }, + ), + ).rejects.toThrow('link disabled'); + + expect(notifyInteractiveHardwareOperationSucceeded).not.toHaveBeenCalled(); + }); + + it('arms desktop Portfolio sync only after a successful BLE operation', async () => { + Object.assign(platformEnv, { isDesktop: true, isNative: false }); + const { + notifyInteractiveHardwareOperationStarted, + notifyInteractiveHardwareOperationSucceeded, + service, + } = prepareService(); + + await expect( + service.withHardwareProcessing(async () => 'address', { + deviceParams: { + dbDevice: { + connectId: 'PRO2_USB_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }), + ).resolves.toBe('address'); + await Promise.resolve(); + + expect(notifyInteractiveHardwareOperationStarted).toHaveBeenCalledWith({ + connectId: 'PRO2_USB_ID', + deviceDbId: 'db-device-1', + }); + expect(notifyInteractiveHardwareOperationSucceeded).toHaveBeenCalledWith({ + connectId: 'PRO2_USB_ID', + deviceDbId: 'db-device-1', + interactionGeneration: 1, + transportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + it('does not notify Portfolio sync for an unsupported desktop device', async () => { + Object.assign(platformEnv, { isDesktop: true, isNative: false }); + const { + notifyInteractiveHardwareOperationStarted, + notifyInteractiveHardwareOperationSucceeded, + service, + } = prepareService(); + + await expect( + service.withHardwareProcessing(async () => 'address', { + deviceParams: { + dbDevice: { + connectId: 'CLASSIC_BLE_ID', + connectProtocol: 'V1', + deviceType: EDeviceType.Classic, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }), + ).resolves.toBe('address'); + + expect(notifyInteractiveHardwareOperationStarted).not.toHaveBeenCalled(); + expect(notifyInteractiveHardwareOperationSucceeded).not.toHaveBeenCalled(); + }); + + it('keeps the existing Portfolio lease when firmware preflight rejects', async () => { + Object.assign(platformEnv, { isDesktop: true, isNative: false }); + jest.mocked(firmwareUpdateWorkflowRunningAtom.get).mockResolvedValue(true); + const { notifyInteractiveHardwareOperationStarted, service } = + prepareService(); + + await expect( + service.withHardwareProcessing(async () => 'address', { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }), + ).rejects.toThrow('Hardware is busy'); + + expect(notifyInteractiveHardwareOperationStarted).not.toHaveBeenCalled(); + }); + + it('keeps the existing Portfolio lease when internal preflight rejects', async () => { + Object.assign(platformEnv, { isDesktop: true, isNative: false }); + const { + notifyInteractiveHardwareOperationStarted, + service, + serviceInternals, + } = prepareService(); + serviceInternals.withHardwareProcessingInternal = async () => { + throw new OneKeyLocalError('Hardware is busy'); + }; + + await expect( + service.withHardwareProcessing(async () => 'address', { + deviceParams: { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never, + }), + ).rejects.toThrow('Hardware is busy'); + + expect(notifyInteractiveHardwareOperationStarted).not.toHaveBeenCalled(); + }); + + it('arms desktop Portfolio sync only after the outer leased operation finishes', async () => { + Object.assign(platformEnv, { isDesktop: true, isNative: false }); + const { + notifyInteractiveHardwareOperationStarted, + notifyInteractiveHardwareOperationSucceeded, + service, + } = prepareService(); + const deviceParams = { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never; + + await service.withHardwareProcessing( + async (oneKeyOperationLease) => { + await service.withHardwareProcessing(async () => 'inner', { + deviceParams, + oneKeyOperationLease, + }); + expect( + notifyInteractiveHardwareOperationSucceeded, + ).not.toHaveBeenCalled(); + return 'outer'; + }, + { deviceParams }, + ); + await Promise.resolve(); + + expect(notifyInteractiveHardwareOperationStarted).toHaveBeenCalledTimes(1); + expect(notifyInteractiveHardwareOperationSucceeded).toHaveBeenCalledTimes( + 1, + ); + expect(notifyInteractiveHardwareOperationSucceeded).toHaveBeenCalledWith({ + connectId: 'PRO2_BLE_ID', + deviceDbId: 'db-device-1', + interactionGeneration: 1, + transportType: EHardwareTransportType.DesktopWebBle, + }); + }); + + it('keeps native Portfolio suspended when an outer leased operation fails', async () => { + const { notifyInteractiveHardwareOperationSucceeded, service } = + prepareService(); + const deviceParams = { + dbDevice: { + connectId: 'PRO2_BLE_ID', + connectProtocol: 'V2', + deviceType: EDeviceType.Pro2, + id: 'db-device-1', + vendor: EHardwareVendor.onekey, + }, + } as never; + + await expect( + service.withHardwareProcessing( + async (oneKeyOperationLease) => { + await service.withHardwareProcessing(async () => 'inner', { + deviceParams, + oneKeyOperationLease, + }); + throw new OneKeyLocalError('outer failed'); + }, + { deviceParams }, + ), + ).rejects.toThrow('outer failed'); + + expect(notifyInteractiveHardwareOperationSucceeded).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts index 58a50f5c9294..10ee3fd98002 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts @@ -5,6 +5,7 @@ import { backgroundMethod, } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { IOneKeyError } from '@onekeyhq/shared/src/errors/types/errorTypes'; import { isHardwareError, isHardwareErrorByCode, @@ -13,13 +14,18 @@ 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'; import { appLocale } from '@onekeyhq/shared/src/locale/appLocale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; import { + EHardwareCallContext, EHardwareVendor, EOneKeyDeviceMode, } from '@onekeyhq/shared/types/device'; @@ -31,19 +37,28 @@ 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 = { + allowDuringFirmwareUpdate?: boolean; hideCheckingDeviceLoading?: boolean; skipDeviceCancel?: boolean; // cancel device at end skipCloseHardwareUiStateDialog?: boolean; // close state dialog at end @@ -54,11 +69,13 @@ export type IWithHardwareProcessingControlParams = { export type IWithHardwareProcessingOptions = { deviceParams: IDeviceSharedCallParams | undefined; debugMethodName?: string; + oneKeyOperationLease?: IOneKeyHardwareOperationLease; onFinally?: () => void; } & IWithHardwareProcessingControlParams; export type ICloseHardwareUiStateDialogParams = { skipDeviceCancel?: boolean; + immediateDeviceCancel?: boolean; delay?: number; connectId: string | undefined; walletId?: string; @@ -66,57 +83,103 @@ export type ICloseHardwareUiStateDialogParams = { deviceResetToHome?: boolean; hardClose?: boolean; // hard close dialog by event bus skipDelayClose?: boolean; + deviceType?: string; }; +const HARDWARE_CONNECTION_CANCEL_SKIP_CODES = [ + HardwareErrorCode.DeviceNotFound, + HardwareErrorCode.BleScanError, + HardwareErrorCode.BlePermissionError, + HardwareErrorCode.BleLocationError, + HardwareErrorCode.BleRequiredUUID, + HardwareErrorCode.BleConnectedError, + HardwareErrorCode.PollingTimeout, + HardwareErrorCode.BleDeviceNotBonded, + HardwareErrorCode.BleServiceNotFound, + HardwareErrorCode.BleCharacteristicNotFound, + HardwareErrorCode.BleMonitorError, + HardwareErrorCode.BleCharacteristicNotifyError, + HardwareErrorCode.BleWriteCharacteristicError, + HardwareErrorCode.BleAlreadyConnected, + HardwareErrorCode.BleLocationServicesDisabled, + HardwareErrorCode.BleTimeoutError, + HardwareErrorCode.BleForceCleanRunPromise, + HardwareErrorCode.BleDeviceBondError, + HardwareErrorCode.BlePeerRemovedPairingInformation, + HardwareErrorCode.BleUnavailableWhileUsbConnected, + HardwareErrorCode.BleCharacteristicNotifyChangeFailure, + HardwareErrorCode.BleDeviceDisconnected, + HardwareErrorCode.BlePoweredOff, + HardwareErrorCode.BleUnsupported, +]; + @backgroundClass() class ServiceHardwareUI extends ServiceBase { private deviceCacheByConnectId: Map = new Map(); + private firmwareUpdateExclusiveDepth = 0; + 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 +318,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 +402,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, @@ -406,12 +486,14 @@ class ServiceHardwareUI extends ServiceBase { /* eslint-disable prefer-const */ let { skipDeviceCancel = true, + immediateDeviceCancel = false, delay, connectId, walletId, reason, deviceResetToHome = true, hardClose, + deviceType, } = params; /* eslint-enable prefer-const */ @@ -438,6 +520,8 @@ class ServiceHardwareUI extends ServiceBase { void this.backgroundApi.serviceHardware.cancel({ connectId, forceDeviceResetToHome: deviceResetToHome, + immediate: immediateDeviceCancel, + deviceType, }); } } catch (_error) { @@ -451,7 +535,187 @@ class ServiceHardwareUI extends ServiceBase { return this.processingNestedNum === 1; } + @backgroundMethod() + async isHardwareChannelBusy(_params?: { connectId?: string }) { + const [ + hardwareUiState, + firmwareUpdateWorkflowRunning, + deviceSearchInProgress, + ] = await Promise.all([ + hardwareUiStateAtom.get(), + firmwareUpdateWorkflowRunningAtom.get(), + this.backgroundApi.serviceHardware.isDeviceSearchInProgress(), + ]); + return ( + this.processingNestedNum > 0 || + this.backgroundApi.serviceHardware.getFeaturesMutex.isLocked() || + firmwareUpdateWorkflowRunning || + deviceSearchInProgress || + Boolean(hardwareUiState) + ); + } + async withHardwareProcessing( + fn: (lease?: IOneKeyHardwareOperationLease) => Promise, + params: IWithHardwareProcessingOptions, + ): Promise { + const device = params.deviceParams?.dbDevice; + const vendor = device?.vendor ?? device?.settings?.vendor; + const isThirdPartyVendor = getVendorProfile( + vendor ?? EHardwareVendor.onekey, + ).isThirdParty; + const supportsPortfolioSync = Boolean( + device && + isProtocolV2ProductType(device.deviceType) && + (device.connectProtocol === 'V2' || + device.deviceStateInfo?.protocol === 'V2') && + vendor === EHardwareVendor.onekey, + ); + // Nested calls reuse the active OneKey operation lease. Only the lease + // owner represents a complete interaction and may resume Portfolio sync. + const shouldNotifyPortfolioInteraction = + !params.oneKeyOperationLease && + !isThirdPartyVendor && + supportsPortfolioSync; + let desktopInteractionGeneration: number | undefined; + if ( + !params.allowDuringFirmwareUpdate && + (this.firmwareUpdateExclusiveDepth > 0 || + (await firmwareUpdateWorkflowRunningAtom.get())) + ) { + throw new OneKeyLocalError({ + message: appLocale.intl.formatMessage({ + id: ETranslations.feedback_hardware_is_busy, + }), + autoToast: false, + }); + } + if (isThirdPartyVendor) { + return this.withHardwareProcessingInternal(() => fn(undefined), params); + } + const tracksFirmwareUpdateExclusivity = Boolean( + params.allowDuringFirmwareUpdate, + ); + if (tracksFirmwareUpdateExclusivity) { + this.firmwareUpdateExclusiveDepth += 1; + } + // Keep operation-level serialization during the mixed-SDK rollout and for + // shared lifecycle work outside the correlated PIN/passphrase response path. + try { + let successfulTransportType: EHardwareTransportType | undefined; + const result = await this.runExclusiveOneKeyOperation( + async (lease) => { + const operationResult = await this.withHardwareProcessingInternal( + async () => { + if ( + shouldNotifyPortfolioInteraction && + platformEnv.isDesktop && + device?.id + ) { + const generation = + await this.backgroundApi.serviceHardwarePortfolioSync + .notifyInteractiveHardwareOperationStarted({ + connectId: device.connectId, + deviceDbId: device.id, + }) + .catch(() => undefined); + if (typeof generation === 'number') { + desktopInteractionGeneration = generation; + } + } + return fn(lease); + }, + params, + ); + if (platformEnv.isDesktop && device?.id) { + successfulTransportType = await this.backgroundApi.serviceHardware + .getCurrentTransportType() + .catch(() => undefined); + } + return operationResult; + }, + { + deviceKey: + device?.id || device?.deviceId || device?.uuid || device?.connectId, + lease: params.oneKeyOperationLease, + }, + ); + if ( + shouldNotifyPortfolioInteraction && + platformEnv.isNative && + device?.id + ) { + void this.backgroundApi.serviceHardwarePortfolioSync + .notifyInteractiveHardwareOperationSucceeded({ + connectId: device.connectId, + deviceDbId: device.id, + }) + .catch(() => undefined); + } else if ( + shouldNotifyPortfolioInteraction && + platformEnv.isDesktop && + device?.id && + desktopInteractionGeneration !== undefined && + successfulTransportType + ) { + void this.backgroundApi.serviceHardwarePortfolioSync + .notifyInteractiveHardwareOperationSucceeded({ + connectId: device.connectId, + deviceDbId: device.id, + interactionGeneration: desktopInteractionGeneration, + transportType: successfulTransportType, + }) + .catch(() => undefined); + } + return result; + } finally { + if (tracksFirmwareUpdateExclusivity) { + this.firmwareUpdateExclusiveDepth = Math.max( + this.firmwareUpdateExclusiveDepth - 1, + 0, + ); + } + } + } + + runExclusiveOneKeyOperation( + operation: (lease: IOneKeyHardwareOperationLease) => Promise, + { + deviceKey, + lease, + }: { + deviceKey?: string; + lease?: IOneKeyHardwareOperationLease; + } = {}, + ) { + return this.hardwareProcessingManager.runExclusiveOneKeyOperation({ + deviceKey, + lease, + operation, + }); + } + + async tryRunExclusiveOneKeyOperation( + operation: (lease: IOneKeyHardwareOperationLease) => Promise, + { + deviceKey, + lease, + }: { + deviceKey?: string; + lease?: IOneKeyHardwareOperationLease; + } = {}, + ) { + if (await this.isHardwareChannelBusy()) { + return { acquired: false } as const; + } + return this.hardwareProcessingManager.tryRunExclusiveOneKeyOperation({ + deviceKey, + lease, + operation, + }); + } + + private async withHardwareProcessingInternal( fn: () => Promise, params: IWithHardwareProcessingOptions, ): Promise { @@ -472,13 +736,13 @@ class ServiceHardwareUI extends ServiceBase { const device = deviceParams?.dbDevice; const connectId = device?.connectId; let isOuterCall = false; + let skipDeviceCancelAfterError = false; // Third-party vendors (Ledger) don't use OneKey SDK // Skip all OneKey-specific flows: DeviceChecking dialog, mutex, cancel, resetToHome const isThirdPartyVendor = getVendorProfile( device?.vendor ?? EHardwareVendor.onekey, ).isThirdParty; - let deviceResetToHome = true; let isBusy = false; try { @@ -510,6 +774,13 @@ class ServiceHardwareUI extends ServiceBase { await this.cleanHardwareUiState(); if (connectId && !hideCheckingDeviceLoading && !isThirdPartyVendor) { + // 先在统一连接管理器中确定本次实际传输,再显示动画,避免 BLE + // 通讯使用上一次持久化的 USB 弹窗。这里只选择传输,不发起设备通讯。 + await this.backgroundApi.serviceHardware.prepareHardwareTransport({ + connectId, + connectProtocol: device?.connectProtocol, + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }); await this.showCheckingDeviceDialog({ connectId, }); @@ -589,6 +860,16 @@ class ServiceHardwareUI extends ServiceBase { 'withHardwareProcessing ERROR stack: ', (error as Error)?.stack, ); + // The SDK error payload never carries the device it came from, so stamp + // the connectId this call was made with — UI actions (firmware update) + // can then target the failing device instead of resolving one. + if (connectId && isHardwareError({ error: error as IOneKeyError })) { + const hardwareError = error as IOneKeyError; + hardwareError.payload = { + ...hardwareError.payload, + connectId: hardwareError.payload?.connectId ?? connectId, + }; + } if ( isHardwareErrorByCode({ error: error as any, @@ -604,36 +885,26 @@ class ServiceHardwareUI extends ServiceBase { }, 300); } } - // skip reset to home if user cancel if ( + isHardwareErrorByCode({ + error: error as any, + code: HARDWARE_CONNECTION_CANCEL_SKIP_CODES, + }) + ) { + // Pairing / link-setup failures never have an acquired session. + // Sending Cancel here can re-enter BLE and raise the OS pairing prompt. + skipDeviceCancelAfterError = true; + deviceResetToHome = false; + } else if ( isHardwareErrorByCode({ error: error as any, code: [ HardwareErrorCode.ActionCancelled, HardwareErrorCode.CallQueueActionCancelled, HardwareErrorCode.PinCancelled, - HardwareErrorCode.DeviceNotFound, // Hardware interrupts generally have follow-up actions; skip reset to home HardwareErrorCode.DeviceInterruptedFromUser, HardwareErrorCode.DeviceInterruptedFromOutside, - // ble connect error, skip reset to home - HardwareErrorCode.BleScanError, - HardwareErrorCode.BlePermissionError, - HardwareErrorCode.BleLocationError, - HardwareErrorCode.BleRequiredUUID, - HardwareErrorCode.BleConnectedError, - HardwareErrorCode.BleDeviceNotBonded, - HardwareErrorCode.BleServiceNotFound, - HardwareErrorCode.BleCharacteristicNotFound, - HardwareErrorCode.BleMonitorError, - HardwareErrorCode.BleCharacteristicNotifyError, - HardwareErrorCode.BleWriteCharacteristicError, - HardwareErrorCode.BleAlreadyConnected, - HardwareErrorCode.BleLocationServicesDisabled, - HardwareErrorCode.BleTimeoutError, - HardwareErrorCode.BleForceCleanRunPromise, - HardwareErrorCode.BleDeviceBondError, - HardwareErrorCode.BleCharacteristicNotifyChangeFailure, ], }) ) { @@ -659,8 +930,7 @@ class ServiceHardwareUI extends ServiceBase { } else if (connectId) { if (!skipCloseHardwareUiStateDialog) { const closeDialogParams = { - // skipDeviceCancel: true, - skipDeviceCancel: skipDeviceCancel ?? false, // auto cancel if device call interaction action + skipDeviceCancel: skipDeviceCancel || skipDeviceCancelAfterError, deviceResetToHome, }; if (isBusy) { @@ -671,6 +941,7 @@ class ServiceHardwareUI extends ServiceBase { connectId, skipDeviceCancel: closeDialogParams.skipDeviceCancel, deviceResetToHome: closeDialogParams.deviceResetToHome, + deviceType: device?.deviceType, }); void this.backgroundApi.serviceAccount.generateHwWalletsMissingXfp({ wallet: deviceParams?.dbWallet, 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/ServiceIpTable.resilience.test.ts b/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts index 65fb47a446b9..2c05b5d50612 100644 --- a/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts +++ b/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { + testDomainSpeed, + testIpSpeed, +} from '@onekeyhq/shared/src/request/helpers/ipTableAdapter'; import type { IIpTableRemoteConfig } from '@onekeyhq/shared/src/request/types/ipTable'; import ServiceIpTable from './ServiceIpTable'; @@ -66,6 +70,8 @@ jest.mock('./ServiceBase', () => ({ })); const DOMAIN = 'onekeycn.com'; +const mockedTestDomainSpeed = testDomainSpeed as jest.Mock; +const mockedTestIpSpeed = testIpSpeed as jest.Mock; function buildConfig(ip: string, version = 1): IIpTableRemoteConfig { return { @@ -160,6 +166,45 @@ describe('ServiceIpTable resilience', () => { ); }); + it('scores API endpoints only with wallet health probes', async () => { + const { service, ipTableDb } = createService(); + const config = buildConfig('1.1.1.1', 1); + + jest.spyOn(service as any, 'isIpTableEnabled').mockResolvedValue(true); + const testMultipleTimesSpy = jest + .spyOn(service as any, 'testMultipleTimes') + .mockImplementation(async (...args: unknown[]) => { + const testFn = args[0] as () => Promise; + return testFn(); + }); + jest.spyOn(service, 'getConfig').mockResolvedValue({ + config, + runtime: undefined, + }); + mockedTestDomainSpeed.mockResolvedValue(20); + mockedTestIpSpeed.mockResolvedValue(25); + + await (service as any).selectBestEndpointForDomainInternal(DOMAIN, { + trigger: 'periodic', + }); + + expect(testMultipleTimesSpy).toHaveBeenCalledTimes(2); + expect(mockedTestDomainSpeed).toHaveBeenCalledTimes(1); + expect(mockedTestIpSpeed).toHaveBeenCalledWith( + '1.1.1.1', + DOMAIN, + expect.any(String), + expect.any(Number), + ); + expect(ipTableDb.commitSpeedTestResult).toHaveBeenCalledWith( + expect.objectContaining({ + domain: DOMAIN, + lastBestIp: '1.1.1.1', + selection: '', + }), + ); + }); + it('starts the queued config-change rerun after the stale round settles', async () => { const { service } = createService(); const internalSpy = jest diff --git a/packages/kit-bg/src/services/ServiceLogger.ts b/packages/kit-bg/src/services/ServiceLogger.ts index d504b4d7bb39..41c26ad62391 100644 --- a/packages/kit-bg/src/services/ServiceLogger.ts +++ b/packages/kit-bg/src/services/ServiceLogger.ts @@ -3,6 +3,8 @@ import { backgroundMethod, } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { ILoggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; +import { loggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; import type { IApiClientResponse } from '@onekeyhq/shared/types/endpoint'; @@ -50,6 +52,17 @@ class ServiceLogger extends ServiceBase { return Promise.resolve(true); } + /** + * Persist a logger config from the UI. On native, main and bg hold + * separate LoggerConfigManager singletons in isolated JS heaps; routing the + * save through bg keeps the runtime that emits most logs in sync without a + * restart. Callers must mirror the config into their own runtime afterwards. + */ + @backgroundMethod() + async updateLoggerConfig(config: ILoggerConfig) { + loggerConfig.saveLoggerConfig(config); + } + @backgroundMethod() async requestUploadToken(payload: { sizeBytes: number; sha256: string }) { if (payload.sizeBytes <= 0) { 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..5ee812ea54ed --- /dev/null +++ b/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts @@ -0,0 +1,170 @@ +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: { + imageJpegBase64: 'full-image', + thumbnailJpegBase64: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }, + }); + + expect(uploadPro2Nft).toHaveBeenCalledWith({ + connectId: 'device-connect-id', + imageJpegBase64: 'full-image', + thumbnailJpegBase64: '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('surfaces an existing Pro2 NFT as a duplicate-file failure', async () => { + const { service, uploadPro2Nft } = buildService(EDeviceType.Pro2); + const error = Object.assign( + new Error('Failure_DataError,NFT already exists : 800'), + { + payload: { + code: 800, + error: 'Failure_DataError,NFT already exists', + }, + }, + ); + uploadPro2Nft.mockImplementationOnce(async () => { + throw error; + }); + + await expect( + service.uploadNFTImageToDevice({ + accountId: 'account-id', + pro2UploadParams: { + imageJpegBase64: 'full-image', + thumbnailJpegBase64: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }, + }), + ).rejects.toBe(error); + }); + + it('keeps other Pro2 data errors as failures', async () => { + const { service, uploadPro2Nft } = buildService(EDeviceType.Pro2); + const error = Object.assign(new Error('Failure_DataError,Invalid NFT'), { + payload: { + code: 800, + error: 'Failure_DataError,Invalid NFT', + }, + }); + uploadPro2Nft.mockImplementationOnce(async () => { + throw error; + }); + + await expect( + service.uploadNFTImageToDevice({ + accountId: 'account-id', + pro2UploadParams: { + imageJpegBase64: 'full-image', + thumbnailJpegBase64: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }, + }), + ).rejects.toBe(error); + }); + + 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..97135e587866 100644 --- a/packages/kit-bg/src/services/ServiceNFT.ts +++ b/packages/kit-bg/src/services/ServiceNFT.ts @@ -7,8 +7,10 @@ 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 { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EServiceEndpointEnum } from '@onekeyhq/shared/types/endpoint'; import type { @@ -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 = { + imageJpegBase64: string; + thumbnailJpegBase64: 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 && isProtocolV2ProductType(device.deviceType)) { + 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..f144e1402245 100644 --- a/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts +++ b/packages/kit-bg/src/services/ServiceNetwork/ServiceNetwork.ts @@ -1,4 +1,4 @@ -import { EFirmwareType } from '@onekeyfe/hd-shared'; +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import BigNumber from 'bignumber.js'; import { isEmpty, isNil, uniq, uniqBy } from 'lodash'; import pLimit from 'p-limit'; @@ -24,7 +24,12 @@ import { AGGREGATE_TOKEN_MOCK_NETWORK_ID, NETWORK_SHOW_VALUE_THRESHOLD_USD, } from '@onekeyhq/shared/src/consts/networkConsts'; -import { IMPL_BTC, SEPERATOR } from '@onekeyhq/shared/src/engine/engineConsts'; +import { + IMPL_BTC, + IMPL_SOL, + IMPL_TRON, + SEPERATOR, +} from '@onekeyhq/shared/src/engine/engineConsts'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -1100,20 +1105,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() @@ -1328,6 +1350,18 @@ class ServiceNetwork extends ServiceBase { }); if (walletDevice) { + if (walletDevice.deviceType === EDeviceType.Pro2) { + const networksNotSupportedByPro2QrWallet = networkVaultSettings + .filter( + (o) => + o.network.impl === IMPL_SOL || o.network.impl === IMPL_TRON, + ) + .map((o) => o.network.id); + networkIdsIncompatible = networkIdsIncompatible.concat( + networksNotSupportedByPro2QrWallet, + ); + } + // Filter by firmware type (Bitcoin Only, etc.) const wallet = await this.backgroundApi.serviceAccount.getWalletSafe({ walletId, @@ -1342,7 +1376,7 @@ class ServiceNetwork extends ServiceBase { networkIdsIncompatible.concat(nonBtcNetworks); } } - // Qr account only support btc/evm network + // Pro2 QR accounts only support BTC/EVM networks. } } diff --git a/packages/kit-bg/src/services/ServiceQrWallet/ServiceQrWallet.ts b/packages/kit-bg/src/services/ServiceQrWallet/ServiceQrWallet.ts index f55db2e7d5a8..abf3bea75d4a 100644 --- a/packages/kit-bg/src/services/ServiceQrWallet/ServiceQrWallet.ts +++ b/packages/kit-bg/src/services/ServiceQrWallet/ServiceQrWallet.ts @@ -24,6 +24,7 @@ import { import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { checkIsDefined } from '@onekeyhq/shared/src/utils/assertUtils'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { resolveQrWalletDeviceType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; import type { IQrWalletDevice } from '@onekeyhq/shared/types/device'; @@ -264,6 +265,7 @@ class ServiceQrWallet extends ServiceBase { walletId: byWallet.id, backgroundApi: this.backgroundApi, includingNetworkWithGlobalDeriveType: true, + deviceType: byDevice?.deviceType, firmwareType: byDevice?.featuresInfo?.$app_firmware_type, }); let allDefaultAddAccountNetworksIds = allDefaultAddAccountNetworks.map( @@ -374,6 +376,9 @@ class ServiceQrWallet extends ServiceBase { } const qrDevice: IQrWalletDevice = { name: airGapMultiAccounts.device || 'QR Wallet', + deviceType: resolveQrWalletDeviceType({ + deviceName: airGapMultiAccounts.device, + }), deviceId: airGapMultiAccounts.deviceId || '', version: airGapMultiAccounts.deviceVersion || '', xfp: airGapMultiAccounts.masterFingerprint || '', diff --git a/packages/kit-bg/src/services/ServiceSetting.desktopBluetooth.test.ts b/packages/kit-bg/src/services/ServiceSetting.desktopBluetooth.test.ts new file mode 100644 index 000000000000..092f7d033942 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceSetting.desktopBluetooth.test.ts @@ -0,0 +1,37 @@ +import { settingsPersistAtom } from '../states/jotai/atoms/settings'; + +import ServiceSetting from './ServiceSetting'; + +describe('ServiceSetting desktop Bluetooth', () => { + const originalIsInBackground = globalThis.$onekeyIsInBackground; + + beforeAll(() => { + globalThis.$onekeyIsInBackground = true; + }); + + afterAll(() => { + globalThis.$onekeyIsInBackground = originalIsInBackground; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + { persistedValue: undefined, expected: true }, + { persistedValue: true, expected: true }, + { persistedValue: false, expected: false }, + ])( + 'resolves $persistedValue to $expected', + async ({ persistedValue, expected }) => { + jest.spyOn(settingsPersistAtom, 'get').mockResolvedValue({ + enableDesktopBluetooth: persistedValue, + } as never); + const service = new ServiceSetting({ + backgroundApi: { simpleDb: { appStatus: {} } }, + }); + + await expect(service.getEnableDesktopBluetooth()).resolves.toBe(expected); + }, + ); +}); diff --git a/packages/kit-bg/src/services/ServiceSetting.ts b/packages/kit-bg/src/services/ServiceSetting.ts index 24ca4640878d..c0d897f83915 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(); } @@ -850,7 +856,7 @@ class ServiceSetting extends ServiceBase { @backgroundMethod() public async getEnableDesktopBluetooth() { const { enableDesktopBluetooth } = await settingsPersistAtom.get(); - return enableDesktopBluetooth ?? false; + return enableDesktopBluetooth ?? true; } @backgroundMethod() diff --git a/packages/kit-bg/src/services/ServiceStaking.ts b/packages/kit-bg/src/services/ServiceStaking.ts index e4783e8bf796..76605df8cc84 100644 --- a/packages/kit-bg/src/services/ServiceStaking.ts +++ b/packages/kit-bg/src/services/ServiceStaking.ts @@ -20,6 +20,7 @@ import { PROMISE_CONCURRENCY_LIMIT, promiseAllSettledEnhanced, } from '@onekeyhq/shared/src/utils/promiseUtils'; +import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import type { INetworkAccount } from '@onekeyhq/shared/types/account'; import type { @@ -1555,7 +1556,43 @@ class ServiceStaking extends ServiceBase { const response = await client.get<{ data: IEarnPageBannerListItem[]; }>('/earn/v1/banner/list'); - return response.data.data; + const list = response.data.data; + // Persist so the next cold start paints at the right height instead of + // expanding once this request lands (OK-60299). + // + // Deliberately not awaited. earnExtra runs with enableCache = false, so + // setRawData is a full read-modify-write — getItem + JSON.parse of the + // whole record, then stringify + setItem, all under the entity's shared + // mutex — which on native is real AsyncStorage IO. Awaiting it put that on + // the return path of a request the UI is blocked on, once per tab switch + // and once per pull-to-refresh. Skipping an unchanged write keeps the + // common case off the disk entirely, and off the mutex that + // setEthenaKycAddresses and markFirstOperation also queue on. + // + // Concurrent requests can still land out of order here, so the record may + // trail the newest response by one round. That only costs the next cold + // start a stale first paint, which the request behind it corrects. + void (async () => { + try { + const previous = + await this.backgroundApi.simpleDb.earnExtra.getPageBannerList(); + if ( + stringUtils.stableStringify(previous) === + stringUtils.stableStringify(list) + ) { + return; + } + await this.backgroundApi.simpleDb.earnExtra.setPageBannerList(list); + } catch { + // A cache write must never surface to the caller. + } + })(); + return list; + } + + @backgroundMethod() + async getEarnPageBannerListFromCache(): Promise { + return this.backgroundApi.simpleDb.earnExtra.getPageBannerList(); } @backgroundMethod() diff --git a/packages/kit-bg/src/services/servicePendingInstallTask.test.ts b/packages/kit-bg/src/services/servicePendingInstallTask.test.ts index fc2c8975e8a2..732ee3ed4d09 100644 --- a/packages/kit-bg/src/services/servicePendingInstallTask.test.ts +++ b/packages/kit-bg/src/services/servicePendingInstallTask.test.ts @@ -53,6 +53,7 @@ jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ default: { version: '1.0.0', bundleVersion: '1', + isDesktop: true, }, })); @@ -62,6 +63,7 @@ const appEventBus = { const EAppEventBusNames = { PendingInstallTaskProcessFinished: 'PendingInstallTaskProcessFinished', + StartAutoDownloadUpdate: 'StartAutoDownloadUpdate', }; jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ @@ -72,7 +74,10 @@ jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ jest.mock('@onekeyhq/shared/src/modules3rdParty/auto-update', () => ({ AppUpdate: { - installPackage: jest.fn(async () => undefined), + checkPackageAvailability: jest.fn(async () => ({ + status: 'notApplicable', + })), + installPackage: jest.fn(async () => true), }, BundleUpdate: { switchBundle: jest.fn(async () => undefined), @@ -467,6 +472,22 @@ describe('servicePendingInstallTask', () => { expect(pendingTaskValue.lastError).toBe('INTERRUPTED'); }); + test('running app install is cleared immediately after the target app starts', async () => { + const service = createService(); + const platformEnvMock = require('@onekeyhq/shared/src/platformEnv').default; + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + platformEnvMock.version = '2.0.0'; + pendingTaskValue = makeAppShellInstallTask({ + status: 'running', + runningStartedAt: Date.now(), + }); + + await service.processPendingInstallTask(); + + expect(pendingTaskValue).toBeUndefined(); + expect(autoUpdate.AppUpdate.installPackage).not.toHaveBeenCalled(); + }); + test('executes app shell install task when package is ready', async () => { const service = createService(); const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); @@ -486,6 +507,228 @@ describe('servicePendingInstallTask', () => { expect(pendingTaskValue.status).toBe('applied_waiting_verify'); }); + test('cancelled app shell install remains pending without retrying', async () => { + const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.installPackage.mockResolvedValueOnce(false); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + const result = await service.processPendingInstallTask(); + + expect(result).toBeUndefined(); + expect(pendingTaskValue).toMatchObject({ + status: 'pending', + retryCount: 0, + runningStartedAt: undefined, + }); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.PendingInstallTaskProcessFinished, + undefined, + ); + }); + + test('missing app shell package triggers full-flow re-download', async () => { + const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + await service.processPendingInstallTask(); + + expect(autoUpdate.AppUpdate.installPackage).not.toHaveBeenCalled(); + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.status).toBe('notify'); + expect(appUpdateState.downloadedEvent).toBeUndefined(); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }); + + test('unavailable app shell package triggers full-flow re-download', async () => { + const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'unavailable', + errorCode: 'EACCES', + }); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + await service.processPendingInstallTask(); + + expect(autoUpdate.AppUpdate.installPackage).not.toHaveBeenCalled(); + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.status).toBe('notify'); + expect(appUpdateState.downloadedEvent).toBeUndefined(); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRecovery' }, + ); + }); + + test('already invalidated app package does not consume the retry budget twice', async () => { + const service = createService(); + setState({ + latestVersion: '2.0.0', + status: 'notify' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: undefined, + fullFlowRetryByTarget: { + 'recovery:2.0.0:1': { count: 1, updatedAt: Date.now() }, + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + await service.processPendingInstallTask(); + + expect(pendingTaskValue).toBeUndefined(); + expect( + appUpdateState.fullFlowRetryByTarget?.['recovery:2.0.0:1']?.count, + ).toBe(1); + expect(appEventBus.emit).not.toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + expect.anything(), + ); + }); + + test('reconciliation recovery budget does not exhaust pending full-flow retries', async () => { + const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + fullFlowRetryByTarget: { + 'recovery:2.0.0:1': { count: 2, updatedAt: Date.now() }, + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + await service.processPendingInstallTask(); + + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.status).toBe('notify'); + expect( + appUpdateState.fullFlowRetryByTarget?.['recovery:2.0.0:1']?.count, + ).toBe(2); + expect(appUpdateState.fullFlowRetryByTarget?.['2.0.0:1']?.count).toBe(1); + expect(appUpdateState.freezeUntil).toBeUndefined(); + expect(appUpdateState.ignoredTargets?.['2.0.0:1']).toBeUndefined(); + }); + + test('exhausted app package recovery enters updateIncomplete', async () => { + const refreshUpdateStatus = jest.fn(async (): Promise => { + setState({ status: 'notify' as any }); + return undefined; + }); + const service = createService(refreshUpdateStatus); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'missing', + }); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.seamless, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + fullFlowRetryByTarget: { + '2.0.0:1': { count: 2, updatedAt: Date.now() }, + }, + }); + pendingTaskValue = makeAppShellInstallTask(); + + await service.processPendingInstallTask(); + + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.status).toBe('updateIncomplete'); + expect(appUpdateState.freezeUntil).toBeGreaterThan(Date.now()); + expect(appUpdateState.ignoredTargets?.['2.0.0:1']).toMatchObject({ + reason: 'FULL_FLOW_RETRY_EXHAUSTED', + }); + expect(refreshUpdateStatus).not.toHaveBeenCalled(); + expect(appEventBus.emit).not.toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + expect.anything(), + ); + }); + + test('unprepared macOS package triggers rehydrate without consuming failure budget', async () => { + const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.checkPackageAvailability.mockResolvedValueOnce({ + status: 'notPrepared', + }); + setState({ + latestVersion: '2.0.0', + status: 'ready' as any, + updateStrategy: EUpdateStrategy.silent, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + }); + pendingTaskValue = makeAppShellInstallTask({ + payload: { + latestVersion: '2.0.0', + updateStrategy: EUpdateStrategy.silent, + channel: 'direct', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, + }); + + await service.processPendingInstallTask(); + + expect(autoUpdate.AppUpdate.installPackage).not.toHaveBeenCalled(); + expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.status).toBe('notify'); + expect(appUpdateState.downloadedEvent).toBeUndefined(); + expect(appUpdateState.fullFlowRetryByTarget?.['2.0.0:1']).toBeUndefined(); + expect(appUpdateState.freezeUntil).toBeUndefined(); + expect(appEventBus.emit).toHaveBeenCalledWith( + EAppEventBusNames.StartAutoDownloadUpdate, + { decision: 'appShellPackageRehydrate' }, + ); + }); + test('bundle missing triggers full-flow retry and clears task', async () => { const service = createService(); const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); @@ -588,6 +831,12 @@ describe('servicePendingInstallTask', () => { test('app-install target already aligned clears task', async () => { const service = createService(); + setState({ + fullFlowRetryByTarget: { + '1.0.0:1': { count: 1, updatedAt: Date.now() }, + 'recovery:1.0.0:1': { count: 2, updatedAt: Date.now() }, + }, + }); // Target app version matches current env pendingTaskValue = makeAppShellInstallTask({ targetAppVersion: '1.0.0', @@ -597,6 +846,7 @@ describe('servicePendingInstallTask', () => { await service.processPendingInstallTask(); expect(pendingTaskValue).toBeUndefined(); + expect(appUpdateState.fullFlowRetryByTarget).toEqual({}); }); test('frozen target blocks app-install sync', async () => { @@ -680,8 +930,15 @@ describe('servicePendingInstallTask', () => { test('app-install retry exhausted freezes target', async () => { const service = createService(); + const autoUpdate = require('@onekeyhq/shared/src/modules3rdParty/auto-update'); + autoUpdate.AppUpdate.installPackage.mockRejectedValueOnce( + new Error('install failed'), + ); setState({ - downloadedEvent: undefined, + downloadedEvent: { + downloadedFile: '/tmp/app-2.0.0.pkg', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.pkg', + }, }); pendingTaskValue = makeAppShellInstallTask({ retryCount: 2 }); diff --git a/packages/kit-bg/src/services/servicePendingInstallTask.ts b/packages/kit-bg/src/services/servicePendingInstallTask.ts index 8a6b7bf1b2d2..14d6a3067129 100644 --- a/packages/kit-bg/src/services/servicePendingInstallTask.ts +++ b/packages/kit-bg/src/services/servicePendingInstallTask.ts @@ -5,10 +5,12 @@ import type { IResponseAppUpdateInfo, } from '@onekeyhq/shared/src/appUpdate'; import { + EAppUpdateStatus, EPendingInstallTaskAction, EPendingInstallTaskStatus, EPendingInstallTaskType, EUpdateStrategy, + isAutoUpdateStrategy, resolveUpdateDecision, } from '@onekeyhq/shared/src/appUpdate'; import { @@ -25,6 +27,10 @@ import { AppUpdate, BundleUpdate, } from '@onekeyhq/shared/src/modules3rdParty/auto-update'; +import { + EAppUpdatePackageAvailabilityStatus, + EAppUpdatePackageErrorCode, +} from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import appStorage from '@onekeyhq/shared/src/storage/appStorage'; import { EAppSyncStorageKeys } from '@onekeyhq/shared/src/storage/syncStorageKeys'; @@ -35,7 +41,8 @@ import { appUpdatePersistAtom } from '../states/jotai/atoms'; export const PLACEHOLDER_SIGNATURE = 'dev-no-signature'; const MAX_TASK_RETRY = 3; -const MAX_FULL_FLOW_RETRY = 2; +export const MAX_FULL_FLOW_RETRY = 2; +export const APP_SHELL_PACKAGE_RECOVERY_RETRY_KEY_PREFIX = 'recovery:'; const MAX_RETRY_DELAY_MS = 10 * 60 * 1000; const RETRY_BASE_DELAY_MS = 30 * 1000; const RETRY_JITTER_MS = 5 * 1000; @@ -330,6 +337,9 @@ class ServicePendingInstallTask { delete nextIgnoredTargets[targetKey]; const nextFullFlowRetryByTarget = { ...prev.fullFlowRetryByTarget }; delete nextFullFlowRetryByTarget[targetKey]; + delete nextFullFlowRetryByTarget[ + `${APP_SHELL_PACKAGE_RECOVERY_RETRY_KEY_PREFIX}${targetKey}` + ]; return { ...prev, ignoredTargets: nextIgnoredTargets, @@ -899,13 +909,99 @@ class ServicePendingInstallTask { message: string, traceId: string, requestSeq?: number, - ) { + ): Promise { const targetKey = this.getTargetKey(task); + const isAppPackageNotPrepared = + platformEnv.isDesktop && + task.type === EPendingInstallTaskType.appInstall && + message.includes(EAppUpdatePackageErrorCode.packageNotPrepared); + if (isAppPackageNotPrepared) { + await appUpdatePersistAtom.set((current) => { + if ( + current.latestVersion !== task.targetAppVersion || + current.status !== EAppUpdateStatus.ready + ) { + return current; + } + return { + ...current, + status: EAppUpdateStatus.notify, + errorText: undefined, + downloadedEvent: undefined, + }; + }); + await this.clearPendingTaskWithLog({ + traceId, + requestSeq, + task, + clearReason: 'app_package_rehydrate_required', + }); + const current = await appUpdatePersistAtom.get(); + if ( + current.latestVersion === task.targetAppVersion && + current.status === EAppUpdateStatus.notify && + !current.downloadedEvent && + (current.updateStrategy === EUpdateStrategy.seamless || + current.updateStrategy === EUpdateStrategy.silent) + ) { + defaultLogger.app.appUpdate.log( + `pending app install requires updater cache rehydrate for ${task.targetAppVersion}`, + ); + appEventBus.emit(EAppEventBusNames.StartAutoDownloadUpdate, { + decision: 'appShellPackageRehydrate', + }); + } + return false; + } + const isAppPackageMissing = + platformEnv.isDesktop && + message.includes(EAppUpdatePackageErrorCode.packageMissing); + const isAppPackageUnavailable = + platformEnv.isDesktop && + message.includes(EAppUpdatePackageErrorCode.packageUnavailable); + const isAppPackageInvalid = isAppPackageMissing || isAppPackageUnavailable; const isFullFlowRetryTrigger = message.includes(RETRY_TRIGGER_BUNDLE_MISSING) || - message.includes(RETRY_TRIGGER_VERIFY_FAILED); + message.includes(RETRY_TRIGGER_VERIFY_FAILED) || + isAppPackageInvalid; if (isFullFlowRetryTrigger) { + let fullFlowTrigger = 'verify_failed'; + if (isAppPackageMissing) { + fullFlowTrigger = 'app_package_missing'; + } else if (isAppPackageUnavailable) { + fullFlowTrigger = 'app_package_unavailable'; + } else if (message.includes(RETRY_TRIGGER_BUNDLE_MISSING)) { + fullFlowTrigger = 'bundle_missing'; + } + let didInvalidateAppPackage = !isAppPackageInvalid; + if (isAppPackageInvalid) { + await appUpdatePersistAtom.set((current) => { + if ( + current.latestVersion !== task.targetAppVersion || + current.status !== EAppUpdateStatus.ready + ) { + return current; + } + didInvalidateAppPackage = true; + return { + ...current, + status: EAppUpdateStatus.notify, + errorText: undefined, + downloadedEvent: undefined, + }; + }); + } + if (!didInvalidateAppPackage) { + await this.clearPendingTaskWithLog({ + traceId, + requestSeq, + task, + clearReason: 'app_package_already_invalidated', + level: 'warn', + }); + return false; + } const fullFlowRetryCount = await this.incrementFullFlowRetry(targetKey); defaultLogger.app.appUpdate.fullFlowRetryTriggered( { @@ -914,9 +1010,7 @@ class ServicePendingInstallTask { taskId: task.taskId, revision: task.revision, action: task.action, - trigger: message.includes(RETRY_TRIGGER_BUNDLE_MISSING) - ? 'bundle_missing' - : 'verify_failed', + trigger: fullFlowTrigger, fullFlowRetryCount, target: targetKey, }, @@ -935,7 +1029,21 @@ class ServicePendingInstallTask { TERMINAL_REASON_FULL_FLOW_RETRY_EXHAUSTED, traceId, ); - return; + if (isAppPackageInvalid) { + await appUpdatePersistAtom.set((current) => { + if ( + current.latestVersion !== task.targetAppVersion || + current.status !== EAppUpdateStatus.notify + ) { + return current; + } + return { + ...current, + status: EAppUpdateStatus.updateIncomplete, + }; + }); + } + return Boolean(isAppPackageInvalid); } await this.clearPendingTaskWithLog({ traceId, @@ -944,7 +1052,20 @@ class ServicePendingInstallTask { clearReason: 'full_flow_retry_fallback_to_refetch', level: 'warn', }); - return; + if (isAppPackageInvalid) { + const current = await appUpdatePersistAtom.get(); + if ( + current.latestVersion === task.targetAppVersion && + current.status === EAppUpdateStatus.notify && + !current.downloadedEvent && + isAutoUpdateStrategy(current.updateStrategy) + ) { + appEventBus.emit(EAppEventBusNames.StartAutoDownloadUpdate, { + decision: 'appShellPackageRecovery', + }); + } + } + return false; } const nextRetryCount = task.retryCount + 1; @@ -962,7 +1083,7 @@ class ServicePendingInstallTask { TERMINAL_REASON_RETRY_EXHAUSTED, traceId, ); - return; + return false; } const delayMs = this.getRetryDelayMs(nextRetryCount); @@ -992,6 +1113,7 @@ class ServicePendingInstallTask { }, 'warn', ); + return false; } private async executeBundleSwitchTask( @@ -1101,9 +1223,27 @@ class ServicePendingInstallTask { const downloadUrl = appInfo.downloadedEvent?.downloadUrl || payload.downloadUrl; if (!appInfo.downloadedEvent?.downloadedFile || !downloadUrl) { - throw new OneKeyLocalError('APP_PACKAGE_MISSING'); + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageMissing); + } + const availability = await AppUpdate.checkPackageAvailability(appInfo); + if (availability.status === EAppUpdatePackageAvailabilityStatus.missing) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageMissing); + } + if ( + availability.status === EAppUpdatePackageAvailabilityStatus.notPrepared + ) { + throw new OneKeyLocalError(EAppUpdatePackageErrorCode.packageNotPrepared); } - await AppUpdate.installPackage({ + if ( + availability.status === EAppUpdatePackageAvailabilityStatus.unavailable + ) { + throw new OneKeyLocalError( + `${EAppUpdatePackageErrorCode.packageUnavailable}:${ + availability.errorCode || 'IO_ERROR' + }`, + ); + } + return AppUpdate.installPackage({ ...appInfo, latestVersion: payload.latestVersion, updateStrategy: payload.updateStrategy, @@ -1120,11 +1260,10 @@ class ServicePendingInstallTask { private async executePendingInstallTask(task: IPendingInstallTask) { if (task.type === EPendingInstallTaskType.jsBundleSwitch) { await this.executeBundleSwitchTask(task); - return; + return true; } if (task.type === EPendingInstallTaskType.appInstall) { - await this.executeAppShellInstallTask(task); - return; + return this.executeAppShellInstallTask(task); } const unknownType = (task as unknown as { type?: string })?.type || 'unknown'; @@ -1390,6 +1529,16 @@ class ServicePendingInstallTask { } if (task.status === EPendingInstallTaskStatus.running) { + if (this.isTaskTargetAligned(task)) { + await this.resetTargetControlState(targetKey); + await this.clearPendingTaskWithLog({ + traceId, + requestSeq: requestSeq ?? undefined, + task, + clearReason: 'running_task_target_aligned', + }); + return; + } const runningStartedAt = task.runningStartedAt || task.createdAt; const runningDuration = now - runningStartedAt; if (runningDuration <= RUNNING_TASK_STALE_MS) { @@ -1507,7 +1656,24 @@ class ServicePendingInstallTask { }); try { - await this.executePendingInstallTask(runningTask); + const installStarted = + await this.executePendingInstallTask(runningTask); + if (!installStarted) { + await setPendingInstallTask({ + ...runningTask, + status: EPendingInstallTaskStatus.pending, + runningStartedAt: undefined, + lastError: undefined, + }); + defaultLogger.app.appUpdate.pendingSwitchResult({ + traceId, + requestSeq, + result: 'cancelled', + durationMs: Date.now() - startedAt, + ...this.buildTaskLogFields(runningTask), + }); + return; + } shouldEmitProcessFinishedEvent = false; const durationMs = Date.now() - startedAt; await setPendingInstallTask({ @@ -1522,6 +1688,7 @@ class ServicePendingInstallTask { durationMs, ...this.buildTaskLogFields(runningTask), }); + return true; } catch (error) { const durationMs = Date.now() - startedAt; const message = (error as Error)?.message ?? 'unknown'; @@ -1557,7 +1724,15 @@ class ServicePendingInstallTask { }, 'error', ); - await this.markTaskFailed(runningTask, message, traceId, undefined); + const reachedTerminalState = await this.markTaskFailed( + runningTask, + message, + traceId, + undefined, + ); + if (reachedTerminalState) { + shouldRunPostRefresh = false; + } } } } finally { diff --git a/packages/kit-bg/src/states/jotai/atomNames.test.ts b/packages/kit-bg/src/states/jotai/atomNames.test.ts index dac346398b65..4a49fcd2c07b 100644 --- a/packages/kit-bg/src/states/jotai/atomNames.test.ts +++ b/packages/kit-bg/src/states/jotai/atomNames.test.ts @@ -31,4 +31,11 @@ describe('atomsConfig', () => { atomsConfig[EAtomNames.perpsDepositOrderAtom]?.mergeInitialValue, ).toBe(false); }); + + it('replaces firmware update dev settings instead of merging target arrays', () => { + expect( + atomsConfig[EAtomNames.firmwareUpdateDevSettingsPersistAtom] + ?.mergeInitialValue, + ).toBe(false); + }); }); diff --git a/packages/kit-bg/src/states/jotai/atomNames.ts b/packages/kit-bg/src/states/jotai/atomNames.ts index a50ec9424606..1b4e5f0b539f 100644 --- a/packages/kit-bg/src/states/jotai/atomNames.ts +++ b/packages/kit-bg/src/states/jotai/atomNames.ts @@ -157,6 +157,12 @@ export const atomsConfig: Partial< [EAtomNames.primePersistAtom]: { mergeInitialValue: false, }, + // Nested force-target arrays must replace, not lodash-merge. merge({}, + // {targets:['boot']}, {targets:[]}) keeps ['boot'], so the Pro2 switches + // cannot turn off (and look like they "don't toggle"). + [EAtomNames.firmwareUpdateDevSettingsPersistAtom]: { + mergeInitialValue: false, + }, // This state is written as a complete snapshot so legacy chart namespace // fields can be removed instead of being merged back on every write. [EAtomNames.marketTradingViewSubIndicatorCountPersistAtom]: { diff --git a/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.test.ts b/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.test.ts new file mode 100644 index 000000000000..bd4afa71b635 --- /dev/null +++ b/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.test.ts @@ -0,0 +1,63 @@ +import { applyPro2FirmwareForceTargetChange } from './applyPro2FirmwareForceTargetChange'; + +describe('applyPro2FirmwareForceTargetChange', () => { + it('enabling force adds the target and clears the once flag', () => { + expect( + applyPro2FirmwareForceTargetChange({ + enabled: true, + mode: 'force', + onceTargets: ['resource', 'boot'], + target: 'resource', + targets: ['boot'], + }), + ).toEqual({ + pro2ForceUpdateOnceTargets: ['boot'], + pro2ForceUpdateTargets: ['boot', 'resource'], + }); + }); + + it('disabling force only removes that target', () => { + expect( + applyPro2FirmwareForceTargetChange({ + enabled: false, + mode: 'force', + onceTargets: ['boot'], + target: 'resource', + targets: ['boot', 'resource'], + }), + ).toEqual({ + pro2ForceUpdateOnceTargets: ['boot'], + pro2ForceUpdateTargets: ['boot'], + }); + }); + + it('enabling once adds the target and clears the force flag', () => { + expect( + applyPro2FirmwareForceTargetChange({ + enabled: true, + mode: 'once', + onceTargets: [], + target: 'app_v1', + targets: ['app_v1', 'boot'], + }), + ).toEqual({ + pro2ForceUpdateOnceTargets: ['app_v1'], + pro2ForceUpdateTargets: ['boot'], + }); + }); + + it('does not duplicate an already-enabled target', () => { + expect( + applyPro2FirmwareForceTargetChange({ + enabled: true, + mode: 'force', + onceTargets: [], + target: 'boot', + targets: ['boot'], + }), + ).toEqual({ + pro2ForceUpdateOnceTargets: [], + pro2ForceUpdateTargets: ['boot'], + }); + }); +}); diff --git a/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.ts b/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.ts new file mode 100644 index 000000000000..17d1a50d8d10 --- /dev/null +++ b/packages/kit-bg/src/states/jotai/atoms/applyPro2FirmwareForceTargetChange.ts @@ -0,0 +1,54 @@ +import type { IPro2FirmwareUpdateTarget } from '@onekeyhq/shared/types/device'; + +import type { IFirmwareUpdateDevSettings } from './devSettings'; + +export type IPro2FirmwareForceTargetMode = 'force' | 'once'; + +function addTarget( + items: IPro2FirmwareUpdateTarget[], + target: IPro2FirmwareUpdateTarget, +): IPro2FirmwareUpdateTarget[] { + return items.includes(target) ? items : [...items, target]; +} + +function removeTarget( + items: IPro2FirmwareUpdateTarget[], + target: IPro2FirmwareUpdateTarget, +): IPro2FirmwareUpdateTarget[] { + return items.filter((item) => item !== target); +} + +export function applyPro2FirmwareForceTargetChange({ + enabled, + mode, + onceTargets, + target, + targets, +}: { + enabled: boolean; + mode: IPro2FirmwareForceTargetMode; + onceTargets: IPro2FirmwareUpdateTarget[]; + target: IPro2FirmwareUpdateTarget; + targets: IPro2FirmwareUpdateTarget[]; +}): Pick< + IFirmwareUpdateDevSettings, + 'pro2ForceUpdateOnceTargets' | 'pro2ForceUpdateTargets' +> { + if (mode === 'force') { + return { + pro2ForceUpdateOnceTargets: enabled + ? removeTarget(onceTargets, target) + : onceTargets, + pro2ForceUpdateTargets: enabled + ? addTarget(targets, target) + : removeTarget(targets, target), + }; + } + + return { + pro2ForceUpdateOnceTargets: enabled + ? addTarget(onceTargets, target) + : removeTarget(onceTargets, target), + pro2ForceUpdateTargets: enabled ? removeTarget(targets, target) : targets, + }; +} diff --git a/packages/kit-bg/src/states/jotai/atoms/devSettings.test.ts b/packages/kit-bg/src/states/jotai/atoms/devSettings.test.ts new file mode 100644 index 000000000000..f227d42d3e58 --- /dev/null +++ b/packages/kit-bg/src/states/jotai/atoms/devSettings.test.ts @@ -0,0 +1,61 @@ +import { + devSettingsPersistAtom, + firmwareUpdateDevSettingsPersistAtom, + getGatedFirmwareUpdateDevSetting, +} from './devSettings'; + +jest.mock('../utils', () => ({ + globalAtom: jest.fn(() => ({ + target: { get: jest.fn(), set: jest.fn() }, + use: jest.fn(), + })), + globalAtomComputed: jest.fn(() => ({ target: {}, use: jest.fn() })), + globalAtomComputedRW: jest.fn(() => ({ target: {}, use: jest.fn() })), +})); + +const mockedDevSettings = jest.mocked(devSettingsPersistAtom.get); +const mockedFirmwareDevSettings = jest.mocked( + firmwareUpdateDevSettingsPersistAtom.get, +); + +describe('getGatedFirmwareUpdateDevSetting', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reads the value only while global developer mode is enabled', async () => { + mockedDevSettings.mockResolvedValue({ enabled: true, settings: {} }); + mockedFirmwareDevSettings.mockResolvedValue({ + usePreReleaseConfig: true, + } as never); + + await expect( + getGatedFirmwareUpdateDevSetting('usePreReleaseConfig'), + ).resolves.toBe(true); + }); + + it('never leaks a stale value once developer mode is off', async () => { + // The persisted firmware settings may still hold `true` from an earlier + // session; the global gate must win. + mockedDevSettings.mockResolvedValue({ enabled: false, settings: {} }); + mockedFirmwareDevSettings.mockResolvedValue({ + usePreReleaseConfig: true, + } as never); + + await expect( + getGatedFirmwareUpdateDevSetting('usePreReleaseConfig'), + ).resolves.toBeUndefined(); + expect(mockedFirmwareDevSettings).not.toHaveBeenCalled(); + }); + + it('returns undefined for a disabled firmware setting under developer mode', async () => { + mockedDevSettings.mockResolvedValue({ enabled: true, settings: {} }); + mockedFirmwareDevSettings.mockResolvedValue({ + usePreReleaseConfig: false, + } as never); + + await expect( + getGatedFirmwareUpdateDevSetting('usePreReleaseConfig'), + ).resolves.toBe(false); + }); +}); diff --git a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts index da6f2ba21c41..01c77818fa47 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'; @@ -102,8 +103,7 @@ export interface IDevSettings { // Force IP Table strict mode: always use IP even if runtime.selections is empty // Fallback to first available IP from config when no selection exists forceIpTableStrict?: boolean; - // Kill switch for the fast-failover behaviors introduced for extreme - // network conditions (adapter fail-open + service fast switch to last-best IP) + // Kill switch for fast failover under extreme network conditions. disableIpTableFailover?: boolean; // Enable mock market banner data for UI testing enableMockMarketBanner?: boolean; @@ -212,6 +212,8 @@ export type IFirmwareUpdateDevSettings = { showDeviceDebugLogs: boolean; showAutoCheckHardwareUpdatesToast: boolean; forceUpdateBtcOnlyUniversalFirmware: boolean; + pro2ForceUpdateTargets: IPro2FirmwareUpdateTarget[]; + pro2ForceUpdateOnceTargets: IPro2FirmwareUpdateTarget[]; }; export type IFirmwareUpdateDevSettingsKeys = keyof IFirmwareUpdateDevSettings; export const { @@ -238,9 +240,24 @@ export const { showDeviceDebugLogs: false, showAutoCheckHardwareUpdatesToast: false, forceUpdateBtcOnlyUniversalFirmware: false, + pro2ForceUpdateTargets: [], + pro2ForceUpdateOnceTargets: [], }, }); +// Firmware update dev settings only take effect while global developer mode is +// enabled; callers outside ServiceDevSetting must go through this gate too. +export async function getGatedFirmwareUpdateDevSetting< + T extends IFirmwareUpdateDevSettingsKeys, +>(key: T): Promise { + const dev = await devSettingsPersistAtom.get(); + if (!dev.enabled) { + return undefined; + } + const fwDev = await firmwareUpdateDevSettingsPersistAtom.get(); + return fwDev[key]; +} + export type INotificationsDevSettings = { showMessagePushSource?: boolean; disabledWebSocket?: boolean; diff --git a/packages/kit-bg/src/states/jotai/atoms/hardware.ts b/packages/kit-bg/src/states/jotai/atoms/hardware.ts index 3b5c87c9bb97..85563233056b 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'; @@ -91,6 +109,7 @@ export type IFirmwareUpdateStepInfo = step: EFirmwareUpdateSteps.updateStart; payload: { startAtTime: number; + isDownloadingArtifacts?: boolean; }; } | { 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..0e15212c3515 --- /dev/null +++ b/packages/kit-bg/src/states/jotai/atoms/settings.hardwareTransport.test.ts @@ -0,0 +1,25 @@ +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() })), + globalAtomComputedR: jest.fn(() => ({ 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/states/jotai/jotaiStorage.test.ts b/packages/kit-bg/src/states/jotai/jotaiStorage.test.ts index 59f00ae8b1b9..43bda97d03eb 100644 --- a/packages/kit-bg/src/states/jotai/jotaiStorage.test.ts +++ b/packages/kit-bg/src/states/jotai/jotaiStorage.test.ts @@ -527,6 +527,19 @@ describe('mergeStoredValue non plain objects', () => { expect(mergeStoredValue([], [1, 2], true)).toEqual([1, 2]); }); + // lodash merge skips empty source arrays, so turning a force target off + // would leave the previous items in place. The firmware atom opts out of + // this merge; this test pins the default-on behavior. + it('keeps previous nested array items when merging onto empty', () => { + expect( + mergeStoredValue( + { pro2ForceUpdateTargets: ['boot'] }, + { pro2ForceUpdateTargets: [] }, + true, + ), + ).toEqual({ pro2ForceUpdateTargets: ['boot'] }); + }); + // merge({}, init, new Date()) collapses to {}. it('leaves a Date value untouched', () => { const date = new Date(1_712_345_678_000); 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..b5ab5d870597 --- /dev/null +++ b/packages/kit-bg/src/vaults/impls/ada/settings.test.ts @@ -0,0 +1,12 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; + +import settings from './settings'; + +describe('ADA hardware settings', () => { + it('supports Pro 2 and Neo hardware wallets together', () => { + expect(settings.supportedDeviceTypes).toContain(EDeviceType.Pro2); + expect(settings.supportedDeviceTypes).toContain(NEO_DEVICE_TYPE); + }); +}); diff --git a/packages/kit-bg/src/vaults/impls/ada/settings.ts b/packages/kit-bg/src/vaults/impls/ada/settings.ts index 3e6bef1ecbb8..ab08ce481903 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'; @@ -7,6 +9,7 @@ import { INDEX_PLACEHOLDER, } from '@onekeyhq/shared/src/engine/engineConsts'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EEarnProviderEnum } from '@onekeyhq/shared/types/earn'; import { EDBAccountType } from '../../../dbs/local/consts'; @@ -35,6 +38,17 @@ const settings: IVaultSettings = { supportExportedSecretKeys: [ECoreApiExportedSecretKeyType.xprvt], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + EDeviceType.Pro2, + NEO_DEVICE_TYPE, + ], + 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/btc/KeyringHardwareLedger.ts b/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareLedger.ts index e277c923cf93..4ea7dddd2ba6 100644 --- a/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareLedger.ts +++ b/packages/kit-bg/src/vaults/impls/btc/KeyringHardwareLedger.ts @@ -241,6 +241,8 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { const { inputs, outputs } = encodedTx; const network = btcNetwork; const psbt = new BitcoinJS.Psbt({ network }); + type IPsbtInputData = Parameters[0]; + type IPsbtOutputData = Parameters[0]; const vault = this.vault as VaultBtc; // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -270,6 +272,10 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { // Get xpub and master fingerprint for BIP32 derivation const utxoAccount = dbAccount as IDBUtxoAccount; const xpub = utxoAccount.xpubSegwit || utxoAccount.xpub; + // For P2TR, xpubSegwit is a `tr([fp/path]xpub/<0;1>/*)` descriptor + // (see prepareAccounts), not a base58 xpub — getPublicKeyFromXpub + // needs the plain one. + const taprootXpub = utxoAccount.xpub; const fpResult = await callLedgerWithFingerprint( this.backgroundApi, dbDevice, @@ -288,7 +294,7 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { 'hex', ); - const isTaproot = dbAccount.path.includes("86'"); + const isTaproot = isTaprootPath(dbAccount.path); for (const input of inputs) { const scriptPubKey = BitcoinJS.address.toOutputScript( @@ -301,7 +307,7 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { const fullPathParts = fullPath.replace(/^m\//, '').split('/'); const relPath = fullPathParts.slice(accountPathParts.length).join('/'); - const inputData: any = { + const inputData: IPsbtInputData = { hash: input.txid, index: input.vout, witnessUtxo: { @@ -321,9 +327,14 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { } // Add BIP32 derivation info (required by Ledger BTC App) - if (isTaproot) { - // Taproot: x-only key from P2TR script - const xOnlyKey = scriptPubKey.slice(2, 34); + if (isTaproot && taprootXpub && relPath) { + // tapInternalKey must be the pre-tweak pubkey, derived from xpub. + const pubkeyHex = getPublicKeyFromXpub({ + xpub: taprootXpub, + network, + relPath, + }); + const xOnlyKey = Buffer.from(pubkeyHex, 'hex').subarray(1, 33); inputData.tapInternalKey = xOnlyKey; inputData.tapBip32Derivation = [ { @@ -362,10 +373,66 @@ export class KeyringHardwareLedger extends KeyringHardwareBtcBase { value: BigInt(0), }); } else { - psbt.addOutput({ + const outputData: IPsbtOutputData = { address: output.address, value: BigInt(output.value), - }); + }; + // BIP-174 change detection: the change output carries the wallet's + // own derivation so the device nets it out of the confirmed amount. + // Recipient outputs must never carry it. A claim must be provable + // against this account — the path has to extend the account path by + // exactly /; anything else (and any derivation + // failure) degrades to a plain output the device displays, never a + // wrong claim and never a failed signature. + const changePath = output.payload?.isChange + ? output.payload?.bip44Path + : undefined; + if (changePath) { + try { + const relPath = changePath.startsWith(`${dbAccount.path}/`) + ? changePath.slice(dbAccount.path.length + 1) + : ''; + if (/^[01]\/\d+$/.test(relPath)) { + if (isTaproot && taprootXpub) { + // Same as the input branch: derive the pre-tweak pubkey. + const pubkeyHex = getPublicKeyFromXpub({ + xpub: taprootXpub, + network, + relPath, + }); + const xOnlyKey = Buffer.from(pubkeyHex, 'hex').subarray( + 1, + 33, + ); + outputData.tapInternalKey = xOnlyKey; + outputData.tapBip32Derivation = [ + { + masterFingerprint, + pubkey: xOnlyKey, + path: changePath, + leafHashes: [], + }, + ]; + } else if (xpub) { + const pubkeyHex = getPublicKeyFromXpub({ + xpub, + network, + relPath, + }); + outputData.bip32Derivation = [ + { + masterFingerprint, + pubkey: Buffer.from(pubkeyHex, 'hex'), + path: changePath, + }, + ]; + } + } + } catch { + // best-effort: fall through to a plain output + } + } + psbt.addOutput(outputData); } } 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..08b40ed9e68a 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, @@ -5,6 +7,7 @@ import { INDEX_PLACEHOLDER, } from '@onekeyhq/shared/src/engine/engineConsts'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EDBAccountType } from '../../../dbs/local/consts'; @@ -34,6 +37,17 @@ const settings: IVaultSettings = { // ECoreApiExportedSecretKeyType.publicKey, ], + supportedDeviceTypes: [ + EDeviceType.Classic, + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro, + EDeviceType.Pro2, + NEO_DEVICE_TYPE, + ], + 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..d377e34c9903 100644 --- a/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts +++ b/packages/kit-bg/src/vaults/impls/lightning/KeyringHardware.ts @@ -18,6 +18,7 @@ import { OneKeyInternalError, OneKeyLocalError, } from '@onekeyhq/shared/src/errors'; +import * as deviceErrors from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; import { convertDeviceError, convertDeviceResponse, @@ -25,6 +26,7 @@ import { import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { checkIsDefined } from '@onekeyhq/shared/src/utils/assertUtils'; import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import type { INetworkAccount } from '@onekeyhq/shared/types/account'; import type { IEncodedTxLightning, @@ -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,23 @@ export class KeyringHardware extends KeyringHardwareBase { override hwSdkNetwork: IHwSdkNetwork = 'btc'; + private assertPro2LightningSupported( + deviceParams: IDeviceSharedCallParams | undefined, + ) { + const dbDevice = deviceParams?.dbDevice; + if (!dbDevice || !isProtocolV2ProductType(dbDevice.deviceType)) { + return; + } + + throw new deviceErrors.UnknownMethod({ + payload: { + error: 'Device not support this method', + connectId: dbDevice.connectId, + deviceId: dbDevice.deviceId, + }, + }); + } + override async buildHwAllNetworkPrepareAccountsParams( params: IBuildHwAllNetworkPrepareAccountsParams, ): Promise { @@ -86,6 +106,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 +271,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 +341,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 +375,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/KeyringHardware.ts b/packages/kit-bg/src/vaults/impls/ton/KeyringHardware.ts index 26340a973411..e43797638caa 100644 --- a/packages/kit-bg/src/vaults/impls/ton/KeyringHardware.ts +++ b/packages/kit-bg/src/vaults/impls/ton/KeyringHardware.ts @@ -295,10 +295,8 @@ export class KeyringHardware extends KeyringHardwareBase { throw new OneKeyInternalError('Failed to sign message'); } const signature = bufferUtils.hexToBytes(result.signature); - // classic1s return signning_message is message hash - // pro return signning_message is message boc - // pro blind sign return signning_message is null - const signingMessageHexFromHw = result.signning_message as string | null; + // The Hardware SDK normalizes Protocol V1/V2 responses to signing_message. + const signingMessageHexFromHw = result.signing_message ?? null; const signingMessageHex = Buffer.from(signingMessage.toBoc()).toString( 'hex', ); @@ -312,11 +310,7 @@ export class KeyringHardware extends KeyringHardwareBase { !result.skip_validate && signingMessageHexFromHw !== signingMessageHex ) { - console.warn( - 'signingMessage mismatch', - signingMessageHexFromHw, - signingMessageHex, - ); + console.warn('TON signingMessage mismatch'); signingMessage = Cell.fromHex(signingMessageHexFromHw); } // For 1S, check the hash diff --git a/packages/kit-bg/src/vaults/impls/ton/settings.ts b/packages/kit-bg/src/vaults/impls/ton/settings.ts index a594d4fdef5f..ce8af509b655 100644 --- a/packages/kit-bg/src/vaults/impls/ton/settings.ts +++ b/packages/kit-bg/src/vaults/impls/ton/settings.ts @@ -9,6 +9,7 @@ import { IMPL_TON, INDEX_PLACEHOLDER, } from '@onekeyhq/shared/src/engine/engineConsts'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EDBAccountType } from '../../../dbs/local/consts'; @@ -64,6 +65,8 @@ const settings: IVaultSettings = { supportedDeviceTypes: [ EDeviceType.Touch, EDeviceType.Pro, + EDeviceType.Pro2, + NEO_DEVICE_TYPE, 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/AppUpdate/AppUpdateForeground.tsx b/packages/kit/src/components/AppUpdate/AppUpdateForeground.tsx index 139108d1af8e..a0449cfb0b8c 100644 --- a/packages/kit/src/components/AppUpdate/AppUpdateForeground.tsx +++ b/packages/kit/src/components/AppUpdate/AppUpdateForeground.tsx @@ -46,12 +46,12 @@ import { useDownloadPackage } from './useDownloadPackage'; // component-local `cancelled` flag which only protects against in-flight // awaits after unmount. let didRunFirstLaunchDispatch = false; -// Silent-ready dialog should fire at most once per app session even if +// Auto-ready install handling should fire at most once per app session even if // the persist atom hydrates after the first-launch dispatch useEffect has // already consumed didRunFirstLaunchDispatch. Tracked separately so the // watcher effect below can react to a late hydration / in-session status // transition without re-running the full first-launch dispatch. -let silentReadyDialogShown = false; +let autoReadyInstallHandled = false; /** * OK-58962: on the extension every UI surface is its own page load, so a window @@ -129,7 +129,28 @@ export function useAppUpdateForegroundEffects(enabled = true) { verifyASC, downloadASC, installPackage, + showUpdateInCompleteDialog, } = useDownloadPackage(); + const processRehydratedAppInstall = useCallback(() => { + if (autoReadyInstallHandled) return; + autoReadyInstallHandled = true; + void backgroundApiProxy.serviceAppUpdate + .processPendingInstallTask() + .then((installStarted) => { + if (!installStarted) { + autoReadyInstallHandled = false; + } + }) + .catch(() => { + autoReadyInstallHandled = false; + }); + }, []); + const showUpdateInCompleteDialogWhenUnlocked = useCallback(() => { + void (async () => { + await whenAppUnlocked(); + showUpdateInCompleteDialog({}); + })(); + }, [showUpdateInCompleteDialog]); const onViewReleaseInfo = useCallback(() => { if (platformEnv.isE2E) return; @@ -351,7 +372,8 @@ export function useAppUpdateForegroundEffects(enabled = true) { // guard prevents any retry — so the dialog ends up surfacing on the // following cold launch instead of the one right after the install. void (async () => { - const info = await backgroundApiProxy.serviceAppUpdate.getUpdateInfo(); + const info = + await backgroundApiProxy.serviceAppUpdate.reconcileAppShellPackage(); if (cancelled) return; if (isFirstLaunchAfterUpdated(info)) { @@ -401,6 +423,17 @@ export function useAppUpdateForegroundEffects(enabled = true) { } const forceUpdate = isForceUpdateStrategy(info.updateStrategy); + const isDesktopAppShellRecovery = + platformEnv.isDesktop && + getUpdateFileType(info) === EUpdateFileType.appShell; + if ( + info.status === EAppUpdateStatus.updateIncomplete && + !forceUpdate && + isDesktopAppShellRecovery + ) { + showUpdateInCompleteDialogWhenUnlocked(); + return; + } if (info.status !== EAppUpdateStatus.done && forceUpdate) { isShowForceUpdatePreviewPage = true; // Pass the force semantics derived from the authoritative `info` so @@ -412,9 +445,7 @@ export function useAppUpdateForegroundEffects(enabled = true) { toUpdatePreviewPage(true, { ...info, isForceUpdate: forceUpdate }); } - if (info.status === EAppUpdateStatus.updateIncomplete) { - // do nothing - } else if (info.status === EAppUpdateStatus.downloadPackage) { + if (info.status === EAppUpdateStatus.downloadPackage) { void downloadPackage(); } else if (info.status === EAppUpdateStatus.downloadASC) { void downloadASC(); @@ -439,6 +470,11 @@ export function useAppUpdateForegroundEffects(enabled = true) { ); void backgroundApiProxy.serviceAppUpdate.reset(); } + } else if ( + platformEnv.isDesktopMac && + info.downloadedEvent?.isUpdaterRehydrated + ) { + processRehydratedAppInstall(); } else { void installPackage( () => undefined, @@ -452,13 +488,19 @@ export function useAppUpdateForegroundEffects(enabled = true) { // already queued a pending install task (silent is allowed past the // strategy gate), applied on the next restart; the update button // offers an immediate restart-install. Keep the guard set so the - // (now no-op) silent-ready watcher below stays consistent. - silentReadyDialogShown = true; + // silent-ready watcher below cannot process the same state twice. + const shouldProcessRehydratedPackage = + !autoReadyInstallHandled && + platformEnv.isDesktopMac && + info.downloadedEvent?.isUpdaterRehydrated; + if (shouldProcessRehydratedPackage) { + processRehydratedAppInstall(); + } // showSilentUpdateDialog(); } else { showUpdateDialog(); } - } else { + } else if (info.status !== EAppUpdateStatus.updateIncomplete) { scheduleFetchUpdateInfo(); } })(); @@ -491,29 +533,39 @@ export function useAppUpdateForegroundEffects(enabled = true) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Silent-ready watcher — independent of didRunFirstLaunchDispatch. + // Auto-ready watcher — independent of didRunFirstLaunchDispatch. // The first-launch dispatch effect above runs exactly once with an // empty dep list, so it cannot react to status changes that arrive - // after the first run (e.g. a silent download completes in-session, or + // after the first run (e.g. an auto-download completes in-session, or // the persist atom hydrates after the initial render on restart). This - // dedicated effect covers both cases. silentReadyDialogShown (module- + // dedicated effect covers both cases. autoReadyInstallHandled (module- // level) ensures only one dispatch per app session even when the hook // is mounted twice (StrictMode / the legacy useAppUpdateInfo opt-in). useEffect(() => { if (!enabled) return; - if (silentReadyDialogShown) return; - if (appUpdateInfo.updateStrategy !== EUpdateStrategy.silent) return; + if (autoReadyInstallHandled) return; + if (!isAutoUpdateStrategy(appUpdateInfo.updateStrategy)) return; if (appUpdateInfo.status !== EAppUpdateStatus.ready) return; if (isFirstLaunchAfterUpdated(appUpdateInfo)) return; - silentReadyDialogShown = true; - // OK-55397: silent-ready dialog removed — apply-on-restart is handled by - // the pending install task queued in readyToInstall. Nothing to show here. + if (!platformEnv.isDesktopMac) return; + if (!appUpdateInfo.downloadedEvent?.isUpdaterRehydrated) return; + processRehydratedAppInstall(); + // OK-55397: regular silent packages remain queued for the next restart. + // A rehydrated macOS package must run now because MacUpdater preparation + // belongs to this process and is lost on another restart. // showSilentUpdateDialog(); // deps: only re-run on status / strategy transitions. // appUpdateInfo is omitted intentionally — including the object ref // would re-fire on every unrelated field mutation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, appUpdateInfo.status, appUpdateInfo.updateStrategy]); + }, [ + enabled, + appUpdateInfo.status, + appUpdateInfo.updateStrategy, + appUpdateInfo.downloadedEvent?.downloadedFile, + appUpdateInfo.downloadedEvent?.isUpdaterRehydrated, + processRehydratedAppInstall, + ]); // Mid-session auto-download bridge. // @@ -552,6 +604,17 @@ export function useAppUpdateForegroundEffects(enabled = true) { }; }, [downloadPackage, enabled]); + useEffect(() => { + if (!enabled) return undefined; + const handler = () => { + showUpdateInCompleteDialogWhenUnlocked(); + }; + appEventBus.on(EAppEventBusNames.ShowAppUpdateIncompleteDialog, handler); + return () => { + appEventBus.off(EAppEventBusNames.ShowAppUpdateIncompleteDialog, handler); + }; + }, [enabled, showUpdateInCompleteDialogWhenUnlocked]); + // Single AppState listener for the whole app — replaces the per-mount // listeners that previously lived in `useAppUpdateInfo`. The service- // side cooldown gate is still consulted (defense-in-depth) but with @@ -592,5 +655,5 @@ export function AppUpdateForeground() { // API surface clean. export function __resetAppUpdateForegroundForTests() { didRunFirstLaunchDispatch = false; - silentReadyDialogShown = false; + autoReadyInstallHandled = false; } diff --git a/packages/kit/src/components/AppUpdate/updateErrorTaxonomy.ts b/packages/kit/src/components/AppUpdate/updateErrorTaxonomy.ts index 660915d7a8e6..a556e0375510 100644 --- a/packages/kit/src/components/AppUpdate/updateErrorTaxonomy.ts +++ b/packages/kit/src/components/AppUpdate/updateErrorTaxonomy.ts @@ -4,6 +4,8 @@ // PII scrubber can be unit-tested independently of the React hooks // that wire them into download / verify / install flows. +import { EAppUpdatePackageErrorCode } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; + /** * Defense-in-depth scrubber for free-text error messages before they leave * the client. The native modules try not to embed PII, but Node.js fs errors @@ -89,6 +91,22 @@ export function extractUpdateErrorCode(error: unknown): string | undefined { : ((error as { message?: string } | null)?.message ?? ''); if (!msg) return undefined; + if (/\bAPP_PACKAGE_NOT_PREPARED\b/i.test(msg)) { + return EAppUpdatePackageErrorCode.packageNotPrepared; + } + if ( + /\b(?:APP_PACKAGE_MISSING|NOT_FOUND_PACKAGE|NOT_FOUND_FILE|ENOENT|ENOTDIR)\b/i.test( + msg, + ) + ) { + return EAppUpdatePackageErrorCode.packageMissing; + } + if ( + /\b(?:APP_PACKAGE_UNAVAILABLE|EACCES|EPERM|EIO|EBUSY|EROFS)\b/i.test(msg) + ) { + return EAppUpdatePackageErrorCode.packageUnavailable; + } + // SHA reasons can include native error class names mixed with digits // and dashes (e.g. iOS "IO_NSCocoaErrorDomain_257" or Android // "IO_FileNotFoundException"). Widen char class beyond A-Z so the diff --git a/packages/kit/src/components/AppUpdate/useAppUpdate.test.ts b/packages/kit/src/components/AppUpdate/useAppUpdate.test.ts index 1515ab89dd85..375d8e6e485b 100644 --- a/packages/kit/src/components/AppUpdate/useAppUpdate.test.ts +++ b/packages/kit/src/components/AppUpdate/useAppUpdate.test.ts @@ -23,6 +23,7 @@ jest.mock('../../background/instance/backgroundApiProxy', () => { const svc = { getUpdateInfo: jest.fn(), + reconcileAppShellPackage: jest.fn(), getDownloadEvent: jest.fn(), downloadPackage: jest.fn(), downloadPackageFailed: jest.fn(), @@ -147,6 +148,7 @@ jest.mock('@onekeyhq/shared/src/platformEnv', () => { isNative: false, isNativeAndroid: false, isDesktop: false, + isDesktopMac: false, isExtension: false, // OK-58962: the two extension surfaces the post-update gate branches on. // Declared here (not just left undefined) so a test can flip them and @@ -219,9 +221,17 @@ jest.mock('../../hooks/useRunAfterTokensDone', () => ({ }, })); -jest.mock('../../utils/passwordUtils', () => ({ - whenAppUnlocked: () => Promise.resolve(), -})); +jest.mock('../../utils/passwordUtils', () => { + const fn = jest.fn(() => Promise.resolve()); + (globalThis as any).__mockWhenAppUnlocked = fn; + return { whenAppUnlocked: fn }; +}); + +// Keep one event-bus singleton across jest.isolateModules() so events emitted +// by a test reach the foreground listener registered by the isolated module. +jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => + jest.requireActual('@onekeyhq/shared/src/eventBus/appEventBus'), +); jest.mock('@onekeyhq/shared/src/request/Interceptor', () => ({ getRequestHeaders: jest.fn().mockResolvedValue({}), @@ -329,6 +339,10 @@ import { EUpdateFileType, EUpdateStrategy, } from '@onekeyhq/shared/src/appUpdate'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; // Resolves to the jest.mock above. Imported directly rather than bridged via @@ -379,6 +393,7 @@ const mockToastError = g.__mockToastError; const mockOpenUrlExternal = g.__mockOpenUrlExternal; const mockPlatformEnv = g.__mockPlatformEnv; const mockAtomHolder = g.__mockAtomHolder; +const mockWhenAppUnlocked = g.__mockWhenAppUnlocked; // --------------------------------------------------------------------------- // Helpers @@ -403,6 +418,8 @@ function resetAllMocks() { mockPlatformEnv.isExtensionUiSidePanel = false; sidePanelUiState.hasReceivedPushedModal = false; dappSvc.hasPendingDappRequest.mockResolvedValue(false); + mockPlatformEnv.isDesktop = false; + mockPlatformEnv.isDesktopMac = false; // Default resolved values. getUpdateInfo uses mockImplementation so it // always returns the CURRENT mockAtomHolder.value — tests that reassign @@ -412,6 +429,7 @@ function resetAllMocks() { svc.getUpdateInfo.mockImplementation(() => Promise.resolve(mockAtomHolder.value), ); + svc.reconcileAppShellPackage.mockImplementation(() => svc.getUpdateInfo()); svc.getDownloadEvent.mockResolvedValue(null); svc.downloadPackage.mockResolvedValue(undefined); svc.downloadPackageFailed.mockResolvedValue(undefined); @@ -422,6 +440,7 @@ function resetAllMocks() { svc.verifyPackage.mockResolvedValue(undefined); svc.verifyPackageFailed.mockResolvedValue(undefined); svc.readyToInstall.mockResolvedValue(undefined); + svc.processPendingInstallTask.mockResolvedValue(true); svc.updateDownloadedEvent.mockResolvedValue(undefined); // OCDS §5.11 attempt-budget hooks default to a fresh budget so download // tests proceed; give-up cases override per test. @@ -435,9 +454,11 @@ function resetAllMocks() { svc.resetToInComplete.mockResolvedValue(undefined); svc.fetchChangeLog.mockResolvedValue(undefined); svc.updateLastDialogShownAt.mockResolvedValue(undefined); + mockWhenAppUnlocked.mockResolvedValue(undefined); // Defaults match the safe baseline: native disallows skip, dev setting off. bundleUpd.isSkipGpgVerificationAllowed.mockResolvedValue(false); devSvc.getSkipBundleGPGVerification.mockResolvedValue(false); + appUpd.installPackage.mockResolvedValue(true); } // --------------------------------------------------------------------------- @@ -1035,6 +1056,42 @@ describe('sanitizeUpdateErrorMessage', () => { // A.x extractUpdateErrorCode — error → stable mixpanel code mapping // ========================================================================= describe('extractUpdateErrorCode', () => { + test('normalizes missing app package errors across IPC and native bridges', () => { + expect( + extractUpdateErrorCode( + new Error( + "Error invoking remote method 'appUpdate.installPackage': APP_PACKAGE_MISSING", + ), + ), + ).toBe('APP_PACKAGE_MISSING'); + expect(extractUpdateErrorCode(new Error('NOT_FOUND_PACKAGE'))).toBe( + 'APP_PACKAGE_MISSING', + ); + expect(extractUpdateErrorCode(new Error('NOT_FOUND_FILE'))).toBe( + 'APP_PACKAGE_MISSING', + ); + expect( + extractUpdateErrorCode( + new Error("ENOENT: no such file, open '/tmp/app.zip'"), + ), + ).toBe('APP_PACKAGE_MISSING'); + }); + + test('normalizes unreadable app package errors', () => { + expect( + extractUpdateErrorCode(new Error('APP_PACKAGE_UNAVAILABLE:EACCES')), + ).toBe('APP_PACKAGE_UNAVAILABLE'); + expect(extractUpdateErrorCode(new Error('EBUSY: file is locked'))).toBe( + 'APP_PACKAGE_UNAVAILABLE', + ); + }); + + test('keeps updater preparation failures distinct from unreadable packages', () => { + expect(extractUpdateErrorCode(new Error('APP_PACKAGE_NOT_PREPARED'))).toBe( + 'APP_PACKAGE_NOT_PREPARED', + ); + }); + test('iOS / Android SHA256 verification failure → SHA256_', () => { expect( extractUpdateErrorCode( @@ -1564,6 +1621,55 @@ describe('useDownloadPackage', () => { expect.objectContaining({ message: 'ASC download failed' }), ); }); + + test('desktop package missing during downloadASC enters recovery instead of failed state', async () => { + mockPlatformEnv.isDesktop = true; + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.downloadASC, + }); + svc.getDownloadEvent.mockResolvedValue({ downloadedFile: '/tmp/a.zip' }); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.updateIncomplete, + }); + appUpd.downloadASC.mockRejectedValue(new Error('APP_PACKAGE_MISSING')); + const emitSpy = jest.spyOn(appEventBus, 'emit'); + + const { result } = renderHook(() => useDownloadPackage()); + await act(async () => { + await result.current.downloadASC(); + }); + + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); + expect(svc.downloadASCFailed).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith( + EAppEventBusNames.ShowAppUpdateIncompleteDialog, + undefined, + ); + }); + + test('desktop downloadASC with no event first enters the stage, then recovers', async () => { + mockPlatformEnv.isDesktop = true; + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.downloadPackage, + }); + svc.getDownloadEvent.mockResolvedValue(null); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.updateIncomplete, + }); + + const { result } = renderHook(() => useDownloadPackage()); + await act(async () => { + await result.current.downloadASC(); + }); + + expect(svc.downloadASC).toHaveBeenCalled(); + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); + expect(svc.downloadASCFailed).not.toHaveBeenCalled(); + }); }); // ----- B3. verifyASC ----- @@ -1701,6 +1807,57 @@ describe('useDownloadPackage', () => { expect.objectContaining({ message: 'Hash mismatch' }), ); }); + + test('desktop unavailable package during verification enters recovery instead of failed state', async () => { + mockPlatformEnv.isDesktop = true; + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.verifyPackage, + }); + svc.getDownloadEvent.mockResolvedValue({ downloadedFile: '/tmp/a.zip' }); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.updateIncomplete, + }); + appUpd.verifyPackage.mockRejectedValue( + new Error('APP_PACKAGE_UNAVAILABLE:EACCES'), + ); + const emitSpy = jest.spyOn(appEventBus, 'emit'); + + const { result } = renderHook(() => useDownloadPackage()); + await act(async () => { + await result.current.verifyPackage(); + }); + + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); + expect(svc.verifyPackageFailed).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith( + EAppEventBusNames.ShowAppUpdateIncompleteDialog, + undefined, + ); + }); + + test('desktop verification with no event first enters the stage, then recovers', async () => { + mockPlatformEnv.isDesktop = true; + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.verifyASC, + }); + svc.getDownloadEvent.mockResolvedValue(null); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.updateIncomplete, + }); + + const { result } = renderHook(() => useDownloadPackage()); + await act(async () => { + await result.current.verifyPackage(); + }); + + expect(svc.verifyPackage).toHaveBeenCalled(); + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); + expect(svc.verifyPackageFailed).not.toHaveBeenCalled(); + }); }); // ----- B4b. getSkipGPGVerification routing through verifyPackage ----- @@ -1831,6 +1988,26 @@ describe('useDownloadPackage', () => { expect(onFail).not.toHaveBeenCalled(); }); + test('appShell install cancelled → does not report success or failure', async () => { + const onSuccess = jest.fn(); + const onFail = jest.fn(); + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + updateStrategy: EUpdateStrategy.manual, + }); + appUpd.installPackage.mockResolvedValueOnce(false); + + const { result } = renderHook(() => useDownloadPackage()); + + await act(async () => { + await result.current.installPackage(onSuccess, onFail); + }); + + expect(appUpd.installPackage).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onFail).not.toHaveBeenCalled(); + }); + test('jsBundle success → calls BundleUpdate.installBundle + onSuccess', async () => { const onSuccess = jest.fn(); const onFail = jest.fn(); @@ -1896,10 +2073,15 @@ describe('useDownloadPackage', () => { test('install throws NOT_FOUND_PACKAGE → calls onFail', async () => { const onSuccess = jest.fn(); const onFail = jest.fn(); + mockPlatformEnv.isDesktop = true; svc.getUpdateInfo.mockResolvedValue({ latestVersion: '2.0.0', updateStrategy: EUpdateStrategy.manual, }); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.updateIncomplete, + }); appUpd.installPackage.mockRejectedValue(new Error('NOT_FOUND_PACKAGE')); const { result } = renderHook(() => useDownloadPackage()); @@ -1910,6 +2092,7 @@ describe('useDownloadPackage', () => { expect(onFail).toHaveBeenCalled(); expect(onSuccess).not.toHaveBeenCalled(); + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); }); test('install throws + silent → no Toast and no onFail', async () => { @@ -1930,6 +2113,34 @@ describe('useDownloadPackage', () => { expect(onFail).not.toHaveBeenCalled(); expect(mockToastError).not.toHaveBeenCalled(); }); + + test('not-prepared package entering rehydrate does not surface an install error', async () => { + const onSuccess = jest.fn(); + const onFail = jest.fn(); + mockPlatformEnv.isDesktop = true; + svc.getUpdateInfo.mockResolvedValue({ + latestVersion: '2.0.0', + updateStrategy: EUpdateStrategy.manual, + }); + svc.reconcileAppShellPackage.mockResolvedValue({ + latestVersion: '2.0.0', + status: EAppUpdateStatus.downloadPackage, + }); + appUpd.installPackage.mockRejectedValue( + new Error('APP_PACKAGE_NOT_PREPARED'), + ); + + const { result } = renderHook(() => useDownloadPackage()); + + await act(async () => { + await result.current.installPackage(onSuccess, onFail); + }); + + expect(svc.reconcileAppShellPackage).toHaveBeenCalled(); + expect(onFail).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); + }); }); // ----- B6. manualInstallPackage ----- @@ -2105,7 +2316,8 @@ describe('useAppUpdateInfo useEffect', () => { expect(svc.verifyPackage).toHaveBeenCalled(); }); - test('status=updateIncomplete → does nothing', async () => { + test('desktop appShell status=updateIncomplete → shows the recovery dialog', async () => { + mockPlatformEnv.isDesktop = true; setAtom({ status: EAppUpdateStatus.updateIncomplete, latestVersion: '2.0.0', @@ -2123,6 +2335,76 @@ describe('useAppUpdateInfo useEffect', () => { expect(svc.downloadASC).not.toHaveBeenCalled(); expect(svc.verifyASC).not.toHaveBeenCalled(); expect(svc.verifyPackage).not.toHaveBeenCalled(); + expect(mockDialogShow).toHaveBeenCalledWith( + expect.objectContaining({ + description: + ETranslations.update_update_incomplete_package_missing_desc, + }), + ); + }); + + test('desktop appShell recovery dialog waits for the app to unlock', async () => { + let resolveUnlock: (() => void) | undefined; + mockWhenAppUnlocked.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUnlock = resolve; + }), + ); + mockPlatformEnv.isDesktop = true; + setAtom({ + status: EAppUpdateStatus.updateIncomplete, + latestVersion: '2.0.0', + }); + + const hooks = requireFreshHooks(); + renderHook(() => hooks.useAppUpdateInfo(false, true)); + + await act(async () => { + await jest.advanceTimersByTimeAsync(0); + }); + expect(mockWhenAppUnlocked).toHaveBeenCalled(); + expect(mockDialogShow).not.toHaveBeenCalled(); + + await act(async () => { + resolveUnlock?.(); + await Promise.resolve(); + }); + expect(mockDialogShow).toHaveBeenCalled(); + }); + + test('non-desktop status=updateIncomplete → does not add a startup dialog', async () => { + setAtom({ + status: EAppUpdateStatus.updateIncomplete, + latestVersion: '2.0.0', + }); + + const hooks = requireFreshHooks(); + renderHook(() => hooks.useAppUpdateInfo(false, true)); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + expect(mockDialogShow).not.toHaveBeenCalled(); + }); + + test('desktop JS Bundle status=updateIncomplete → does not add a startup dialog', async () => { + mockPlatformEnv.isDesktop = true; + setAtom({ + status: EAppUpdateStatus.updateIncomplete, + latestVersion: '1.0.0', + jsBundleVersion: '5', + }); + + const hooks = requireFreshHooks(); + renderHook(() => hooks.useAppUpdateInfo(false, true)); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + expect(mockDialogShow).not.toHaveBeenCalled(); }); }); @@ -2750,6 +3032,141 @@ describe('useAppUpdateInfo useEffect', () => { expect(mockDialogShow).not.toHaveBeenCalled(); expect(nav.pushModal).not.toHaveBeenCalled(); expect(nav.pushFullModal).not.toHaveBeenCalled(); + expect(svc.processPendingInstallTask).not.toHaveBeenCalled(); + }); + + test('rehydrated macOS silent package installs in the prepared process', async () => { + setAtom({ + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.silent, + latestVersion: '2.0.0', + downloadedEvent: { + downloadedFile: '/tmp/app.zip', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + isUpdaterRehydrated: true, + }, + }); + mockPlatformEnv.isDesktop = true; + mockPlatformEnv.isDesktopMac = true; + svc.getUpdateInfo.mockResolvedValue(mockAtomHolder.value); + svc.fetchAppUpdateInfo.mockResolvedValue(mockAtomHolder.value); + + const hooks = requireFreshHooks(); + renderHook(() => hooks.useAppUpdateInfo(false, true)); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + expect(svc.processPendingInstallTask).toHaveBeenCalledTimes(1); + }); + + test('rehydrated macOS seamless package installs when it becomes ready in-session', async () => { + setAtom({ + status: EAppUpdateStatus.done, + updateStrategy: EUpdateStrategy.manual, + latestVersion: '1.0.0', + }); + mockPlatformEnv.isDesktop = true; + mockPlatformEnv.isDesktopMac = true; + + const hooks = requireFreshHooks(); + const { rerender } = renderHook(() => + hooks.useAppUpdateInfo(false, true), + ); + await act(async () => { + await jest.runAllTimersAsync(); + }); + svc.processPendingInstallTask.mockClear(); + + setAtom({ + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + latestVersion: '2.0.0', + downloadedEvent: { + downloadedFile: '/tmp/app.zip', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + isUpdaterRehydrated: true, + }, + }); + rerender(); + await act(async () => { + await Promise.resolve(); + }); + + expect(svc.processPendingInstallTask).toHaveBeenCalledTimes(1); + }); + + test('unprepared auto-ready state does not consume the later rehydrate install', async () => { + setAtom({ + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + latestVersion: '2.0.0', + downloadedEvent: { + downloadedFile: '/tmp/app.zip', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + }, + }); + mockPlatformEnv.isDesktop = true; + mockPlatformEnv.isDesktopMac = true; + + const hooks = requireFreshHooks(); + const { rerender } = renderHook(() => + hooks.useAppUpdateInfo(false, true), + ); + await act(async () => { + await jest.runAllTimersAsync(); + }); + expect(svc.processPendingInstallTask).not.toHaveBeenCalled(); + + setAtom({ + status: EAppUpdateStatus.ready, + updateStrategy: EUpdateStrategy.seamless, + latestVersion: '2.0.0', + downloadedEvent: { + downloadedFile: '/tmp/app.zip', + downloadUrl: 'https://cdn.onekey.so/app-2.0.0.zip', + isUpdaterRehydrated: true, + }, + }); + rerender(); + await act(async () => { + await Promise.resolve(); + }); + + expect(svc.processPendingInstallTask).toHaveBeenCalledTimes(1); + }); + + test('update-incomplete event waits for the app to unlock', async () => { + let resolveUnlock: (() => void) | undefined; + mockWhenAppUnlocked.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUnlock = resolve; + }), + ); + setAtom({ + status: EAppUpdateStatus.done, + latestVersion: '1.0.0', + }); + + const hooks = requireFreshHooks(); + renderHook(() => hooks.useAppUpdateInfo(false, true)); + act(() => { + appEventBus.emit( + EAppEventBusNames.ShowAppUpdateIncompleteDialog, + undefined, + ); + }); + + expect(mockWhenAppUnlocked).toHaveBeenCalled(); + expect(mockDialogShow).not.toHaveBeenCalled(); + + await act(async () => { + resolveUnlock?.(); + await Promise.resolve(); + }); + expect(mockDialogShow).toHaveBeenCalled(); }); test('ready + manual strategy → shows regular update dialog', async () => { diff --git a/packages/kit/src/components/AppUpdate/useDownloadPackage.tsx b/packages/kit/src/components/AppUpdate/useDownloadPackage.tsx index cce85ed5333f..81227ede4ce1 100644 --- a/packages/kit/src/components/AppUpdate/useDownloadPackage.tsx +++ b/packages/kit/src/components/AppUpdate/useDownloadPackage.tsx @@ -13,11 +13,16 @@ import { useIntl } from 'react-intl'; import { Dialog, Toast } from '@onekeyhq/components'; import { + EAppUpdateStatus, EUpdateFileType, getUpdateFileType, } from '@onekeyhq/shared/src/appUpdate'; import { OneKeyError } from '@onekeyhq/shared/src/errors'; import { resolveErrorI18nMessage } from '@onekeyhq/shared/src/errors/utils/electronIpcError'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import type { IDownloadPackageParams } from '@onekeyhq/shared/src/modules3rdParty/auto-update'; @@ -25,6 +30,7 @@ import { AppUpdate, BundleUpdate, } from '@onekeyhq/shared/src/modules3rdParty/auto-update'; +import { EAppUpdatePackageErrorCode } from '@onekeyhq/shared/src/modules3rdParty/auto-update/type'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { getRequestHeaders } from '@onekeyhq/shared/src/request/Interceptor'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -49,6 +55,14 @@ import { isShowToastError } from './updateStrategy'; const MIN_EXECUTION_DURATION = 3000; // 3 seconds minimum execution time +function isAppShellPackageInvalidError(errorCode?: string) { + return ( + errorCode === EAppUpdatePackageErrorCode.packageMissing || + errorCode === EAppUpdatePackageErrorCode.packageUnavailable || + errorCode === EAppUpdatePackageErrorCode.packageNotPrepared + ); +} + export const useDownloadPackage = () => { const intl = useIntl(); const navigation = useAppNavigation(); @@ -74,6 +88,52 @@ export const useDownloadPackage = () => { [], ); + const recoverAppShellPackage = useCallback( + async ({ + fileType, + errorCode, + showIncompleteDialog, + }: { + fileType: EUpdateFileType; + errorCode?: string; + showIncompleteDialog: boolean; + }): Promise => { + if ( + !platformEnv.isDesktop || + fileType !== EUpdateFileType.appShell || + !isAppShellPackageInvalidError(errorCode) + ) { + return undefined; + } + try { + const info = + await backgroundApiProxy.serviceAppUpdate.reconcileAppShellPackage(); + const recoveredStatus = + info.status === EAppUpdateStatus.updateIncomplete || + info.status === EAppUpdateStatus.notify || + info.status === EAppUpdateStatus.downloadPackage + ? info.status + : undefined; + if ( + showIncompleteDialog && + recoveredStatus === EAppUpdateStatus.updateIncomplete + ) { + appEventBus.emit( + EAppEventBusNames.ShowAppUpdateIncompleteDialog, + undefined, + ); + } + return recoveredStatus; + } catch { + defaultLogger.app.appUpdate.log( + 'recoverAppShellPackage: reconciliation failed', + ); + return undefined; + } + }, + [], + ); + const installPackage = useCallback( async (onSuccess: () => void, onFail: () => void) => { const data = await backgroundApiProxy.serviceAppUpdate.getUpdateInfo(); @@ -88,27 +148,40 @@ export const useDownloadPackage = () => { } await BundleUpdate.installBundle(data.downloadedEvent); } else { - await AppUpdate.installPackage(data); + const installStarted = await AppUpdate.installPackage(data); + if (!installStarted) { + return; + } } defaultLogger.app.appUpdate.endInstallPackage(true); onSuccess(); } catch (e: unknown) { + const errorCode = extractUpdateErrorCode(e); defaultLogger.app.appUpdate.endInstallPackage(false, e as Error); defaultLogger.app.appUpdate.softwareUpdateResult({ ...buildSoftwareUpdateParams(fileType, data, getUpdateAttemptId()), status: 'failed', failedStep: 'install', errorMessage: sanitizeUpdateErrorMessage(e), - errorCode: extractUpdateErrorCode(e), + errorCode, + }); + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode, + showIncompleteDialog: false, }); - if ((e as { message?: string })?.message === 'NOT_FOUND_PACKAGE') { + if (recoveredStatus) { + if (recoveredStatus === EAppUpdateStatus.updateIncomplete) { + onFail(); + } + } else if (errorCode === EAppUpdatePackageErrorCode.packageMissing) { onFail(); } else if (showToastError) { Toast.error({ title: resolveErrorI18nMessage(e, intl) }); } } }, - [getFileTypeFromUpdateInfo, intl], + [getFileTypeFromUpdateInfo, intl, recoverAppShellPackage], ); const verifyPackage = useCallback(async () => { @@ -121,6 +194,17 @@ export const useDownloadPackage = () => { const params = await backgroundApiProxy.serviceAppUpdate.getDownloadEvent(); if (!params) { + if (platformEnv.isDesktop && fileType === EUpdateFileType.appShell) { + await backgroundApiProxy.serviceAppUpdate.verifyPackage(); + } + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode: EAppUpdatePackageErrorCode.packageMissing, + showIncompleteDialog: true, + }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.verifyPackageFailed(); return; } @@ -141,6 +225,7 @@ export const useDownloadPackage = () => { await backgroundApiProxy.serviceAppUpdate.readyToInstall(); defaultLogger.app.appUpdate.endVerifyPackage(true); } catch (e) { + const errorCode = extractUpdateErrorCode(e); defaultLogger.app.appUpdate.endVerifyPackage(false, e as Error); defaultLogger.app.appUpdate.softwareUpdateResult({ ...buildSoftwareUpdateParams( @@ -151,11 +236,19 @@ export const useDownloadPackage = () => { status: 'failed', failedStep: 'verifyPackage', errorMessage: sanitizeUpdateErrorMessage(e), - errorCode: extractUpdateErrorCode(e), + errorCode, }); + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode, + showIncompleteDialog: true, + }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.verifyPackageFailed(e as Error); } - }, [getSkipGPGVerification]); + }, [getSkipGPGVerification, recoverAppShellPackage]); const verifyASC = useCallback(async () => { const fileType = await getFileTypeFromUpdateInfo(); @@ -164,6 +257,17 @@ export const useDownloadPackage = () => { const params = await backgroundApiProxy.serviceAppUpdate.getDownloadEvent(); if (!params) { + if (platformEnv.isDesktop && fileType === EUpdateFileType.appShell) { + await backgroundApiProxy.serviceAppUpdate.verifyASC(); + } + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode: EAppUpdatePackageErrorCode.packageMissing, + showIncompleteDialog: true, + }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.verifyASCFailed(); return; } @@ -186,6 +290,7 @@ export const useDownloadPackage = () => { } catch (e) { const appUpdateInfo = await backgroundApiProxy.serviceAppUpdate.getUpdateInfo(); + const errorCode = extractUpdateErrorCode(e); defaultLogger.app.appUpdate.endVerifyASC(false, e as Error); defaultLogger.app.appUpdate.softwareUpdateResult({ ...buildSoftwareUpdateParams( @@ -196,11 +301,24 @@ export const useDownloadPackage = () => { status: 'failed', failedStep: 'verifyASC', errorMessage: sanitizeUpdateErrorMessage(e), - errorCode: extractUpdateErrorCode(e), + errorCode, }); + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode, + showIncompleteDialog: true, + }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.verifyASCFailed(e as Error); } - }, [getFileTypeFromUpdateInfo, getSkipGPGVerification, verifyPackage]); + }, [ + getFileTypeFromUpdateInfo, + getSkipGPGVerification, + recoverAppShellPackage, + verifyPackage, + ]); const downloadASC = useCallback(async () => { const fileType = await getFileTypeFromUpdateInfo(); @@ -209,6 +327,17 @@ export const useDownloadPackage = () => { const params = await backgroundApiProxy.serviceAppUpdate.getDownloadEvent(); if (!params) { + if (platformEnv.isDesktop && fileType === EUpdateFileType.appShell) { + await backgroundApiProxy.serviceAppUpdate.downloadASC(); + } + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode: EAppUpdatePackageErrorCode.packageMissing, + showIncompleteDialog: true, + }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.downloadASCFailed(); return; } @@ -231,6 +360,7 @@ export const useDownloadPackage = () => { } catch (e) { const appUpdateInfo = await backgroundApiProxy.serviceAppUpdate.getUpdateInfo(); + const errorCode = extractUpdateErrorCode(e); defaultLogger.app.appUpdate.endDownloadASC(false, e as Error); defaultLogger.app.appUpdate.softwareUpdateResult({ ...buildSoftwareUpdateParams( @@ -241,11 +371,24 @@ export const useDownloadPackage = () => { status: 'failed', failedStep: 'downloadASC', errorMessage: sanitizeUpdateErrorMessage(e), - errorCode: extractUpdateErrorCode(e), + errorCode, + }); + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode, + showIncompleteDialog: true, }); + if (recoveredStatus) { + return; + } await backgroundApiProxy.serviceAppUpdate.downloadASCFailed(e as Error); } - }, [getFileTypeFromUpdateInfo, getSkipGPGVerification, verifyASC]); + }, [ + getFileTypeFromUpdateInfo, + getSkipGPGVerification, + recoverAppShellPackage, + verifyASC, + ]); const downloadPackage = useCallback(async () => { return withDownloadMutex(async () => { @@ -445,7 +588,23 @@ export const useDownloadPackage = () => { } defaultLogger.app.appUpdate.endManualInstallPackage(true); } catch (e) { + const errorCode = extractUpdateErrorCode(e); defaultLogger.app.appUpdate.endManualInstallPackage(false, e as Error); + const recoveredStatus = await recoverAppShellPackage({ + fileType, + errorCode, + showIncompleteDialog: false, + }); + if (recoveredStatus) { + if (recoveredStatus === EAppUpdateStatus.updateIncomplete) { + showUpdateInCompleteDialog({ + onConfirm: () => { + navigation.popStack(); + }, + }); + } + return; + } Toast.error({ title: intl.formatMessage({ id: ETranslations.global_update_failed, @@ -458,7 +617,13 @@ export const useDownloadPackage = () => { }, }); } - }, [getFileTypeFromUpdateInfo, intl, navigation, showUpdateInCompleteDialog]); + }, [ + getFileTypeFromUpdateInfo, + intl, + navigation, + recoverAppShellPackage, + showUpdateInCompleteDialog, + ]); return useMemo( () => ({ diff --git a/packages/kit/src/components/Hardware/Hardware.tsx b/packages/kit/src/components/Hardware/Hardware.tsx index 6ff95eed3bab..f1e204785b0b 100644 --- a/packages/kit/src/components/Hardware/Hardware.tsx +++ b/packages/kit/src/components/Hardware/Hardware.tsx @@ -26,9 +26,9 @@ import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms' import LazyLoad from '@onekeyhq/shared/src/lazyLoad'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; -import { usePromiseResult } from '../../hooks/usePromiseResult'; import { useThemeVariant } from '../../hooks/useThemeVariant'; import { SHOW_CLOSE_ACTION_MIN_DURATION } from '../../provider/Container/HardwareUiStateContainer/constants'; @@ -453,6 +453,8 @@ 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 NEO_DEVICE_TYPE: case EDeviceType.Pro: return import('@onekeyhq/kit/assets/animations/confirm-on-pro-dark.json'); default: @@ -518,24 +520,12 @@ export function CommonDeviceLoading({ bleName?: string; }) { const [{ hardwareTransportType }] = useSettingsPersistAtom(); - const { result: communicationMethod } = usePromiseResult<'bluetooth' | 'usb'>( - async () => { - if (platformEnv.isNative) { - return 'bluetooth'; - } - if (platformEnv.isSupportDesktopBle) { - if (hardwareTransportType === EHardwareTransportType.DesktopWebBle) { - return 'bluetooth'; - } - return 'usb'; - } - return 'usb'; - }, - [hardwareTransportType], - { - initResult: 'usb', - }, - ); + const communicationMethod = + platformEnv.isNative || + (platformEnv.isSupportDesktopBle && + hardwareTransportType === EHardwareTransportType.DesktopWebBle) + ? 'bluetooth' + : 'usb'; return ( <> : 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 ? ( + ); +} + function NavigateToCloudSyncSwitchButton() { const intl = useIntl(); @@ -218,6 +243,7 @@ function ClearPendingTransactionsButton() { export function getErrorAction({ errorCode, + connectId, requestId, diagnosticText, i18nKey, @@ -227,6 +253,12 @@ export function getErrorAction({ return ; } + // Generic hardware fallback: advises staying up to date, so send the user to + // the in-app firmware update flow rather than the web tool. + if (errorCode === ECustomOneKeyHardwareError.UnknownHardwareError) { + return ; + } + // Cloud sync: navigate to Cloud Sync settings page if (errorCode === ECustomCloudSyncError.OnekeyIdSyncSunsetReminder) { return ; diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx b/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx index 514c16df1be9..56bc987c0d9b 100644 --- a/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx @@ -77,6 +77,8 @@ import { SHOW_CLOSE_LOADING_ACTION_MIN_DURATION, } from './constants'; import { isTrezorHardwareErrorDialogPayload } from './hardwareErrorDialogUtils'; +import { shouldSkipHardwareDeviceCancel } from './hardwareUiCancelPolicy'; +import { hardwareUiStateDialogLifecycle } from './hardwareUiStateDialogLifecycle'; let globalShowDeviceProgressDialogEnabled = true; @@ -278,6 +280,7 @@ function HardwareSingletonDialogCmp( onConfirm={async (value) => { await serviceHardwareUI.sendPinToDevice({ pin: value, + responseCorrelation: state?.payload?.uiResponseCorrelation, }); await serviceHardwareUI.closeHardwareUiStateDialog({ skipDeviceCancel: true, @@ -299,7 +302,10 @@ function HardwareSingletonDialogCmp( // EnterPassphrase on App if (action === EHardwareUiStateAction.REQUEST_PASSPHRASE) { - const isSingleInput = !!state?.payload?.passphraseState; + const isSingleInput = !!( + state?.payload?.passphraseState || + state?.payload?.expectedPassphraseState + ); const saveCachedHiddenWalletOptions = async ({ hideImmediately, }: { @@ -319,12 +325,17 @@ function HardwareSingletonDialogCmp( { await saveCachedHiddenWalletOptions({ hideImmediately, }); await serviceHardwareUI.sendPassphraseToDevice({ passphrase, + responseCorrelation: state?.payload?.uiResponseCorrelation, }); // The device will not emit a loading event // so we need to manually display the loading to inform the user that the device is currently processing @@ -341,13 +352,17 @@ function HardwareSingletonDialogCmp( await saveCachedHiddenWalletOptions({ hideImmediately, }); - await serviceHardwareUI.showEnterPassphraseOnDeviceDialog(); + await serviceHardwareUI.showEnterPassphraseOnDeviceDialog({ + responseCorrelation: state?.payload?.uiResponseCorrelation, + }); }} switchOnDeviceAttachPin={async ({ hideImmediately }) => { await saveCachedHiddenWalletOptions({ hideImmediately, }); - await serviceHardwareUI.showEnterAttachPinOnDeviceDialog(); + await serviceHardwareUI.showEnterAttachPinOnDeviceDialog({ + responseCorrelation: state?.payload?.uiResponseCorrelation, + }); }} /> ); @@ -631,22 +646,13 @@ function HardwareUiStateContainerCmpControlled() { [], ); - const shouldSkipCancel = useMemo(() => { - // TODO atom firmware is updating - if ( - action && - [ - EHardwareUiStateAction.FIRMWARE_TIP, - EHardwareUiStateAction.FIRMWARE_PROGRESS, - EHardwareUiStateAction.FIRMWARE_PROCESSING, - EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, - ].includes(action) - ) { - return true; - } - - return false; - }, [action]); + const shouldSkipCancel = useMemo( + () => + shouldSkipHardwareDeviceCancel({ + action, + }), + [action], + ); const shouldSkipCancelRef = useRef(shouldSkipCancel); shouldSkipCancelRef.current = shouldSkipCancel; @@ -676,6 +682,17 @@ function HardwareUiStateContainerCmpControlled() { state, ]); + useEffect(() => { + hardwareUiStateDialogLifecycle.updateOpenState(actionStatus.isDialogAction); + }, [actionStatus.isDialogAction]); + + useEffect( + () => () => { + hardwareUiStateDialogLifecycle.updateOpenState(false); + }, + [], + ); + // Block Android back button when hardware toast is showing const handleBackPress = useCallback(() => true, []); useBackHandler(handleBackPress, actionStatus.isToastAction); @@ -714,7 +731,9 @@ function HardwareUiStateContainerCmpControlled() { await serviceHardwareUI.closeHardwareUiStateDialog({ connectId: state?.connectId, skipDeviceCancel: shouldSkipCancelRef.current, + immediateDeviceCancel: true, deviceResetToHome: actionStatus.currentShouldDeviceResetToHome, + deviceType: state?.payload?.deviceType, }); } }} @@ -754,7 +773,9 @@ function HardwareUiStateContainerCmpControlled() { connectId: state?.connectId, reason: 'HardwareUiStateContainer onClose', skipDeviceCancel: shouldSkipCancelRef.current, + immediateDeviceCancel: true, deviceResetToHome: actionStatus.currentShouldDeviceResetToHome, + deviceType: state?.payload?.deviceType, }); } }} diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.test.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.test.ts new file mode 100644 index 000000000000..f1677020fbae --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.test.ts @@ -0,0 +1,58 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; + +import { shouldSkipHardwareDeviceCancel } from './hardwareUiCancelPolicy'; + +describe('shouldSkipHardwareDeviceCancel', () => { + it('sends cancel for device prompts regardless of device type', () => { + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.REQUEST_PIN, + deviceType: EDeviceType.Pro2, + }), + ).toBe(false); + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.REQUEST_BUTTON, + deviceType: EDeviceType.Classic, + }), + ).toBe(false); + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.REQUEST_PIN, + deviceType: EDeviceType.Pro, + }), + ).toBe(false); + }); + + it('lets the SDK decide cancel during pairing or permission UI', () => { + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.DeviceChecking, + eventType: EHardwareUiStateAction.BLUETOOTH_DEVICE_PAIRING, + deviceType: EDeviceType.Pro2, + }), + ).toBe(false); + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.BLUETOOTH_PERMISSION, + deviceType: EDeviceType.Pro2, + }), + ).toBe(false); + }); + + it('still skips cancel for firmware workflow and SDK pin-window close', () => { + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.FIRMWARE_PROGRESS, + deviceType: EDeviceType.Pro2, + }), + ).toBe(true); + expect( + shouldSkipHardwareDeviceCancel({ + action: EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, + }), + ).toBe(true); + }); +}); diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.ts new file mode 100644 index 000000000000..f98b5fc97ad4 --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiCancelPolicy.ts @@ -0,0 +1,23 @@ +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; + +const SKIP_CANCEL_ACTIONS = new Set([ + EHardwareUiStateAction.FIRMWARE_TIP, + EHardwareUiStateAction.FIRMWARE_PROGRESS, + EHardwareUiStateAction.FIRMWARE_PROCESSING, + EHardwareUiStateAction.CLOSE_UI_PIN_WINDOW, +]); + +export function shouldSkipHardwareDeviceCancel({ + action, +}: { + action?: EHardwareUiStateAction | string; + eventType?: string; + deviceType?: string | null; +}): boolean { + // Firmware install and SDK-owned pin-window close are App workflow + // lifetime, not protocol Cancel policy. Device type and pairing skips + // belong in the SDK. + return Boolean( + action && SKIP_CANCEL_ACTIONS.has(action as EHardwareUiStateAction), + ); +} diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts new file mode 100644 index 000000000000..395954357c23 --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts @@ -0,0 +1,152 @@ +import { HardwareUiStateDialogLifecycle } from './hardwareUiStateDialogLifecycle'; + +describe('HardwareUiStateDialogLifecycle', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not wait when the hardware dialog is already closed', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + let closeCalled = false; + + await lifecycle.closeAndWait(async () => { + closeCalled = true; + }); + + expect(closeCalled).toBe(true); + }); + + it('waits for a pending hardware dialog to open before continuing', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + let completed = false; + + const openPromise = lifecycle + .openAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(true); + await openPromise; + + expect(completed).toBe(true); + }); + + it('serializes a pending open and close acknowledgement', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + + const openPromise = lifecycle.openAndWait(async () => undefined); + lifecycle.updateOpenState(true); + await openPromise; + + let closeCompleted = false; + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + closeCompleted = true; + }); + + await Promise.resolve(); + expect(closeCompleted).toBe(false); + + lifecycle.updateOpenState(false); + await closePromise; + + expect(closeCompleted).toBe(true); + }); + + it('waits for the UI commit that closes an open hardware dialog', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(false); + await closePromise; + + expect(completed).toBe(true); + }); + + it('waits for the native Sheet exit window after the close commit', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 300); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(299); + await Promise.resolve(); + expect(completed).toBe(false); + + jest.advanceTimersByTime(1); + await closePromise; + expect(completed).toBe(true); + }); + + it('cancels close settlement when another hardware dialog opens', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 300); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(150); + lifecycle.updateOpenState(true); + jest.advanceTimersByTime(300); + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(300); + await closePromise; + expect(completed).toBe(true); + }); + + it('rejects instead of mounting another overlay without a close acknowledgement', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(1000, 0); + lifecycle.updateOpenState(true); + + const closePromise = lifecycle.closeAndWait(async () => undefined); + jest.advanceTimersByTime(1000); + + await expect(closePromise).rejects.toThrow( + 'Hardware UI dialog close acknowledgement timed out', + ); + }); + + it('rejects when a requested hardware dialog never opens', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(1000, 0); + + const openPromise = lifecycle.openAndWait(async () => undefined); + jest.advanceTimersByTime(1000); + + await expect(openPromise).rejects.toThrow( + 'Hardware UI dialog open acknowledgement timed out', + ); + }); +}); diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts new file mode 100644 index 000000000000..faf8bf157e56 --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts @@ -0,0 +1,121 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +const DEFAULT_STATE_ACK_TIMEOUT_MS = 5000; +// Dialog.show keeps its portal mounted for 300 ms while the native Sheet exits. +// Treating the React state commit as "closed" lets the next Sheet mount inside +// that exit window, which can leave the old iOS overlay intercepting touches. +const DEFAULT_CLOSE_SETTLE_MS = 300; + +type IStateWaiter = { + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +export class HardwareUiStateDialogLifecycle { + private isOpen = false; + + private closeSettleTimer: ReturnType | undefined; + + private readonly openWaiters = new Set(); + + private readonly closeWaiters = new Set(); + + constructor( + private readonly stateAckTimeoutMs = DEFAULT_STATE_ACK_TIMEOUT_MS, + private readonly closeSettleMs = DEFAULT_CLOSE_SETTLE_MS, + ) {} + + updateOpenState(isOpen: boolean) { + clearTimeout(this.closeSettleTimer); + this.closeSettleTimer = undefined; + + if (isOpen) { + this.isOpen = true; + this.resolveWaiters(this.openWaiters); + return; + } + + if (!this.isOpen) { + this.resolveWaiters(this.closeWaiters); + return; + } + + this.closeSettleTimer = setTimeout(() => { + this.closeSettleTimer = undefined; + this.isOpen = false; + this.resolveWaiters(this.closeWaiters); + }, this.closeSettleMs); + } + + async openAndWait(openAction: () => Promise) { + const openWaiter = this.isOpen + ? undefined + : this.createStateWaiter( + this.openWaiters, + 'Hardware UI dialog open acknowledgement timed out', + ); + + try { + await openAction(); + await openWaiter?.promise; + } catch (error) { + openWaiter?.cancel(); + throw error; + } + } + + async closeAndWait(closeAction: () => Promise) { + const closeWaiter = this.isOpen + ? this.createStateWaiter( + this.closeWaiters, + 'Hardware UI dialog close acknowledgement timed out', + ) + : undefined; + + try { + await closeAction(); + await closeWaiter?.promise; + } catch (error) { + closeWaiter?.cancel(); + throw error; + } + } + + private resolveWaiters(waiters: Set) { + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve(); + } + waiters.clear(); + } + + private createStateWaiter( + waiters: Set, + timeoutMessage: string, + ) { + let waiter: IStateWaiter; + const promise = new Promise((resolve, reject) => { + waiter = { + resolve, + reject, + timer: setTimeout(() => { + waiters.delete(waiter); + reject(new OneKeyLocalError(timeoutMessage)); + }, this.stateAckTimeoutMs), + }; + waiters.add(waiter); + }); + + return { + promise, + cancel: () => { + clearTimeout(waiter.timer); + waiters.delete(waiter); + }, + }; + } +} + +export const hardwareUiStateDialogLifecycle = + new HardwareUiStateDialogLifecycle(); diff --git a/packages/kit/src/routes/routerPathConfig.firmware.test.ts b/packages/kit/src/routes/routerPathConfig.firmware.test.ts new file mode 100644 index 000000000000..97402dfb7038 --- /dev/null +++ b/packages/kit/src/routes/routerPathConfig.firmware.test.ts @@ -0,0 +1,16 @@ +import { + EModalFirmwareUpdateRoutes, + EModalRoutes, +} from '@onekeyhq/shared/src/routes'; + +import { modalRouterPathConfig } from './routerPathConfig'; + +describe('firmware update extension cold-start route', () => { + it('exposes the changelog without exposing install routes', () => { + expect( + modalRouterPathConfig + .find((route) => route.name === EModalRoutes.FirmwareUpdateModal) + ?.children?.map((route) => route.name), + ).toEqual([EModalFirmwareUpdateRoutes.ChangeLog]); + }); +}); diff --git a/packages/kit/src/routes/routerPathConfig.ts b/packages/kit/src/routes/routerPathConfig.ts index 0f5b580f01cc..d96e6c79c436 100644 --- a/packages/kit/src/routes/routerPathConfig.ts +++ b/packages/kit/src/routes/routerPathConfig.ts @@ -2,6 +2,7 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EAppUpdateRoutes, EDAppConnectionModal, + EModalFirmwareUpdateRoutes, EModalReferFriendsRoutes, EModalRewardCenterRoutes, EModalRoutes, @@ -64,6 +65,10 @@ const appUpdatePathConfig = [ }), ]; +const firmwareUpdatePathConfig = [ + route({ name: EModalFirmwareUpdateRoutes.ChangeLog }), +]; + const stakingPathConfig = [ route({ name: EModalStakingRoutes.ProtocolDetails, @@ -137,6 +142,10 @@ const modalRouteOverrides: Partial> = { rewrite: '/update', children: appUpdatePathConfig, }), + [EModalRoutes.FirmwareUpdateModal]: route({ + name: EModalRoutes.FirmwareUpdateModal, + children: firmwareUpdatePathConfig, + }), [EModalRoutes.StakingModal]: route({ name: EModalRoutes.StakingModal, children: stakingPathConfig, diff --git a/packages/kit/src/states/jotai/contexts/accountSelector/actions.tsx b/packages/kit/src/states/jotai/contexts/accountSelector/actions.tsx index 0e577e38673c..9e9e64415cc6 100644 --- a/packages/kit/src/states/jotai/contexts/accountSelector/actions.tsx +++ b/packages/kit/src/states/jotai/contexts/accountSelector/actions.tsx @@ -2192,15 +2192,12 @@ class AccountSelectorActions extends ContextJotaiActionsBase { async (_, set, params: IDBCreateHwWalletParamsBase) => this.withFinalizeWalletSetupStep.call(set, { createWalletFn: async () => { - const shouldCreateHiddenWalletOnly = Boolean( - params?.features?.passphrase_protection, - ); const { wallet, device, indexedAccount, isOverrideWallet } = await this.createHWWallet.call( set, { ...params, - isMockedStandardHwWallet: shouldCreateHiddenWalletOnly, + isMockedStandardHwWallet: true, skipDeviceCancel: true, }, { @@ -2208,56 +2205,40 @@ class AccountSelectorActions extends ContextJotaiActionsBase { }, ); - let hiddenWalletCreatedResult: - | { - wallet: IDBWallet; - indexedAccount: IDBIndexedAccount | undefined; - } - | undefined; - // add hidden wallet if device passphrase enabled (SearchedDevice.features is cached in web sdk) - if (device && shouldCreateHiddenWalletOnly) { - // wait previous action done, wait device ready - if (!params.hideCheckingDeviceLoading) { - await backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog( - { - connectId: device.connectId, - }, - ); - } - await timerUtils.wait(100); + if (!device) { + throw new OneKeyLocalError( + 'Unable to create hidden wallet without a hardware device', + ); + } - hiddenWalletCreatedResult = await this.createHWHiddenWallet.call( - set, + // wait previous action done, wait device ready + if (!params.hideCheckingDeviceLoading) { + await backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog( { - walletId: wallet.id, - skipDeviceCancel: true, - hideCheckingDeviceLoading: params.hideCheckingDeviceLoading, + connectId: device.connectId, }, ); } + await timerUtils.wait(100); + + const hiddenWalletCreatedResult = + await this.createHWHiddenWallet.call(set, { + walletId: wallet.id, + skipDeviceCancel: true, + hideCheckingDeviceLoading: params.hideCheckingDeviceLoading, + }); await serviceAccount.restoreTempCreatedWallet({ walletId: wallet.id, }); - if (!hiddenWalletCreatedResult) { - await this.autoSelectToCreatedWallet.call(set, { - wallet, - indexedAccount, - isOverrideWallet, - isAttachPinMode: params.isAttachPinMode, - }); - } - return { isOverrideWallet, wallet, indexedAccount, - hidden: hiddenWalletCreatedResult - ? { - wallet: hiddenWalletCreatedResult?.wallet, - indexedAccount: hiddenWalletCreatedResult?.indexedAccount, - } - : undefined, + hidden: { + wallet: hiddenWalletCreatedResult.wallet, + indexedAccount: hiddenWalletCreatedResult.indexedAccount, + }, }; }, generatingAccountsFn: async ({ wallet, indexedAccount, hidden }) => { diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/actions.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/actions.ts index 34fc9fd13133..8fa2b4a2c89e 100644 --- a/packages/kit/src/states/jotai/contexts/deviceDetails/actions.ts +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/actions.ts @@ -2,8 +2,10 @@ import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/background import { ContextJotaiActionsBase } from '@onekeyhq/kit/src/states/jotai/utils/ContextJotaiActionsBase'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { memoFn } from '@onekeyhq/shared/src/utils/cacheUtils'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import thirdPartyDeviceUtils from '@onekeyhq/shared/src/utils/thirdPartyDeviceUtils'; import type { IHwQrWalletWithDevice } from '@onekeyhq/shared/types/account'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; @@ -13,31 +15,74 @@ import { currentWalletIdAtom, deviceMetaStateAtom, deviceMetaStaticAtom, + deviceStateSnapshotAtom, emptyMetaState, emptyMetaStatic, refreshSettledAtom, walletWithDeviceStateAtom, } from './atoms'; +import { + buildDeviceMetaStateFromState, + getDeviceMetaStaticDataFromState, + getDeviceStateSnapshotFromEvent, + mergeDeviceSettingState, + pickNewerDeviceStateSnapshot, + resolveDeviceState, + resolveUsableWalletWithDevice, + shouldApplyDeviceSettingMutationLocally, +} from './deviceStateManagement'; import type { IDeviceMetaState, IDeviceMetaStatic } from './atoms'; +import type { IDeviceStateSnapshot } from './deviceStateManagement'; +import type { DeviceStateEvent } from '@onekeyfe/hd-core'; async function buildDeviceMetaStatic( walletWithDevice?: IHwQrWalletWithDevice, + stateSnapshot?: IDeviceStateSnapshot, ): Promise { - if (!walletWithDevice?.device?.featuresInfo) { + if (!walletWithDevice?.device) { return undefined; } const { device } = walletWithDevice; - const features = device.featuresInfo; - if (!features) { - return undefined; - } const vendorProfile = getVendorProfile( device.vendor ?? EHardwareVendor.onekey, ); const isThirdParty = vendorProfile.isThirdParty; + const state = isThirdParty + ? undefined + : resolveDeviceState({ + persistedState: device.deviceStateInfo, + snapshot: stateSnapshot, + }); + if (state) { + const data = getDeviceMetaStaticDataFromState(state); + const firmwareTypeLabel = deviceUtils.getFirmwareTypeLabelByFirmwareType({ + firmwareType: data.firmwareType, + displayFormat: 'withSpace', + }); + let addWallpaperTitleId = ETranslations.global_wallpaper; + if ( + !isProtocolV2ProductType(data.deviceType) && + deviceUtils.isTouchDevice(data.deviceType) + ) { + addWallpaperTitleId = ETranslations.global_wallpaper_add; + } + return { + ...data, + firmwareVersion: data.firmwareVersion ?? '0.0.0', + firmwareVersionDisplay: data.firmwareVersion + ? `${firmwareTypeLabel}v${data.firmwareVersion}` + : '-', + firmwareTypeLabel, + addWallpaperTitleId, + }; + } + const features = device.featuresInfo; + if (!features) { + return undefined; + } const versions = isThirdParty ? thirdPartyDeviceUtils.getDeviceVersion({ device, @@ -63,8 +108,9 @@ async function buildDeviceMetaStatic( firmwareType, displayFormat: 'withSpace', }); - const firmwareVersionDisplay = versions?.firmwareVersion - ? `${firmwareTypeLabel}v${versions.firmwareVersion}` + const firmwareVersion = versions?.firmwareVersion; + const firmwareVersionDisplay = firmwareVersion + ? `${firmwareTypeLabel}v${firmwareVersion}` : '-'; const deviceName = isThirdParty @@ -73,15 +119,20 @@ async function buildDeviceMetaStatic( features, defaultDeviceName: vendorProfile.defaultDeviceName, }) - : deviceUtils.buildDeviceBleName({ + : await deviceUtils.buildDeviceName({ + device, features, }); return { deviceName, + bleName: isThirdParty + ? undefined + : deviceUtils.buildDeviceBleName({ features }), + serialNo: device.uuid, deviceType, firmwareType, - firmwareVersion: versions?.firmwareVersion ?? '0.0.0', + firmwareVersion: firmwareVersion ?? '0.0.0', firmwareVersionDisplay, firmwareTypeLabel, addWallpaperTitleId: deviceUtils.isTouchDevice(deviceType) @@ -92,29 +143,56 @@ async function buildDeviceMetaStatic( async function buildDeviceMetaState( walletWithDevice?: IHwQrWalletWithDevice, + stateSnapshot?: IDeviceStateSnapshot, ): Promise { - if (!walletWithDevice?.device?.featuresInfo) { + if (!walletWithDevice?.device) { return undefined; } const { device } = walletWithDevice; + const vendorProfile = getVendorProfile( + device.vendor ?? EHardwareVendor.onekey, + ); + if (!vendorProfile.isThirdParty) { + const state = resolveDeviceState({ + persistedState: device.deviceStateInfo, + snapshot: stateSnapshot, + }); + if (state) { + return buildDeviceMetaStateFromState({ + isVerified: Boolean(device.verifiedAtVersion), + pinOnAppEnabled: isProtocolV2ProductType(device.deviceType) + ? undefined + : Boolean(device.settings?.inputPinOnSoftware), + state, + }); + } + } const features = device.featuresInfo; if (!features) { return undefined; } + const thirdPartyState = thirdPartyDeviceUtils.getDeviceState({ + features: features as Record, + }); const isVerified = Boolean(device.verifiedAtVersion); - const autoLockDelayMs = features.auto_lock_delay_ms ?? 0; - const autoShutDownDelayMs = features.auto_shutdown_delay_ms ?? 0; + const autoLockDelayMs = thirdPartyState.autoLockDelayMs ?? 0; + const autoShutDownDelayMs = thirdPartyState.autoShutDownDelayMs ?? 0; const language = features.language ?? undefined; - const hapticFeedback = features.haptic_feedback ?? false; + const hapticFeedback = thirdPartyState.hapticFeedback ?? false; return { isVerified, - passphraseEnabled: Boolean(features?.passphrase_protection), + unlocked: thirdPartyState.unlocked !== false, + initialized: thirdPartyState.initialized !== false, + backupRequired: Boolean(features.backupRequired), + unlockedByAttachToPin: false, + passphraseEnabled: Boolean(thirdPartyState.passphraseProtection), pinOnAppEnabled: Boolean(device.settings?.inputPinOnSoftware), autoLockDelayMs, autoShutDownDelayMs, language, + brightness: undefined, hapticFeedback, isReady: true, }; @@ -124,7 +202,10 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { updateDeviceMetaStatic = contextAtomMethod( async (get, set, walletId?: string) => { const data = get(walletWithDeviceStateAtom()); - const metaStatic = await buildDeviceMetaStatic(data); + const metaStatic = await buildDeviceMetaStatic( + data, + get(deviceStateSnapshotAtom()), + ); // Superseded by a newer device switch during the await — drop this write. if (walletId && get(currentWalletIdAtom()) !== walletId) return; if (metaStatic) { @@ -136,7 +217,10 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { updateDeviceMetaState = contextAtomMethod( async (get, set, walletId?: string) => { const data = get(walletWithDeviceStateAtom()); - const metaState = await buildDeviceMetaState(data); + const metaState = await buildDeviceMetaState( + data, + get(deviceStateSnapshotAtom()), + ); if (walletId && get(currentWalletIdAtom()) !== walletId) return; if (metaState) { set(deviceMetaStateAtom(), metaState); @@ -144,42 +228,141 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { }, ); - refresh = contextAtomMethod(async (get, set, incomingWalletId?: string) => { - const walletId = incomingWalletId ?? get(currentWalletIdAtom()); - if (!walletId) return; + applyDeviceStateEvent = contextAtomMethod( + async (get, set, event: DeviceStateEvent) => { + const data = get(walletWithDeviceStateAtom()); + const vendorProfile = getVendorProfile( + data?.device?.vendor ?? EHardwareVendor.onekey, + ); + if (vendorProfile.isThirdParty) { + return false; + } + const currentState = resolveDeviceState({ + persistedState: data?.device?.deviceStateInfo, + snapshot: get(deviceStateSnapshotAtom()), + }); + const snapshot = getDeviceStateSnapshotFromEvent({ + device: data?.device, + currentState, + event, + }); + // Evidence for silently dropped events (OK-60121): the page shows + // stale settings exactly when this reports applied=false. + defaultLogger.hardware.sdkLog.serviceEvent('applyDeviceStateEvent', { + applied: Boolean(snapshot), + source: event.source, + revision: event.state?.revision, + eventUpdatedAt: event.state?.updatedAt, + currentUpdatedAt: currentState?.updatedAt, + currentRevision: currentState?.revision, + language: event.state?.settings?.language, + }); + if (!snapshot) { + return false; + } + set(deviceStateSnapshotAtom(), snapshot); + const walletId = get(currentWalletIdAtom()); + await this.updateDeviceMetaStatic.call(set, walletId); + await this.updateDeviceMetaState.call(set, walletId); + return true; + }, + ); - // Device switched: reset header state so the skeleton re-engages. - if (walletId !== get(currentWalletIdAtom())) { - set(currentWalletIdAtom(), walletId); - set(walletWithDeviceStateAtom(), undefined); - set(deviceMetaStaticAtom(), emptyMetaStatic); - set(deviceMetaStateAtom(), emptyMetaState); - set(refreshSettledAtom(), false); - } + refresh = contextAtomMethod( + async ( + get, + set, + incomingWalletId?: string, + options?: { + refreshFirmwareInfo?: boolean; + skipDeviceStateSnapshot?: boolean; + }, + ) => { + const walletId = incomingWalletId ?? get(currentWalletIdAtom()); + if (!walletId) return; - try { - const r = - await backgroundApiProxy.serviceAccount.getAllHwQrWalletWithDevice({ - filterHiddenWallet: true, - }); + // Device switched: reset header state so the skeleton re-engages. + if (walletId !== get(currentWalletIdAtom())) { + set(currentWalletIdAtom(), walletId); + set(walletWithDeviceStateAtom(), undefined); + set(deviceStateSnapshotAtom(), undefined); + set(deviceMetaStaticAtom(), emptyMetaStatic); + set(deviceMetaStateAtom(), emptyMetaState); + set(refreshSettledAtom(), false); + } - const data = r?.[walletId]; - // Drop a superseded response (device switched mid-flight). - if (get(currentWalletIdAtom()) !== walletId) { + try { + const r = + await backgroundApiProxy.serviceAccount.getAllHwQrWalletWithDevice({ + filterHiddenWallet: true, + }); + + const data = resolveUsableWalletWithDevice(r?.[walletId]); + // Drop a superseded response (device switched mid-flight). + if (get(currentWalletIdAtom()) !== walletId) { + return data; + } + set(currentWalletIdAtom(), walletId); + set(walletWithDeviceStateAtom(), data); + if (!data) { + set(deviceStateSnapshotAtom(), undefined); + set(deviceMetaStaticAtom(), emptyMetaStatic); + set(deviceMetaStateAtom(), emptyMetaState); + return undefined; + } + const vendorProfile = getVendorProfile( + data?.device?.vendor ?? EHardwareVendor.onekey, + ); + if ( + data?.device?.connectId && + !vendorProfile.isThirdParty && + !options?.skipDeviceStateSnapshot + ) { + const snapshot = await backgroundApiProxy.serviceHardware + .getDeviceManagementSnapshot({ + connectId: data.device.connectId, + refreshInfo: options?.refreshFirmwareInfo, + }) + .catch(() => undefined); + if (get(currentWalletIdAtom()) !== walletId) { + return data; + } + set( + deviceStateSnapshotAtom(), + pickNewerDeviceStateSnapshot({ + current: get(deviceStateSnapshotAtom()), + // When the live read fails (e.g. the device reboots right + // after a firmware update), fall back to the persisted state + // so a retained snapshot cannot outlive newer DB data. + incoming: + snapshot ?? + (data?.device?.deviceStateInfo + ? { state: data.device.deviceStateInfo } + : undefined), + }), + ); + } else if (!vendorProfile.isThirdParty) { + set( + deviceStateSnapshotAtom(), + pickNewerDeviceStateSnapshot({ + current: get(deviceStateSnapshotAtom()), + incoming: data?.device?.deviceStateInfo + ? { state: data.device.deviceStateInfo } + : undefined, + }), + ); + } + await this.updateDeviceMetaStatic.call(set, walletId); + await this.updateDeviceMetaState.call(set, walletId); return data; + } finally { + // Don't mark settled if a newer refresh already took over. + if (get(currentWalletIdAtom()) === walletId) { + set(refreshSettledAtom(), true); + } } - set(currentWalletIdAtom(), walletId); - set(walletWithDeviceStateAtom(), data); - await this.updateDeviceMetaStatic.call(set, walletId); - await this.updateDeviceMetaState.call(set, walletId); - return data; - } finally { - // Don't mark settled if a newer refresh already took over. - if (get(currentWalletIdAtom()) === walletId) { - set(refreshSettledAtom(), true); - } - } - }); + }, + ); getCurrentWalletId = contextAtomMethod(async (get) => { return get(currentWalletIdAtom()); @@ -197,6 +380,28 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { return get(deviceMetaStateAtom()); }); + updateDeviceSettingState = contextAtomMethod( + async (get, set, next: Partial) => { + set( + deviceMetaStateAtom(), + mergeDeviceSettingState(get(deviceMetaStateAtom()), next), + ); + }, + ); + + syncDeviceSettingStateAfterMutation = contextAtomMethod( + async (get, set, next: Partial) => { + const walletId = get(currentWalletIdAtom()); + if (!walletId) return; + + const deviceType = get(walletWithDeviceStateAtom())?.device?.deviceType; + if (!shouldApplyDeviceSettingMutationLocally(deviceType, next)) { + return; + } + await this.updateDeviceSettingState.call(set, next); + }, + ); + updateLanguage = contextAtomMethod(async (get, set, value: string) => { const walletId = get(currentWalletIdAtom()); if (!walletId) return; @@ -205,15 +410,23 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, language: value, }); + await this.syncDeviceSettingStateAfterMutation.call(set, { + language: value, + }); }); - updateBrightness = contextAtomMethod(async (get, _set) => { + updateBrightness = contextAtomMethod(async (get, set, value?: number) => { const walletId = get(currentWalletIdAtom()); if (!walletId) return; await backgroundApiProxy.serviceHardware.setBrightness({ walletId, + brightness: value, }); + await this.syncDeviceSettingStateAfterMutation.call( + set, + typeof value === 'number' ? { brightness: value } : {}, + ); }); updateHapticFeedback = contextAtomMethod(async (get, set, value: boolean) => { @@ -224,6 +437,9 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, hapticFeedback: value, }); + await this.syncDeviceSettingStateAfterMutation.call(set, { + hapticFeedback: value, + }); }); updateAutoLockDelayMs = contextAtomMethod(async (get, set, value: number) => { @@ -234,7 +450,9 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, autoLockDelayMs: value, }); - await this.refresh.call(set); + await this.syncDeviceSettingStateAfterMutation.call(set, { + autoLockDelayMs: value, + }); }); updateAutoShutDownDelayMs = contextAtomMethod( @@ -246,7 +464,9 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, autoShutdownDelayMs: value, }); - await this.refresh.call(set); + await this.syncDeviceSettingStateAfterMutation.call(set, { + autoShutDownDelayMs: value, + }); }, ); @@ -259,7 +479,9 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, passphraseEnabled: value, }); - await this.refresh.call(set); + await this.syncDeviceSettingStateAfterMutation.call(set, { + passphraseEnabled: value, + }); }, ); @@ -272,7 +494,9 @@ class DeviceDetailsActions extends ContextJotaiActionsBase { walletId, inputPinOnSoftware: value, }); - await this.refresh.call(set); + await this.syncDeviceSettingStateAfterMutation.call(set, { + pinOnAppEnabled: value, + }); }, ); } @@ -281,6 +505,7 @@ const createActions = memoFn(() => new DeviceDetailsActions()); export function useDeviceDetailsActions() { const actions = createActions(); + const applyDeviceStateEvent = actions.applyDeviceStateEvent.use(); const refresh = actions.refresh.use(); const updateDeviceMetaState = actions.updateDeviceMetaState.use(); const getWalletWithDevice = actions.getWalletWithDevice.use(); @@ -296,6 +521,7 @@ export function useDeviceDetailsActions() { const updateInputPinOnSoftware = actions.updateInputPinOnSoftware.use(); return { + applyDeviceStateEvent, refresh, getCurrentWalletId, updateDeviceMetaState, diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/atoms.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/atoms.ts index 33e04e026929..5488956a92a4 100644 --- a/packages/kit/src/states/jotai/contexts/deviceDetails/atoms.ts +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/atoms.ts @@ -3,6 +3,7 @@ import { ETranslations } from '@onekeyhq/shared/src/locale'; import type deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; import type { IHwQrWalletWithDevice } from '@onekeyhq/shared/types/account'; +import type { IDeviceStateSnapshot } from './deviceStateManagement'; import type { EDeviceType } from '@onekeyfe/hd-shared'; const { @@ -27,6 +28,11 @@ export const { use: useWalletWithDeviceStateAtom, } = contextAtom(undefined); +export const { + atom: deviceStateSnapshotAtom, + use: useDeviceStateSnapshotAtom, +} = contextAtom(undefined); + // True once the first refresh settles; distinguishes "still loading" from // "loaded, no device" for the header skeleton gate. export const { atom: refreshSettledAtom, use: useRefreshSettledAtom } = @@ -43,6 +49,8 @@ export const { atom: deviceAtom, use: useDeviceAtom } = contextAtomComputed( export type IDeviceMetaStatic = { deviceName?: string; + bleName?: string; + serialNo?: string; deviceType?: EDeviceType; firmwareType?: Awaited>; firmwareVersion: string; @@ -53,6 +61,8 @@ export type IDeviceMetaStatic = { export const emptyMetaStatic: IDeviceMetaStatic = { deviceName: undefined, + bleName: undefined, + serialNo: undefined, deviceType: undefined, firmwareType: undefined, firmwareVersion: '0.0.0', @@ -66,23 +76,33 @@ export const { atom: deviceMetaStaticAtom, use: useDeviceMetaStaticAtom } = export type IDeviceMetaState = { isVerified: boolean; - passphraseEnabled: boolean; - pinOnAppEnabled: boolean; + unlocked: boolean | undefined; + initialized: boolean | undefined; + backupRequired: boolean | undefined; + unlockedByAttachToPin: boolean | undefined; + passphraseEnabled: boolean | undefined; + pinOnAppEnabled: boolean | undefined; autoLockDelayMs: number | undefined; autoShutDownDelayMs: number | undefined; language: string | undefined; - hapticFeedback: boolean; + brightness: number | undefined; + hapticFeedback: boolean | undefined; /** false = still the loading placeholder; true = real device data resolved */ isReady: boolean; }; export const emptyMetaState: IDeviceMetaState = { isVerified: false, + unlocked: false, + initialized: false, + backupRequired: false, + unlockedByAttachToPin: false, passphraseEnabled: false, pinOnAppEnabled: false, autoLockDelayMs: undefined, autoShutDownDelayMs: undefined, language: undefined, + brightness: undefined, hapticFeedback: false, isReady: false, }; @@ -111,11 +131,19 @@ export const { export const { atom: deviceLanguageAtom, use: useDeviceLanguageAtom } = contextAtomComputed((get) => get(deviceMetaStateAtom())?.language); +export const { atom: deviceBrightnessAtom, use: useDeviceBrightnessAtom } = + contextAtomComputed((get) => get(deviceMetaStateAtom())?.brightness); + export const { atom: deviceHapticFeedbackAtom, use: useDeviceHapticFeedbackAtom, } = contextAtomComputed((get) => get(deviceMetaStateAtom())?.hapticFeedback); +export const { + atom: deviceSettingsAccessibleAtom, + use: useDeviceSettingsAccessibleAtom, +} = contextAtomComputed((get) => get(deviceMetaStateAtom())?.unlocked); + export const { atom: devicePassphraseEnabledAtom, use: useDevicePassphraseEnabledAtom, diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts new file mode 100644 index 000000000000..91cc49e3659f --- /dev/null +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts @@ -0,0 +1,722 @@ +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; + +import { emptyMetaState } from './atoms'; +import { + buildDeviceMetaStateFromState, + getDeviceMetaStaticDataFromState, + getDeviceSecondaryIdentifier, + getDeviceStateSnapshotFromEvent, + isDeviceManagementWalletUsable, + mergeDeviceSettingState, + pickNewerDeviceStateSnapshot, + resolveDeviceState, + resolveDeviceWithCurrentType, + resolveUsableWalletWithDevice, + shouldApplyDeviceSettingMutationLocally, +} from './deviceStateManagement'; + +describe('device reset wallet isolation', () => { + const firmwareTypeSwitchDeviceTypes = [ + EDeviceType.Classic1s, + EDeviceType.ClassicPure, + EDeviceType.Pro, + ]; + + const unsupportedDeviceTypes = [ + EDeviceType.Classic, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Pro2, + EDeviceType.Neo, + ]; + + it('does not expose a deprecated hardware wallet to device details', () => { + expect( + resolveUsableWalletWithDevice({ + wallet: { + id: 'hw-wallet-1', + deprecated: true, + firmwareTypeAtCreated: EFirmwareType.BitcoinOnly, + }, + device: { + id: 'old-device-1', + featuresInfo: { + $app_firmware_type: EFirmwareType.BitcoinOnly, + }, + }, + } as never), + ).toBeUndefined(); + }); + + it('keeps an active mocked standard wallet as the hidden-only device proxy', () => { + const walletWithDevice = { + wallet: { + id: 'hw-wallet-1', + associatedDevice: 'device-1', + deprecated: false, + isMocked: true, + }, + device: { + id: 'device-1', + }, + }; + + expect(isDeviceManagementWalletUsable(walletWithDevice as never)).toBe( + true, + ); + expect(resolveUsableWalletWithDevice(walletWithDevice as never)).toBe( + walletWithDevice, + ); + }); + + it('does not expose a deprecated mocked wallet after a firmware switch', () => { + expect( + resolveUsableWalletWithDevice({ + wallet: { + id: 'hw-wallet-1', + deprecated: true, + isMocked: true, + firmwareTypeAtCreated: EFirmwareType.Universal, + }, + device: { + id: 'device-1', + deviceType: EDeviceType.Pro, + featuresInfo: { + $app_firmware_type: EFirmwareType.BitcoinOnly, + }, + }, + } as never), + ).toBeUndefined(); + }); + + it.each(firmwareTypeSwitchDeviceTypes)( + 'does not expose a deprecated %s wallet after switching firmware type', + (deviceType) => { + const walletWithDevice = { + wallet: { + id: `hw-wallet-${deviceType}`, + deprecated: true, + firmwareTypeAtCreated: EFirmwareType.Universal, + }, + device: { + id: `device-${deviceType}`, + deviceType, + featuresInfo: { + deviceType, + $app_firmware_type: EFirmwareType.BitcoinOnly, + }, + }, + }; + + expect( + resolveUsableWalletWithDevice(walletWithDevice as never), + ).toBeUndefined(); + }, + ); + + it.each(unsupportedDeviceTypes)( + 'does not expose a deprecated %s wallet without a firmware type switch action', + (deviceType) => { + const walletWithDevice = { + wallet: { + id: `hw-wallet-${deviceType}`, + deprecated: true, + firmwareTypeAtCreated: EFirmwareType.Universal, + }, + device: { + id: `device-${deviceType}`, + deviceType, + deviceStateInfo: { + identity: { + deviceType, + firmwareType: EFirmwareType.BitcoinOnly, + }, + }, + }, + }; + + expect( + resolveUsableWalletWithDevice(walletWithDevice as never), + ).toBeUndefined(); + }, + ); + + it('does not expose a deprecated Bitcoin-only wallet after switching back to Universal firmware', () => { + const walletWithDevice = { + wallet: { + id: 'hw-wallet-1', + deprecated: true, + firmwareTypeAtCreated: EFirmwareType.BitcoinOnly, + }, + device: { + id: 'device-1', + deviceType: EDeviceType.Pro, + featuresInfo: { + $app_firmware_type: EFirmwareType.Universal, + }, + }, + }; + + expect( + resolveUsableWalletWithDevice(walletWithDevice as never), + ).toBeUndefined(); + }); + + it('does not revive a deprecated Protocol V1 wallet from normalized firmwareType', () => { + const walletWithDevice = { + wallet: { + id: 'legacy-classic1s-wallet', + deprecated: true, + firmwareTypeAtCreated: EFirmwareType.Universal, + }, + device: { + id: 'legacy-classic1s-device', + deviceType: EDeviceType.Classic1s, + featuresInfo: { + deviceType: EDeviceType.Classic1s, + firmwareType: EFirmwareType.BitcoinOnly, + }, + }, + }; + + expect( + resolveUsableWalletWithDevice(walletWithDevice as never), + ).toBeUndefined(); + }); + + it('does not expose a deprecated legacy wallet without firmwareTypeAtCreated', () => { + const walletWithDevice = { + wallet: { + id: 'legacy-hw-wallet-1', + deprecated: true, + }, + device: { + id: 'device-1', + deviceType: EDeviceType.Pro, + deviceStateInfo: { + identity: { + firmwareType: EFirmwareType.BitcoinOnly, + }, + }, + }, + }; + + expect( + resolveUsableWalletWithDevice(walletWithDevice as never), + ).toBeUndefined(); + }); +}); + +describe('device details navigation state', () => { + it('uses the current DeviceState model when the database device is stale', () => { + const staleDevice = { + id: 'db-device-1', + deviceType: EDeviceType.Unknown, + }; + + expect(resolveDeviceWithCurrentType(staleDevice, EDeviceType.Pro2)).toEqual( + { + id: 'db-device-1', + deviceType: EDeviceType.Pro2, + }, + ); + }); +}); + +describe('device setting state updates', () => { + it('only applies a confirmed passphrase mutation locally for Pro2', () => { + expect(shouldApplyDeviceSettingMutationLocally(EDeviceType.Pro2)).toBe( + false, + ); + expect( + shouldApplyDeviceSettingMutationLocally(EDeviceType.Pro2, { + passphraseEnabled: false, + }), + ).toBe(true); + expect( + shouldApplyDeviceSettingMutationLocally(EDeviceType.Pro2, { + hapticFeedback: true, + }), + ).toBe(false); + expect(shouldApplyDeviceSettingMutationLocally(EDeviceType.Pro)).toBe(true); + }); + + it('applies confirmed haptic feedback changes in both directions', () => { + const enabled = mergeDeviceSettingState( + { ...emptyMetaState, hapticFeedback: false }, + { hapticFeedback: true }, + ); + const disabled = mergeDeviceSettingState(enabled, { + hapticFeedback: false, + }); + + expect(enabled.hapticFeedback).toBe(true); + expect(disabled.hapticFeedback).toBe(false); + }); + + it('keeps brightness when only auto-lock changes', () => { + const current = { + ...emptyMetaState, + brightness: 43, + autoLockDelayMs: 30_000, + }; + + expect( + mergeDeviceSettingState(current, { autoLockDelayMs: 60_000 }), + ).toEqual({ + ...emptyMetaState, + brightness: 43, + autoLockDelayMs: 60_000, + }); + }); +}); + +describe('getDeviceStateSnapshotFromEvent', () => { + it.each(['classic1s', 'touch', 'pro', 'pro2'])( + 'accepts a matching %s DeviceState event', + (deviceType) => { + const state = { + identity: { + deviceType, + serialNo: `${deviceType}_SERIAL`, + label: `Renamed ${deviceType}`, + }, + }; + + expect( + getDeviceStateSnapshotFromEvent({ + device: { + deviceType, + connectId: `${deviceType}_USB`, + uuid: `${deviceType}_SERIAL`, + }, + event: { + connectId: `${deviceType}_USB`, + state, + }, + } as never), + ).toEqual({ state }); + }, + ); + + it('ignores an event from another device', () => { + expect( + getDeviceStateSnapshotFromEvent({ + device: { connectId: 'CURRENT' }, + event: { + connectId: 'OTHER', + state: { identity: { serialNo: 'OTHER_SERIAL' } }, + }, + } as never), + ).toBeUndefined(); + }); + + it('rejects a reused connect id when the serial number belongs to another device', () => { + expect( + getDeviceStateSnapshotFromEvent({ + device: { connectId: 'REUSED', uuid: 'SERIAL-CURRENT' }, + event: { + connectId: 'REUSED', + state: { identity: { serialNo: 'SERIAL-OTHER' } }, + }, + } as never), + ).toBeUndefined(); + }); + + it('merges only changed fields and preserves trusted runtime state', () => { + const currentState = { + revision: 3, + updatedAt: 300, + identity: { + deviceId: 'DEVICE_ID', + serialNo: 'SERIAL', + label: 'Desk wallet', + bleName: 'Pro2 6136', + }, + status: { mode: 'normal', unlocked: false }, + settings: { language: 'en-US' }, + versions: { firmware: '1.0.0' }, + }; + const snapshot = getDeviceStateSnapshotFromEvent({ + device: { + connectId: 'PRO2_USB', + uuid: 'SERIAL', + deviceId: 'DEVICE_ID', + }, + currentState, + event: { + connectId: 'PRO2_USB', + revision: 4, + changedKeys: ['identity.bleName'], + state: { + ...currentState, + revision: 4, + updatedAt: 400, + identity: { + ...currentState.identity, + deviceId: null, + label: null, + bleName: 'Pro2 9999', + }, + status: { mode: 'normal', unlocked: null }, + settings: { language: null }, + }, + }, + } as never); + + expect(snapshot?.state.identity).toMatchObject({ + deviceId: 'DEVICE_ID', + label: 'Desk wallet', + bleName: 'Pro2 9999', + }); + expect(snapshot?.state.status.unlocked).toBe(false); + expect(snapshot?.state.settings.language).toBe('en-US'); + }); + + it('refreshes all device settings in the page after a settings read', () => { + const currentState = { + revision: 3, + updatedAt: 300, + identity: { deviceId: 'DEVICE_ID', serialNo: 'SERIAL' }, + status: { mode: 'normal', unlocked: false }, + settings: { brightness: 30, autoLockDelayMs: 60_000 }, + versions: { firmware: '1.0.0' }, + }; + + const snapshot = getDeviceStateSnapshotFromEvent({ + device: { + connectId: 'PRO2_USB', + uuid: 'SERIAL', + deviceId: 'DEVICE_ID', + }, + currentState, + event: { + connectId: 'PRO2_USB', + revision: 4, + source: 'settings-read', + changedKeys: ['settings.brightness'], + state: { + ...currentState, + revision: 4, + updatedAt: 400, + status: { mode: 'normal', unlocked: true }, + settings: { brightness: 70, autoLockDelayMs: 300_000 }, + }, + }, + } as never); + + expect(snapshot?.state.settings).toMatchObject({ + brightness: 70, + autoLockDelayMs: 300_000, + }); + expect(snapshot?.state.status.unlocked).toBe(false); + }); + + it('applies a force-emitted settings read whose store revision did not change', () => { + // On Protocol V1 the SDK cache can learn a device-side change (e.g. + // language) before app listeners attach. The follow-up settings read then + // finds nothing new, so the SDK force-emits with the OLD revision and + // updatedAt. The event must still be applied. + const currentState = { + revision: 4, + updatedAt: 400, + identity: { deviceId: 'DEVICE_ID', serialNo: 'SERIAL' }, + status: { mode: 'normal', unlocked: true }, + settings: { language: 'zh_cn', brightness: 30 }, + versions: { firmware: '1.0.0' }, + }; + + const snapshot = getDeviceStateSnapshotFromEvent({ + device: { + connectId: 'PRO_BLE', + uuid: 'SERIAL', + deviceId: 'DEVICE_ID', + }, + currentState, + event: { + connectId: 'PRO_BLE', + revision: 4, + source: 'settings-read', + changedKeys: ['settings'], + state: { + ...currentState, + settings: { language: 'zh_hk', brightness: 30 }, + }, + }, + } as never); + + expect(snapshot?.state.settings.language).toBe('zh_hk'); + }); + + it('drops a same-timestamp settings read with a lower revision', () => { + const currentState = { + revision: 5, + updatedAt: 400, + identity: { deviceId: 'DEVICE_ID', serialNo: 'SERIAL' }, + status: { mode: 'normal' }, + settings: { language: 'zh_hk' }, + versions: { firmware: '1.0.0' }, + }; + + expect( + getDeviceStateSnapshotFromEvent({ + device: { connectId: 'PRO_BLE', uuid: 'SERIAL' }, + currentState, + event: { + connectId: 'PRO_BLE', + revision: 4, + source: 'settings-read', + changedKeys: ['settings'], + state: { + ...currentState, + revision: 4, + settings: { language: 'zh_cn' }, + }, + }, + } as never), + ).toBeUndefined(); + }); + + it('still drops a settings read strictly older than the current state', () => { + const currentState = { + revision: 4, + updatedAt: 400, + identity: { deviceId: 'DEVICE_ID', serialNo: 'SERIAL' }, + status: { mode: 'normal' }, + settings: { language: 'zh_hk' }, + versions: { firmware: '1.0.0' }, + }; + + expect( + getDeviceStateSnapshotFromEvent({ + device: { connectId: 'PRO_BLE', uuid: 'SERIAL' }, + currentState, + event: { + connectId: 'PRO_BLE', + revision: 3, + source: 'settings-read', + changedKeys: ['settings'], + state: { + ...currentState, + revision: 3, + updatedAt: 300, + settings: { language: 'zh_cn' }, + }, + }, + } as never), + ).toBeUndefined(); + }); + + it('keeps dropping equal-revision events from non-authoritative sources', () => { + const currentState = { + revision: 4, + updatedAt: 400, + identity: { deviceId: 'DEVICE_ID', serialNo: 'SERIAL' }, + status: { mode: 'normal' }, + settings: { language: 'zh_cn' }, + versions: { firmware: '1.0.0' }, + }; + + expect( + getDeviceStateSnapshotFromEvent({ + device: { connectId: 'PRO_BLE', uuid: 'SERIAL' }, + currentState, + event: { + connectId: 'PRO_BLE', + revision: 4, + source: 'initialize', + changedKeys: ['settings.language'], + state: { + ...currentState, + settings: { language: 'zh_hk' }, + }, + }, + } as never), + ).toBeUndefined(); + }); + + it('rejects a new wallet identity even when the physical serial still matches', () => { + expect( + getDeviceStateSnapshotFromEvent({ + device: { + connectId: 'PRO2_USB', + uuid: 'SERIAL', + deviceId: 'OLD_DEVICE_ID', + }, + currentState: { + identity: { + serialNo: 'SERIAL', + deviceId: 'OLD_DEVICE_ID', + }, + }, + event: { + connectId: 'PRO2_USB', + changedKeys: ['identity.deviceId'], + state: { + identity: { + serialNo: 'SERIAL', + deviceId: 'NEW_DEVICE_ID', + }, + }, + }, + } as never), + ).toBeUndefined(); + }); +}); + +describe('DeviceState metadata projection', () => { + it('uses the canonical device label when the persisted state has no displayName', () => { + expect( + getDeviceMetaStaticDataFromState({ + identity: { + deviceType: 'pro2', + firmwareType: 'universal', + model: 'pro2', + vendor: 'onekey.so', + deviceId: '6C9F1443AF7400512AD1AD8D', + serialNo: 'PR9999999999', + label: 'My OneKey', + bleName: 'Pro2 6136', + }, + versions: { firmware: '1.0.0' }, + } as never), + ).toEqual({ + deviceName: 'My OneKey', + bleName: 'Pro2 6136', + serialNo: 'PR9999999999', + deviceType: 'pro2', + firmwareType: 'universal', + firmwareVersion: '1.0.0', + }); + }); + + it('uses the BLE name as the Pro2 secondary identifier', () => { + expect( + getDeviceSecondaryIdentifier({ + deviceType: EDeviceType.Pro2, + bleName: 'Pro2 6136', + serialNo: 'P2D33C0005B', + }), + ).toBe('Pro2 6136'); + + expect( + getDeviceSecondaryIdentifier({ + deviceType: EDeviceType.Pro2, + bleName: '', + serialNo: 'P2D33C0005B', + }), + ).toBe('P2D33C0005B'); + + expect( + getDeviceSecondaryIdentifier({ + deviceType: EDeviceType.Pro, + bleName: 'Pro 6136', + serialNo: 'SERIAL', + }), + ).toBe('SERIAL'); + }); + + it('uses canonical state fields while retaining the V1 software-PIN preference', () => { + expect( + buildDeviceMetaStateFromState({ + isVerified: true, + pinOnAppEnabled: true, + state: { + status: { + unlocked: true, + initialized: true, + backupRequired: false, + passphraseProtection: true, + }, + settings: { + language: 'en-US', + autoLockDelayMs: 60_000, + }, + } as never, + }), + ).toMatchObject({ + isVerified: true, + unlocked: true, + initialized: true, + backupRequired: false, + passphraseEnabled: true, + pinOnAppEnabled: true, + language: 'en-US', + autoLockDelayMs: 60_000, + isReady: true, + }); + }); + + it('uses the last trusted passphrase setting while Pro 2 is locked', () => { + expect( + buildDeviceMetaStateFromState({ + isVerified: true, + state: { + status: { + unlocked: false, + passphraseProtection: true, + }, + settings: {}, + } as never, + }), + ).toMatchObject({ + unlocked: false, + passphraseEnabled: true, + }); + }); + + it('prefers the newest event snapshot over persisted state', () => { + const persistedState = { revision: 1 }; + const snapshotState = { revision: 2 }; + + expect( + resolveDeviceState({ + persistedState, + snapshot: { state: snapshotState }, + } as never), + ).toBe(snapshotState); + }); +}); + +describe('pickNewerDeviceStateSnapshot', () => { + const buildSnapshot = (updatedAt: number, revision: number) => + ({ state: { updatedAt, revision } }) as never; + + it('keeps the applied event snapshot when a refresh serves older DB data', () => { + const current = buildSnapshot(400, 4); + const incoming = buildSnapshot(300, 3); + + expect(pickNewerDeviceStateSnapshot({ current, incoming })).toBe(current); + }); + + it('takes the incoming snapshot when it is newer', () => { + const current = buildSnapshot(300, 3); + const incoming = buildSnapshot(400, 4); + + expect(pickNewerDeviceStateSnapshot({ current, incoming })).toBe(incoming); + }); + + it('takes the incoming snapshot on equal stamps', () => { + const current = buildSnapshot(400, 4); + const incoming = buildSnapshot(400, 4); + + expect(pickNewerDeviceStateSnapshot({ current, incoming })).toBe(incoming); + }); + + it('never clears an existing snapshot with an empty refresh result', () => { + const current = buildSnapshot(400, 4); + + expect(pickNewerDeviceStateSnapshot({ current, incoming: undefined })).toBe( + current, + ); + expect( + pickNewerDeviceStateSnapshot({ current: undefined, incoming: current }), + ).toBe(current); + expect( + pickNewerDeviceStateSnapshot({ + current: undefined, + incoming: undefined, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts new file mode 100644 index 000000000000..cae4c1594948 --- /dev/null +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts @@ -0,0 +1,268 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { + hasDeviceStateIdentityMismatch, + mergeDeviceStateEvent, +} from '@onekeyhq/shared/src/hardware/deviceStateUtils'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; +import type { IHwQrWalletWithDevice } from '@onekeyhq/shared/types/account'; +import type { IOneKeyDeviceState } from '@onekeyhq/shared/types/device'; + +import type { IDeviceMetaState, IDeviceMetaStatic } from './atoms'; +import type { DeviceStateEvent } from '@onekeyfe/hd-core'; + +export type IDeviceStateSnapshot = { + state: IOneKeyDeviceState; +}; + +export function getDeviceStateSnapshotFromEvent({ + device, + currentState, + event, +}: { + device?: { + connectId?: string; + usbConnectId?: string; + bleConnectId?: string; + deviceId?: string; + uuid?: string; + }; + currentState?: IOneKeyDeviceState; + event: DeviceStateEvent; +}): IDeviceStateSnapshot | undefined { + if ( + currentState && + typeof currentState.updatedAt === 'number' && + typeof event.state.updatedAt === 'number' + ) { + // 'settings-read' events are authoritative hardware read-backs. When the + // SDK cache already holds a device-side change the app never observed + // (e.g. BLE initialize runs before event listeners attach), the SDK + // force-emits them without bumping revision/updatedAt, so an event with + // stamps EQUAL to the current state must still be applied. A lower + // revision at the same timestamp is still an out-of-order older event + // and must not roll the newer snapshot back. + const isStale = + event.source === 'settings-read' + ? event.state.updatedAt < currentState.updatedAt || + (event.state.updatedAt === currentState.updatedAt && + event.state.revision < currentState.revision) + : event.state.updatedAt < currentState.updatedAt || + (event.state.updatedAt === currentState.updatedAt && + event.state.revision <= currentState.revision); + if (isStale) { + return undefined; + } + } + const currentDeviceId = currentState?.identity.deviceId ?? device?.deviceId; + if ( + hasDeviceStateIdentityMismatch({ + currentDeviceId, + incomingDeviceId: event.state.identity.deviceId, + }) + ) { + return undefined; + } + const normalizedEventConnectId = event.connectId?.toLowerCase(); + const matchesConnectId = Boolean( + normalizedEventConnectId && + [device?.connectId, device?.usbConnectId, device?.bleConnectId].some( + (connectId) => connectId?.toLowerCase() === normalizedEventConnectId, + ), + ); + const matchesSerialNo = Boolean( + event.state.identity.serialNo && + device?.uuid === event.state.identity.serialNo, + ); + const matchesDeviceId = Boolean( + event.state.identity.deviceId && + device?.deviceId === event.state.identity.deviceId, + ); + if (event.state.identity.serialNo && device?.uuid) { + return matchesSerialNo + ? { + state: mergeDeviceStateEvent({ + currentState, + incomingState: event.state, + changedKeys: event.changedKeys ?? ['*'], + source: event.source, + }), + } + : undefined; + } + if (event.state.identity.deviceId && device?.deviceId) { + return matchesDeviceId + ? { + state: mergeDeviceStateEvent({ + currentState, + incomingState: event.state, + changedKeys: event.changedKeys ?? ['*'], + source: event.source, + }), + } + : undefined; + } + if (!matchesConnectId && !matchesSerialNo && !matchesDeviceId) { + return undefined; + } + return { + state: mergeDeviceStateEvent({ + currentState, + incomingState: event.state, + changedKeys: event.changedKeys ?? ['*'], + source: event.source, + }), + }; +} + +export function resolveDeviceState({ + persistedState, + snapshot, +}: { + persistedState?: IOneKeyDeviceState; + snapshot?: IDeviceStateSnapshot; +}) { + return snapshot?.state ?? persistedState; +} + +/** + * Refresh reads can be served from short-lived DB record caches that may + * predate the write which triggered the refresh. Never let such a read + * regress a snapshot that a newer device-state event already applied. + */ +export function pickNewerDeviceStateSnapshot({ + current, + incoming, +}: { + current?: IDeviceStateSnapshot; + incoming?: IDeviceStateSnapshot; +}): IDeviceStateSnapshot | undefined { + if (!current) { + return incoming; + } + if (!incoming) { + return current; + } + const currentUpdatedAt = + typeof current.state.updatedAt === 'number' ? current.state.updatedAt : 0; + const incomingUpdatedAt = + typeof incoming.state.updatedAt === 'number' ? incoming.state.updatedAt : 0; + if (incomingUpdatedAt !== currentUpdatedAt) { + return incomingUpdatedAt > currentUpdatedAt ? incoming : current; + } + return (incoming.state.revision ?? 0) >= (current.state.revision ?? 0) + ? incoming + : current; +} + +export function isDeviceManagementWalletUsable( + walletWithDevice?: IHwQrWalletWithDevice, +) { + const wallet = walletWithDevice?.wallet; + if (!wallet) { + return false; + } + // Hidden-only devices retain an active mocked standard wallet as their + // device-management proxy, while deprecated wallets stay hidden. + return !wallet.deprecated; +} + +export function resolveUsableWalletWithDevice( + walletWithDevice?: IHwQrWalletWithDevice, +) { + if (!isDeviceManagementWalletUsable(walletWithDevice)) { + return undefined; + } + return walletWithDevice; +} + +export function resolveDeviceWithCurrentType< + T extends { deviceType?: EDeviceType }, +>(device: T, currentDeviceType?: EDeviceType): T { + if (!currentDeviceType || device.deviceType === currentDeviceType) { + return device; + } + return { + ...device, + deviceType: currentDeviceType, + }; +} + +export function mergeDeviceSettingState( + current: IDeviceMetaState, + next: Partial, +): IDeviceMetaState { + return { + ...current, + ...next, + }; +} + +export function shouldApplyDeviceSettingMutationLocally( + deviceType?: EDeviceType, + next?: Partial, +) { + return ( + !isProtocolV2ProductType(deviceType) || + typeof next?.passphraseEnabled === 'boolean' + ); +} + +export function canEditPro2DeviceWideSettings({ + unlocked: _unlocked, +}: { + unlocked: boolean; +}) { + // Pro 2 device-wide settings remain available while the wallet is locked. + return true; +} + +export function getDeviceMetaStaticDataFromState(state: IOneKeyDeviceState) { + return { + deviceName: deviceUtils.getDeviceDisplayName({ state }), + bleName: state.identity.bleName ?? undefined, + serialNo: state.identity.serialNo ?? undefined, + deviceType: state.identity.deviceType, + firmwareType: state.identity.firmwareType, + firmwareVersion: state.versions.firmware ?? undefined, + }; +} + +export function getDeviceSecondaryIdentifier( + deviceMetaStatic: Pick< + IDeviceMetaStatic, + 'bleName' | 'deviceType' | 'serialNo' + >, +) { + return deviceMetaStatic.deviceType === EDeviceType.Pro2 + ? deviceMetaStatic.bleName || deviceMetaStatic.serialNo + : deviceMetaStatic.serialNo; +} + +export function buildDeviceMetaStateFromState({ + isVerified, + pinOnAppEnabled, + state, +}: { + isVerified: boolean; + pinOnAppEnabled?: boolean; + state: IOneKeyDeviceState; +}): IDeviceMetaState { + return { + isVerified, + unlocked: state.status.unlocked ?? undefined, + initialized: state.status.initialized ?? undefined, + backupRequired: state.status.backupRequired ?? undefined, + unlockedByAttachToPin: state.status.unlockedAttachPin ?? undefined, + passphraseEnabled: state.status.passphraseProtection ?? undefined, + pinOnAppEnabled: + pinOnAppEnabled ?? state.status.attachToPinEnabled ?? undefined, + autoLockDelayMs: state.settings.autoLockDelayMs ?? undefined, + autoShutDownDelayMs: state.settings.autoShutdownDelayMs ?? undefined, + language: state.settings.language ?? undefined, + brightness: state.settings.brightness ?? undefined, + hapticFeedback: state.settings.hapticFeedback ?? undefined, + isReady: true, + }; +} diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/index.tsx b/packages/kit/src/states/jotai/contexts/deviceDetails/index.tsx index d8b114231894..910fee6e5dab 100644 --- a/packages/kit/src/states/jotai/contexts/deviceDetails/index.tsx +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/index.tsx @@ -1,2 +1,7 @@ export * from './atoms'; export * from './actions'; +export { + canEditPro2DeviceWideSettings, + getDeviceSecondaryIdentifier, + resolveDeviceWithCurrentType, +} from './deviceStateManagement'; diff --git a/packages/kit/src/utils/loggerConfigUtils.ts b/packages/kit/src/utils/loggerConfigUtils.ts new file mode 100644 index 000000000000..282a237f8c83 --- /dev/null +++ b/packages/kit/src/utils/loggerConfigUtils.ts @@ -0,0 +1,15 @@ +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import type { ILoggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; +import { loggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; + +/** + * Persist a logger config to every runtime. On native, main and bg hold + * separate LoggerConfigManager singletons in isolated JS heaps: bg saves and + * persists first (it emits most logs), then the main-runtime singleton is + * mirrored without a second storage write. On single-context platforms both + * steps hit the same singleton, which is harmless. + */ +export async function saveLoggerConfigToAllRuntimes(config: ILoggerConfig) { + await backgroundApiProxy.serviceLogger.updateLoggerConfig(config); + loggerConfig.updateRuntimeConfig(config); +} diff --git a/packages/kit/src/utils/passphraseUtils.test.ts b/packages/kit/src/utils/passphraseUtils.test.ts index 0d66ca5e5da8..01c620ebf3d5 100644 --- a/packages/kit/src/utils/passphraseUtils.test.ts +++ b/packages/kit/src/utils/passphraseUtils.test.ts @@ -1,4 +1,7 @@ -import { isPassphraseValid } from './passphraseUtils'; +import { + isPassphraseValid, + normalizeProtocolV2Passphrase, +} from './passphraseUtils'; const passphraseTests = [ { @@ -124,4 +127,38 @@ describe('Passphrase Utils Tests', () => { expect(result).toBe(should); }); }); + + test('accepts Protocol V2 Unicode passphrases within the normalized UTF-8 byte limit', () => { + expect(isPassphraseValid('私の鍵', { allowProtocolV2Utf8: true })).toBe( + true, + ); + expect( + isPassphraseValid('😀'.repeat(12), { allowProtocolV2Utf8: true }), + ).toBe(true); + expect( + isPassphraseValid('😀'.repeat(13), { allowProtocolV2Utf8: true }), + ).toBe(false); + expect( + isPassphraseValid('a'.repeat(50), { allowProtocolV2Utf8: true }), + ).toBe(true); + expect( + isPassphraseValid('a'.repeat(51), { allowProtocolV2Utf8: true }), + ).toBe(false); + expect( + isPassphraseValid('\u00e9'.repeat(16), { allowProtocolV2Utf8: true }), + ).toBe(true); + expect( + isPassphraseValid('\u00e9'.repeat(17), { allowProtocolV2Utf8: true }), + ).toBe(false); + }); + + test('normalizes Protocol V2 passphrases and rejects NUL after normalization', () => { + expect(normalizeProtocolV2Passphrase('\u00e9')).toBe('e\u0301'); + expect( + isPassphraseValid('hidden\0wallet', { allowProtocolV2Utf8: true }), + ).toBe(false); + expect(isPassphraseValid('\ud800', { allowProtocolV2Utf8: true })).toBe( + false, + ); + }); }); diff --git a/packages/kit/src/utils/passphraseUtils.ts b/packages/kit/src/utils/passphraseUtils.ts index 9309e4ec4513..fdcc50870633 100644 --- a/packages/kit/src/utils/passphraseUtils.ts +++ b/packages/kit/src/utils/passphraseUtils.ts @@ -1,9 +1,34 @@ +export const normalizeProtocolV2Passphrase = (passphrase: string) => + passphrase.normalize('NFKD'); + +export const protocolV2Utf8ByteLength = (value: string) => { + let length = 0; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint >= 0xd8_00 && codePoint <= 0xdf_ff) + return Number.POSITIVE_INFINITY; + if (codePoint <= 0x7f) length += 1; + else if (codePoint <= 0x7_ff) length += 2; + else if (codePoint <= 0xff_ff) length += 3; + else length += 4; + } + return length; +}; + export const isPassphraseValid = ( passphrase: string, options?: { allowExtendedASCII?: boolean; + allowProtocolV2Utf8?: boolean; }, ): boolean => { + if (options?.allowProtocolV2Utf8) { + const normalized = normalizeProtocolV2Passphrase(passphrase); + return ( + !normalized.includes('\0') && protocolV2Utf8ByteLength(normalized) <= 50 + ); + } + let regExp = /^[\x20-\x7E]*$/; if (options?.allowExtendedASCII) { regExp = /^[\x20-\xFF]*$/; diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.test.ts b/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.test.ts index 950ffe54fe64..33c7298f47dd 100644 --- a/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.test.ts +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.test.ts @@ -1,11 +1,38 @@ import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { + resolveWalletPassphraseProtection, shouldShowAddHiddenWalletButtonForWallet, shouldShowCreateHiddenWalletSidebarButtonForWallet, shouldShowDeviceManagementButtonForWallet, } from './WalletEditButtonUtils'; +describe('resolveWalletPassphraseProtection', () => { + it('优先使用 Pro2 的 DeviceState 判断 Passphrase 已开启', () => { + expect( + resolveWalletPassphraseProtection({ + deviceState: { + status: { passphraseProtection: true }, + } as never, + features: { + passphraseProtection: false, + passphrase_protection: false, + } as never, + }), + ).toBe(true); + }); + + it('没有 DeviceState 时兼容 Pro 的旧 Features 字段', () => { + expect( + resolveWalletPassphraseProtection({ + features: { + passphrase_protection: true, + } as never, + }), + ).toBe(true); + }); +}); + describe('shouldShowAddHiddenWalletButtonForWallet', () => { it('allows Trezor hidden wallet creation because Trezor supports passphrase', () => { expect( @@ -27,6 +54,16 @@ describe('shouldShowAddHiddenWalletButtonForWallet', () => { ).toBe(false); }); + it('allows Pro2 hidden wallet creation', () => { + expect( + shouldShowAddHiddenWalletButtonForWallet({ + isHiddenWallet: false, + isHwOrQrWallet: true, + vendor: EHardwareVendor.onekey, + }), + ).toBe(true); + }); + it('allows the Trezor sidebar add-hidden entry when passphrase is enabled', () => { expect( shouldShowCreateHiddenWalletSidebarButtonForWallet({ diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.ts b/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.ts index e9516aa174cf..cf351c1298c2 100644 --- a/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.ts +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletEdit/WalletEditButtonUtils.ts @@ -1,5 +1,24 @@ +import { resolveHardwarePassphraseEnabled } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; -import type { EHardwareVendor } from '@onekeyhq/shared/types/device'; +import type { + EHardwareVendor, + IOneKeyDeviceFeatures, + IOneKeyDeviceState, +} from '@onekeyhq/shared/types/device'; + +export function resolveWalletPassphraseProtection({ + deviceState, + features, +}: { + deviceState?: IOneKeyDeviceState; + features?: IOneKeyDeviceFeatures; +}): boolean { + const canonicalValue = deviceState?.status.passphraseProtection; + if (typeof canonicalValue === 'boolean') { + return canonicalValue; + } + return features ? resolveHardwarePassphraseEnabled({ features }) : false; +} export function shouldShowAddHiddenWalletButtonForWallet(params: { isKeyless?: boolean; diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/HardwareLabelSetDialog.tsx b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/HardwareLabelSetDialog.tsx index 7ffd3d379353..00154d8911bd 100644 --- a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/HardwareLabelSetDialog.tsx +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/HardwareLabelSetDialog.tsx @@ -1,10 +1,15 @@ import { useState } from 'react'; -import emojiRegex from 'emoji-regex'; import { useIntl } from 'react-intl'; import type { IDialogShowProps } from '@onekeyhq/components'; -import { Dialog, Keyboard, Toast } from '@onekeyhq/components'; +import { + Dialog, + Keyboard, + Toast, + useDialogInstance, +} from '@onekeyhq/components'; +import { useFormWatch } from '@onekeyhq/components/src/hooks/useForm'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { RenameInputWithNameSelector } from '@onekeyhq/kit/src/components/RenameDialog'; import { MAX_LENGTH_HW_LABEL_NAME } from '@onekeyhq/kit/src/components/RenameDialog/renameConsts'; @@ -15,87 +20,149 @@ import { EChangeHistoryEntityType, } from '@onekeyhq/shared/src/types/changeHistory'; +import { AccountManagerTestIDs } from '../../testIDs'; + +import { getHardwareLabelValidationError } from './hardwareLabelValidation'; + import type { IntlShape } from 'react-intl'; +function DeviceLabelFormField(props: { + wallet: IDBWallet | undefined; + asciiOnly?: boolean; + asciiAlphanumericWithSpacesOnly?: boolean; +}) { + const intl = useIntl(); + const { wallet, asciiOnly, asciiAlphanumericWithSpacesOnly } = props; + const maxLength = MAX_LENGTH_HW_LABEL_NAME; + const labelValue = useFormWatch<{ name: string }>({ name: 'name' }) ?? ''; + const validationError = getHardwareLabelValidationError({ + value: labelValue, + maxLength, + asciiOnly, + asciiAlphanumericWithSpacesOnly, + }); + let validationErrorMessage: string | undefined; + if (!labelValue.trim()) { + validationErrorMessage = intl.formatMessage({ + id: ETranslations.form_rename_error_empty, + }); + } else if (validationError === 'tooLong') { + validationErrorMessage = intl.formatMessage({ + id: ETranslations.global_hardware_name_input_max, + }); + } else if (validationError === 'invalid') { + validationErrorMessage = intl.formatMessage({ + id: ETranslations.global_hardware_label_input_error, + }); + } + + return ( + <>} + label={intl.formatMessage({ + id: ETranslations.global_hardware_label_title, + })} + rules={{ + maxLength: { + value: maxLength, + message: 'Label is too long', + }, + validate: (value: string) => { + if (!value.trim()) { + return intl.formatMessage({ + id: ETranslations.form_rename_error_empty, + }); + } + const formValidationError = getHardwareLabelValidationError({ + value, + maxLength, + asciiOnly, + asciiAlphanumericWithSpacesOnly, + }); + if (formValidationError === 'tooLong') { + return intl.formatMessage({ + id: ETranslations.global_hardware_name_input_max, + }); + } + if (formValidationError === 'invalid') { + return intl.formatMessage({ + id: ETranslations.global_hardware_label_input_error, + }); + } + return true; + }, + required: { + value: true, + message: intl.formatMessage({ + id: ETranslations.form_rename_error_empty, + }), + }, + }} + > + + + ); +} + function DeviceLabelDialogContent(props: { wallet: IDBWallet | undefined; deviceLabel: string; asciiOnly?: boolean; + asciiAlphanumericWithSpacesOnly?: boolean; onSubmit: (name: string) => Promise; }) { const intl = useIntl(); + const dialog = useDialogInstance(); const [isLoading, setIsLoading] = useState(false); - const { wallet, deviceLabel, asciiOnly, onSubmit } = props; + const { + wallet, + deviceLabel, + asciiOnly, + asciiAlphanumericWithSpacesOnly, + onSubmit, + } = props; - const maxLength = MAX_LENGTH_HW_LABEL_NAME; return ( <> - - { - if (!value.length) return true; - - if (Buffer.from(value, 'utf-8').length > maxLength) { - return intl.formatMessage({ - id: ETranslations.global_hardware_name_input_max, - }); - } - - const regexRule = emojiRegex(); - if (regexRule.test(value)) { - return intl.formatMessage({ - id: ETranslations.global_hardware_label_input_error, - }); - } - - // Some devices (e.g. Trezor) can only store printable ASCII - // labels, so reject anything outside ASCII 32-126 (CJK, control - // chars, etc.) before writing it to the device. - if (asciiOnly && /[^\x20-\x7E]/.test(value)) { - return intl.formatMessage({ - id: ETranslations.global_hardware_label_input_error, - }); - } - }, - required: { - value: true, - message: intl.formatMessage({ - id: ETranslations.form_rename_error_empty, - }), - }, - }} - > - - + + { + Keyboard.dismiss(); + await dialog.close(); }} - onCancel={Keyboard.dismiss} onConfirm={async ({ getForm, close }) => { await Keyboard.dismissWithDelay(350); try { @@ -126,10 +193,12 @@ export const showLabelSetDialog = async ( wallet, intl, asciiOnly, + asciiAlphanumericWithSpacesOnly, }: { wallet: IDBWallet | undefined; intl: IntlShape; asciiOnly?: boolean; + asciiAlphanumericWithSpacesOnly?: boolean; }, { onSubmit, @@ -154,6 +223,7 @@ export const showLabelSetDialog = async ( wallet={wallet} deviceLabel={deviceLabel} asciiOnly={asciiOnly} + asciiAlphanumericWithSpacesOnly={asciiAlphanumericWithSpacesOnly} onSubmit={onSubmit} /> ), diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/WalletRenameButton.tsx b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/WalletRenameButton.tsx index a6b62a6f2e91..91749a3d255c 100644 --- a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/WalletRenameButton.tsx +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/WalletRenameButton.tsx @@ -14,6 +14,7 @@ import { EChangeHistoryEntityType, } from '@onekeyhq/shared/src/types/changeHistory'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { AccountManagerTestIDs } from '../../testIDs'; @@ -57,9 +58,15 @@ export function WalletRenameButton({ [wallet?.associatedDeviceInfo?.vendor], ); + const labelAsciiAlphanumericWithSpacesOnly = useMemo( + () => isProtocolV2ProductType(wallet?.associatedDeviceInfo?.deviceType), + [wallet?.associatedDeviceInfo?.deviceType], + ); + return ( <> { diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.test.ts b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.test.ts new file mode 100644 index 000000000000..6faf67619acd --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.test.ts @@ -0,0 +1,41 @@ +import { getHardwareLabelValidationError } from './hardwareLabelValidation'; + +const validatePro2Label = (value: string) => + getHardwareLabelValidationError({ + value, + maxLength: 32, + asciiAlphanumericWithSpacesOnly: true, + }); + +describe('getHardwareLabelValidationError', () => { + test.each(['OneKeyPro2', 'OneKey Pro 2', 'ONEKEY', '123456'])( + 'accepts a supported Pro2 label: %s', + (value) => { + expect(validatePro2Label(value)).toBeUndefined(); + }, + ); + + test.each([ + 'ran😂', + '一二三四五六七八九十123', + 'OneKey-Pro2', + 'OneKey_Pro2', + 'OneKey Pro2', + ])('rejects an unsupported Pro2 label: %s', (value) => { + expect(validatePro2Label(value)).toBe('invalid'); + }); + + it('reports an overlong supported label', () => { + expect(validatePro2Label('A'.repeat(33))).toBe('tooLong'); + }); + + it('keeps printable punctuation available for Trezor labels', () => { + expect( + getHardwareLabelValidationError({ + value: 'My-Trezor_1', + maxLength: 32, + asciiOnly: true, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.ts b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.ts new file mode 100644 index 000000000000..544de6d7bc8d --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/components/WalletRename/hardwareLabelValidation.ts @@ -0,0 +1,43 @@ +import emojiRegex from 'emoji-regex'; + +import { isAsciiAlphanumericWithSpaces } from '@onekeyhq/shared/src/utils/stringUtils'; + +export type IHardwareLabelValidationError = 'invalid' | 'tooLong'; + +export function getHardwareLabelValidationError({ + value, + maxLength, + asciiOnly, + asciiAlphanumericWithSpacesOnly, +}: { + value: string; + maxLength: number; + asciiOnly?: boolean; + asciiAlphanumericWithSpacesOnly?: boolean; +}): IHardwareLabelValidationError | undefined { + if (!value.length) { + return undefined; + } + + if (emojiRegex().test(value)) { + return 'invalid'; + } + + if ( + asciiAlphanumericWithSpacesOnly && + !isAsciiAlphanumericWithSpaces(value) + ) { + return 'invalid'; + } + + // Trezor labels support printable ASCII, including punctuation. + if (asciiOnly && /[^\x20-\x7E]/.test(value)) { + return 'invalid'; + } + + if (Buffer.from(value, 'utf-8').length > maxLength) { + return 'tooLong'; + } + + return undefined; +} diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx index b7838cfeff18..1a1d2277fa38 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBar.tsx @@ -35,7 +35,10 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import { swrKeys } from '@onekeyhq/shared/src/utils/swrCacheUtils'; -import { shouldShowCreateHiddenWalletSidebarButtonForWallet } from '../../../components/WalletEdit/WalletEditButtonUtils'; +import { + resolveWalletPassphraseProtection, + shouldShowCreateHiddenWalletSidebarButtonForWallet, +} from '../../../components/WalletEdit/WalletEditButtonUtils'; import { useAccountSelectorRoute } from '../../../router/useAccountSelectorRoute'; import { AccountManagerTestIDs } from '../../../testIDs'; @@ -117,8 +120,10 @@ export function AccountSelectorWalletListSideBar({ trailing: true, }, ); + appEventBus.on(EAppEventBusNames.HardwareDeviceStateUpdate, fn); appEventBus.on(EAppEventBusNames.HardwareFeaturesUpdate, fn); return () => { + appEventBus.off(EAppEventBusNames.HardwareDeviceStateUpdate, fn); appEventBus.off(EAppEventBusNames.HardwareFeaturesUpdate, fn); }; }, []); @@ -131,7 +136,7 @@ export function AccountSelectorWalletListSideBar({ // - Wallet/Account CRUD funnels through WalletUpdate / AccountUpdate // (see ServiceAccount emits) — listeners below call reloadWallets, // which runs the fetcher and overwrites this slot via usePromiseResult. - // - HardwareFeaturesUpdate / passphrase toggle flow through + // - OneKey state / third-party features / passphrase toggle flow through // reloadWalletsHook -> useEffect refetch -> same overwrite path. // - Bulk wipes (ServiceApp.resetApp, ServiceE2E.clearWalletsAndAccounts) // clear the cold-start cache in the bg service before emitting the @@ -284,6 +289,7 @@ export function AccountSelectorWalletListSideBar({ ({ wallet }: { wallet: IDBWallet | undefined }) => { noop(reloadWalletsHook); if (!wallet) return false; + const deviceInfo = wallet.associatedDeviceInfo; return shouldShowCreateHiddenWalletSidebarButtonForWallet({ isEditableRouteParams: !!isEditableRouteParams, showAddHiddenInWalletSidebar: settings.showAddHiddenInWalletSidebar, @@ -296,11 +302,12 @@ export function AccountSelectorWalletListSideBar({ isQrWallet: accountUtils.isQrWallet({ walletId: wallet.id, }), - hasPassphraseProtection: - wallet.associatedDeviceInfo?.featuresInfo?.passphrase_protection === - true, + hasPassphraseProtection: resolveWalletPassphraseProtection({ + deviceState: deviceInfo?.deviceStateInfo, + features: deviceInfo?.featuresInfo, + }), hiddenWalletsLength: wallet.hiddenWallets?.length ?? 0, - vendor: wallet.associatedDeviceInfo?.vendor, + vendor: deviceInfo?.vendor, }); }, [ diff --git a/packages/kit/src/views/AccountManagerStacks/pages/ExportKeys/ExportPrivateKeys.tsx b/packages/kit/src/views/AccountManagerStacks/pages/ExportKeys/ExportPrivateKeys.tsx index 2ed01742a6da..14543fcf73fa 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/ExportKeys/ExportPrivateKeys.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/ExportKeys/ExportPrivateKeys.tsx @@ -127,6 +127,7 @@ function ExportPrivateKeysPage({ await backgroundApiProxy.serviceNetwork.getSupportExportAccountKeyNetworks( { exportType, + walletId: indexedAccount?.walletId, }, ); return networksInfo.map((n) => n.network.id); @@ -135,6 +136,7 @@ function ExportPrivateKeysPage({ exportType, isImportedAccount, isWatchingAccount, + indexedAccount?.walletId, ]); const initialNetworkId = useMemo(() => { diff --git a/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.protocolV2.test.ts b/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.protocolV2.test.ts new file mode 100644 index 000000000000..e52e03193238 --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.protocolV2.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +describe('HardwareHomeScreenModal Protocol V2 wallpapers', () => { + it('shows default wallpapers for Pro2 and Neo', () => { + const source = readFileSync( + join(__dirname, 'HardwareHomeScreenModal.tsx'), + 'utf8', + ); + + expect(source).toMatch( + /const shouldShowDefaultWallpapers =[\s\S]*isProtocolV2ProductType\(deviceInfo\.deviceType\)/u, + ); + expect(source).toContain( + 'defaultWallpapers.length > 0 && shouldShowDefaultWallpapers', + ); + }); +}); diff --git a/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.tsx b/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.tsx index 0b278897df1f..ca4ed4782ebb 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/HardwareHomeScreen/HardwareHomeScreenModal.tsx @@ -43,6 +43,7 @@ import type { } from '@onekeyhq/shared/src/routes'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import imageUtils from '@onekeyhq/shared/src/utils/imageUtils'; import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; import type { IDeviceHomeScreen } from '@onekeyhq/shared/types/device'; @@ -122,6 +123,7 @@ function HomeScreenImageItem({ return ( ( async () => { - const { getDeviceFirmwareVersion, getDeviceUUID } = await CoreSDKLoader(); + const { getDeviceFirmwareVersion } = await CoreSDKLoader(); const serialNumber = device?.featuresInfo - ? getDeviceUUID(device.featuresInfo) + ? (deviceUtils.getDeviceSerialNoFromFeatures(device.featuresInfo) ?? '') : ''; const firmwareVersion = device?.featuresInfo @@ -693,12 +695,12 @@ export default function HardwareHomeScreenModal({ ); const categories: IWallpaperCategory[] = []; + const shouldShowDefaultWallpapers = deviceInfo?.deviceType + ? !deviceUtils.isTouchDevice(deviceInfo.deviceType) || + isProtocolV2ProductType(deviceInfo.deviceType) + : false; - if ( - defaultWallpapers.length > 0 && - deviceInfo?.deviceType && - !deviceUtils.isTouchDevice(deviceInfo?.deviceType) - ) { + if (defaultWallpapers.length > 0 && shouldShowDefaultWallpapers) { categories.push({ title: intl.formatMessage({ id: ETranslations.global_wallpaper_collection, @@ -768,7 +770,7 @@ export default function HardwareHomeScreenModal({ - + { try { @@ -812,6 +815,7 @@ export default function HardwareHomeScreenModal({ let buildCustomHexError: string | undefined = ''; let finallyScreenHex = ''; + let finallyScreenBase64: string | undefined; let finallyThumbnailHex: string | undefined; let finallyBlurScreenHex: string | undefined; try { @@ -820,6 +824,7 @@ export default function HardwareHomeScreenModal({ // case 2: server custom wallpaper from url const { screenHex: customScreenHex, + screenBase64: customScreenBase64, thumbnailHex: customThumbnailHex, blurScreenHex: customBlurScreenHex, } = await deviceHomeScreenUtils.buildCustomScreenHex({ @@ -831,10 +836,12 @@ export default function HardwareHomeScreenModal({ }); finallyScreenHex = customScreenHex || ''; + finallyScreenBase64 = customScreenBase64; finallyThumbnailHex = customThumbnailHex; finallyBlurScreenHex = customBlurScreenHex; } else { finallyScreenHex = screenHex || nameHex || ''; + finallyScreenBase64 = selectedItem.screenBase64; finallyThumbnailHex = thumbnailHex; finallyBlurScreenHex = blurScreenHex; } @@ -867,6 +874,7 @@ export default function HardwareHomeScreenModal({ screenItem: { ...selectedItem, screenHex: finallyScreenHex, + screenBase64: finallyScreenBase64, thumbnailHex: finallyThumbnailHex, blurScreenHex: finallyBlurScreenHex, }, diff --git a/packages/kit/src/views/AccountManagerStacks/testIDs.ts b/packages/kit/src/views/AccountManagerStacks/testIDs.ts index c62b5a06bb37..807bafa1c12c 100644 --- a/packages/kit/src/views/AccountManagerStacks/testIDs.ts +++ b/packages/kit/src/views/AccountManagerStacks/testIDs.ts @@ -27,7 +27,9 @@ export const AccountManagerTestIDs = { exportMnemonicKey: (name: string) => `popover-export-mnemonic-key-${name}`, // preserve existing // Wallet rename + walletRenameButton: 'account-manager-wallet-rename-button', walletRenameInput: 'account-manager-wallet-rename-input', + walletRenameError: 'account-manager-wallet-rename-error', walletRenameConfirm: 'account-manager-wallet-rename-confirm', // Account rename diff --git a/packages/kit/src/views/AssetDetails/pages/NFTDetails.tsx b/packages/kit/src/views/AssetDetails/pages/NFTDetails.tsx index ace0b99fe2be..43ba50eaed67 100644 --- a/packages/kit/src/views/AssetDetails/pages/NFTDetails.tsx +++ b/packages/kit/src/views/AssetDetails/pages/NFTDetails.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { EDeviceType } from '@onekeyfe/hd-shared'; import { useRoute } from '@react-navigation/core'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -18,6 +17,7 @@ import { import type { IPickerImage } from '@onekeyhq/components/src/composite/ImageCrop/type'; import { HeaderIconButton } from '@onekeyhq/components/src/layouts/Navigation/Header'; import type { IDBDevice } from '@onekeyhq/kit-bg/src/dbs/local/types'; +import type { IPro2NftUploadParams } from '@onekeyhq/kit-bg/src/services/ServiceNFT'; import { OneKeyAppError } from '@onekeyhq/shared/src/errors'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; @@ -31,8 +31,13 @@ import type { } from '@onekeyhq/shared/src/routes/assetDetails'; import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import imageUtils from '@onekeyhq/shared/src/utils/imageUtils'; -import { generateUploadNFTParams } from '@onekeyhq/shared/src/utils/nftUtils'; +import { + generatePro2NftMetadata, + generateUploadNFTParams, + isCollectNFTDeviceCompatible, +} from '@onekeyhq/shared/src/utils/nftUtils'; import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; import type { IServerNetwork } from '@onekeyhq/shared/types'; import type { IAccountNFT } from '@onekeyhq/shared/types/nft'; @@ -45,16 +50,11 @@ import { getNFTDetailsComponents } from '../../../utils/getNFTDetailsComponents' import type { DeviceUploadResourceParams } from '@onekeyfe/hd-core'; import type { RouteProp } from '@react-navigation/core'; -const isCollectNFTDeviceCompatible = (device?: IDBDevice) => - device && - (device.deviceType === EDeviceType.Touch || - device.deviceType === EDeviceType.Pro); - // Disable NFT image collection on web due to CORS errors when fetching NFT image data const canCollectNFT = (nft?: IAccountNFT, device?: IDBDevice) => !platformEnv.isWeb && nft?.metadata?.image && - isCollectNFTDeviceCompatible(device); + isCollectNFTDeviceCompatible(device?.deviceType); export default function NFTDetails() { const intl = useIntl(); @@ -113,22 +113,30 @@ export default function NFTDetails() { close(); if (!nft || !nft.metadata || !nft.metadata.image || !device) return; - const accountAddress = - await backgroundApiProxy.serviceAccount.getAccountAddressForApi({ - accountId, - networkId, - }); - setIsCollecting(true); let uploadResParams: DeviceUploadResourceParams | undefined; + let pro2UploadParams: IPro2NftUploadParams | undefined; + const isProtocolV2Product = isProtocolV2ProductType(device.deviceType); - const config = - await backgroundApiProxy.serviceHardware.getDeviceHomeScreenConfig({ + let config: Awaited< + ReturnType + >; + try { + config = await backgroundApiProxy.serviceHardware.getDeviceNftConfig({ dbDeviceId: device?.id, - homeScreenType: 'Nft', }); + } catch (_error) { + setIsCollecting(false); + Toast.error({ + title: intl.formatMessage({ + id: ETranslations.global_unknown_error, + }), + }); + return; + } if (!config || !config.size) { + setIsCollecting(false); Toast.error({ title: intl.formatMessage({ id: ETranslations.global_unknown_error, @@ -201,32 +209,68 @@ export default function NFTDetails() { originW, originH, + includeHex: false, }); - const { - screenHex: customScreenHex, - thumbnailHex: customThumbnailHex, - blurScreenHex: customBlurScreenHex, - } = await deviceHomeScreenUtils.buildCustomScreenHex({ - dbDeviceId: device.id, - url: img.uri, - deviceType: device.deviceType, - isUserUpload: true, - config, - }); + if (isProtocolV2Product) { + if (!config.thumbnailSize) { + throw new OneKeyAppError({ + message: 'Pro2 NFT thumbnail config is missing', + }); + } + const thumbnail = await imageUtils.resizeImage({ + uri: img.uri, + width: config.thumbnailSize.width, + height: config.thumbnailSize.height, + originW: config.size.width, + originH: config.size.height, + includeHex: false, + }); + if (!img.base64 || !thumbnail.base64) { + throw new OneKeyAppError({ + message: 'Pro2 NFT JPEG data is missing', + }); + } + pro2UploadParams = { + imageJpegBase64: img.base64, + thumbnailJpegBase64: thumbnail.base64, + ...generatePro2NftMetadata({ + title: + name && name.length > 0 ? name : `#${nft.collectionAddress}`, + subtitle: nft.collectionName ?? network?.name ?? '', + }), + }; + } else { + const accountAddress = + await backgroundApiProxy.serviceAccount.getAccountAddressForApi({ + accountId, + networkId, + }); + const { + screenHex: customScreenHex, + thumbnailHex: customThumbnailHex, + blurScreenHex: customBlurScreenHex, + } = await deviceHomeScreenUtils.buildCustomScreenHex({ + dbDeviceId: device.id, + url: img.uri, + deviceType: device.deviceType, + isUserUpload: true, + config, + }); - uploadResParams = await generateUploadNFTParams({ - screenHex: customScreenHex, - thumbnailHex: customThumbnailHex ?? '', - blurScreenHex: customBlurScreenHex ?? '', - metadata: { - header: - name && name?.length > 0 ? name : `#${nft.collectionAddress}`, - subheader: nft.metadata?.description ?? '', - network: network?.name ?? '', - owner: accountAddress, - }, - }); + uploadResParams = await generateUploadNFTParams({ + screenHex: customScreenHex, + thumbnailHex: customThumbnailHex ?? '', + blurScreenHex: customBlurScreenHex ?? '', + metadata: { + header: + name && name?.length > 0 ? name : `#${nft.collectionAddress}`, + subheader: nft.metadata?.description ?? '', + network: network?.name ?? '', + owner: accountAddress, + }, + }); + } } catch (_e) { Toast.error({ title: intl.formatMessage({ @@ -236,22 +280,25 @@ export default function NFTDetails() { setIsCollecting(false); return; } - if (uploadResParams && !modalClosed.current) { - try { - await backgroundApiProxy.serviceNFT.uploadNFTImageToDevice({ - accountId, - uploadResParams, - }); - Toast.success({ - title: intl.formatMessage({ - id: ETranslations.nft_already_collected, - }), - }); - } catch (e) { - Toast.error({ title: (e as Error).message }); - } finally { - setIsCollecting(false); - } + if ((!uploadResParams && !pro2UploadParams) || modalClosed.current) { + setIsCollecting(false); + return; + } + try { + await backgroundApiProxy.serviceNFT.uploadNFTImageToDevice({ + accountId, + uploadResParams, + pro2UploadParams, + }); + Toast.success({ + title: intl.formatMessage({ + id: ETranslations.nft_already_collected, + }), + }); + } catch (e) { + Toast.error({ title: (e as Error).message }); + } finally { + setIsCollecting(false); } }, [accountId, device, intl, network?.name, networkId, nft], diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx index 249df4bcc64d..1f840e14ac12 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx @@ -76,6 +76,7 @@ const Index = () => { { // @ts-expect-error diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery.tsx index b5c78985eac5..ddc79a3c1495 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery.tsx @@ -3,8 +3,10 @@ import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/background import { BadAuthError, InvoiceExpiredError, + NeedFirmwareUpgradeFromWeb, OneKeyError, OneKeyLocalError, + UnknownHardwareError, } from '@onekeyhq/shared/src/errors'; import errorToastUtils from '@onekeyhq/shared/src/errors/utils/errorToastUtils'; import { @@ -42,6 +44,13 @@ async function showHyperLiquidVariableErrorToast() { } } +// Hardware error classes drop props.autoToast in their constructor, so the flag +// has to be set on the instance before the toast pipeline sees it. +function showHardwareErrorToast(error: Error) { + errorToastUtils.toastIfError(error); + errorToastUtils.showToastOfError(error); +} + function error10() { throw new BadAuthError(); } @@ -306,6 +315,27 @@ Timestamp: ${new Date().toISOString()}`, > Error without Diagnostic Info + + + + ))} + + {uiError ? ( + UI error: {uiError} + ) : null} + + + + Filter app-latest.log by firmwareArtifactSelfTest or the Run ID. + + + A cached artifact is still integrity-checked. Clear App data before + the first run when a fresh network transfer is required. + + + + ); +} + +const FirmwareArtifactGallery = () => ( + __CURRENT_FILE_PATH__} + > + + +); + +export default FirmwareArtifactGallery; diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts new file mode 100644 index 000000000000..b21a25861e26 --- /dev/null +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts @@ -0,0 +1,29 @@ +import type { IFirmwareArtifactSelfTestState } from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest'; + +import { createFirmwareArtifactGalleryClient } from './FirmwareArtifactGalleryClient'; + +describe('FirmwareArtifactGalleryClient', () => { + it('passes the dev-only password to start and state requests', async () => { + const state = {} as IFirmwareArtifactSelfTestState; + const startFirmwareArtifactSelfTest = jest.fn().mockResolvedValue(state); + const getFirmwareArtifactSelfTestState = jest.fn().mockResolvedValue(state); + const client = createFirmwareArtifactGalleryClient( + { + startFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestState, + }, + { $$devOnlyPassword: 'dev-password' }, + ); + + await client.start('pro-firmware'); + await client.getState(); + + expect(startFirmwareArtifactSelfTest).toHaveBeenCalledWith({ + $$devOnlyPassword: 'dev-password', + scenario: 'pro-firmware', + }); + expect(getFirmwareArtifactSelfTestState).toHaveBeenCalledWith({ + $$devOnlyPassword: 'dev-password', + }); + }); +}); diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts new file mode 100644 index 000000000000..629eacdce55c --- /dev/null +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts @@ -0,0 +1,37 @@ +import type { + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest'; +import type { IBackgroundMethodWithDevOnlyPassword } from '@onekeyhq/shared/src/background/backgroundDecorators'; + +type IFirmwareArtifactSelfTestService = { + startFirmwareArtifactSelfTest( + params: IBackgroundMethodWithDevOnlyPassword & { + scenario: IFirmwareArtifactSelfTestScenario; + }, + ): Promise; + getFirmwareArtifactSelfTestState( + params: IBackgroundMethodWithDevOnlyPassword, + ): Promise; +}; + +export function createFirmwareArtifactGalleryClient( + service: IFirmwareArtifactSelfTestService, + devOnlyParams: IBackgroundMethodWithDevOnlyPassword, +) { + return { + start(scenario: IFirmwareArtifactSelfTestScenario) { + return service.startFirmwareArtifactSelfTest({ + ...devOnlyParams, + scenario, + }); + }, + getState() { + return service.getFirmwareArtifactSelfTestState(devOnlyParams); + }, + }; +} + +export type IFirmwareArtifactGalleryClient = ReturnType< + typeof createFirmwareArtifactGalleryClient +>; diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx index ec6903a41f3e..9dcad54f6c1d 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx @@ -1,9 +1,18 @@ /* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react/no-unstable-nested-components */ +import { useState } from 'react'; + import { EDeviceType } from '@onekeyfe/hd-shared'; -import { Button, Dialog, SizableText, Stack } from '@onekeyhq/components'; +import { + Button, + Dialog, + EInPageDialogType, + SizableText, + Stack, + useInPageDialog, +} from '@onekeyhq/components'; import { ConfirmOnDeviceToast, confirmByPin, @@ -13,6 +22,8 @@ import { confirmPhraseOnDevice, confirmPinOnDevice, } from '@onekeyhq/kit/src/components/Hardware'; +import { hardwareUiStateDialogLifecycle } from '@onekeyhq/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; +import { useFirmwareUpdateActions } from '@onekeyhq/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions'; import type { IHardwareUiPayload } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { EHardwareUiStateAction, @@ -20,6 +31,7 @@ import { hardwareUiStateCompletedAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; +import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EOneKeyDeviceMode } from '@onekeyhq/shared/types/device'; import { Layout } from './utils/Layout'; @@ -27,6 +39,60 @@ import { Layout } from './utils/Layout'; import type { IDeviceType } from '@onekeyfe/hd-core'; // https://i.mij.rip/2024/09/19/b0cdcbdb45494fe53b831fff02981fdb.jpeg +const BootloaderDialogHandoffTest = () => { + const [confirmCount, setConfirmCount] = useState(0); + const dialogHost = useInPageDialog(EInPageDialogType.inOnboardingPage); + const firmwareUpdateActions = useFirmwareUpdateActions(); + + return ( + + + + Confirm count: {confirmCount} + + + ); +}; + const HardwareActionTest = () => { const generateAction = async ( uiRequestType: EHardwareUiStateAction, @@ -54,6 +120,9 @@ const HardwareActionTest = () => { if (uiRequestType === EHardwareUiStateAction.FIRMWARE_PROGRESS) { usedPayload.firmwareProgress = payload; } + if (uiRequestType === EHardwareUiStateAction.DEVICE_PROGRESS) { + usedPayload.deviceProgress = payload; + } if ( ![ @@ -78,6 +147,33 @@ const HardwareActionTest = () => { return ( + + + + + + + Device transfer progress + + + + 事件:Confirm =》Confirm =》Pin =》Pin =》Confirm =》Confirm =》Pin diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/LoggerConfigGallery.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/LoggerConfigGallery.tsx index efb221c325e9..ff48601ca801 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/LoggerConfigGallery.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/LoggerConfigGallery.tsx @@ -13,6 +13,7 @@ import { XStack, YStack, } from '@onekeyhq/components'; +import { saveLoggerConfigToAllRuntimes } from '@onekeyhq/kit/src/utils/loggerConfigUtils'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import type { ILoggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; import { defaultLoggerConfig } from '@onekeyhq/shared/src/logger/loggerConfig'; @@ -53,7 +54,7 @@ const LoggingConfigCheckbox = () => { async () => { // use debounce to wait state update await timerUtils.wait(0); - void defaultLoggerConfig.saveLoggerConfig({ + void saveLoggerConfigToAllRuntimes({ ...configRef.current, highlightDurationGt: highlightDurationGtRef.current, enabled: enabledConfigRef.current as any, @@ -155,6 +156,22 @@ const LoggingConfigCheckbox = () => { /> ) : null} + + { + setConfig((prev) => ({ + ...prev, + enableAllScenes: v, + })); + void saveLoggerConfig(); + }} + /> + + Enable all scenes (persist logs like production) + + + = ThirdPartyWalletAvatarImages; - const profile = getVendorProfile(device.vendor); - const key = - device.vendor === EHardwareVendor.trezor && device.model in avatars - ? device.model - : profile.avatarKey; - return avatars[key] ?? avatars.trezor; -} - type IActionItem = { label: string; action: EThirdPartyHardwareUiAction; @@ -137,6 +154,12 @@ function ActionRows({ function ThirdPartyHardwareActionsTest() { const [device, setDevice] = useState(DEVICE_MOCKS[0]); + const avatarKey = getThirdPartyDeviceAvatarImage({ + vendor: device.vendor, + vendorModel: device.vendorModel, + vendorModelName: device.vendorModelName, + fallback: device.vendor === EHardwareVendor.ledger ? 'ledger' : 'trezor', + }); return ( @@ -156,15 +179,15 @@ function ThirdPartyHardwareActionsTest() { - {device.model} + {device.label} - {`vendor=${device.vendor}`} + {`vendor=${device.vendor} avatarKey=${avatarKey}`} diff --git a/packages/kit/src/views/Developer/pages/Gallery/index.tsx b/packages/kit/src/views/Developer/pages/Gallery/index.tsx index fa40d9790fb5..b76f0dbf103e 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/index.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/index.tsx @@ -24,6 +24,11 @@ const ErrorToastGallery = LazyLoadPage( import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery'), ); +const FirmwareArtifactGallery = LazyLoadPage( + () => + import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGallery'), +); + const QRWalletGallery = LazyLoadPage( () => import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/QRWalletGallery'), @@ -375,6 +380,10 @@ export const galleryScreenList: { { name: EGalleryRoutes.ComponentJotaiGlobal, component: JotaiGlobalGallery }, { name: EGalleryRoutes.ComponentLocalDB, component: LocalDBGallery }, { name: EGalleryRoutes.ComponentErrorToast, component: ErrorToastGallery }, + { + name: EGalleryRoutes.ComponentFirmwareArtifact, + component: FirmwareArtifactGallery, + }, { name: EGalleryRoutes.ComponentQRWallet, component: QRWalletGallery, diff --git a/packages/kit/src/views/Developer/testIDs.ts b/packages/kit/src/views/Developer/testIDs.ts index eee324d39130..80f9376f562e 100644 --- a/packages/kit/src/views/Developer/testIDs.ts +++ b/packages/kit/src/views/Developer/testIDs.ts @@ -18,4 +18,11 @@ export const DeveloperTestIDs = { connectedSiteBtn: 'developer-connected-site-btn', customSignMessageInput: 'developer-custom-sign-message-input', customSignMessageBtn: 'developer-custom-sign-message-btn', + + // --- Firmware Artifact Gallery --- + firmwareArtifactScreen: 'firmware-artifact-validator-screen', + firmwareArtifactRunFirmware: 'firmware-artifact-run-pro-firmware', + firmwareArtifactRunResource: 'firmware-artifact-run-pro-resource', + firmwareArtifactRunFullResource: 'firmware-artifact-run-pro-full-resource', + firmwareArtifactStatus: 'firmware-artifact-status', } as const; diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx index de70f9dfaa2a..c19d89755b1a 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBasicInfo.tsx @@ -13,6 +13,7 @@ import { } from '@onekeyhq/components'; import { WalletAvatar } from '@onekeyhq/kit/src/components/WalletAvatar'; import { + getDeviceSecondaryIdentifier, useCurrentWalletIdAtom, useDeviceMetaStateAtom, useDeviceMetaStaticAtom, @@ -93,6 +94,8 @@ function DeviceBasicInfo({ const [deviceMetaStatic] = useDeviceMetaStaticAtom(); const [deviceMetaState] = useDeviceMetaStateAtom(); const [refreshSettled] = useRefreshSettledAtom(); + const deviceSecondaryIdentifier = + getDeviceSecondaryIdentifier(deviceMetaStatic); const isQrWallet = accountUtils.isQrWallet({ walletId: currentWalletId }); @@ -154,9 +157,9 @@ function DeviceBasicInfo({ - {deviceMetaStatic.deviceName ? ( + {deviceSecondaryIdentifier ? ( - {deviceMetaStatic.deviceName} + {deviceSecondaryIdentifier} ) : 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..946e5a075940 --- /dev/null +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceBrightnessSlider.tsx @@ -0,0 +1,150 @@ +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..66635386eb12 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionAdvance.tsx @@ -58,7 +58,7 @@ function DeviceSectionAdvancePassphrase() { })} titleProps={{ size: '$bodyMdMedium', color: '$text' }} justifyContent="center" - value={passphraseEnabled} + value={passphraseEnabled ?? false} onAction={onPressPassphrase} > {({ value, disabled, onChange }) => ( @@ -97,8 +97,9 @@ function DeviceSectionAdvanceInputPinOnSoftware() { })} titleProps={{ size: '$bodyMdMedium', color: '$text' }} justifyContent="center" - value={inputPinOnSoftwareEnabled} + value={inputPinOnSoftwareEnabled ?? false} onAction={actions.updateInputPinOnSoftware} + disabled={inputPinOnSoftwareEnabled === undefined} > {({ 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..938a6821fcb4 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/DeviceSectionGeneral.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import BigNumber from 'bignumber.js'; import { useIntl } from 'react-intl'; @@ -9,9 +9,11 @@ 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 { + resolveDeviceWithCurrentType, useDeviceAtom, useDeviceAutoLockDelayMsAtom, useDeviceAutoShutDownDelayMsAtom, + useDeviceBrightnessAtom, useDeviceDetailsActions, useDeviceHapticFeedbackAtom, useDeviceLanguageAtom, @@ -26,17 +28,29 @@ import { import deviceUtils, { ESupportSettings, } from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; 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 +76,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 +110,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 +150,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(); + const [isOpen, setIsOpen] = useState(false); const [autoLockDelayMs] = useDeviceAutoLockDelayMsAtom(); const stateful = useStatefulAction({ @@ -149,8 +204,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) { @@ -166,31 +224,42 @@ export function AutoLockListItem({ return { displayLabel: label, isLocked: locked }; }, [stateful.value, autoLockOptions, intl]); + const isDisabled = disabled || stateful.loading; + const handleOpen = useCallback(() => { + if (!isDisabled && !isOpen) { + setIsOpen(true); + } + }, [isDisabled, isOpen]); + return ( - ( - - )} - /> + )} + /> + ); } export function AutoShutDownListItem({ autoShutDownOptions, + disabled, }: { - autoShutDownOptions: Array<{ label: string; value: number }>; + autoShutDownOptions: Array<{ + isNever?: boolean; + label: string; + value: number; + }>; + disabled?: boolean; }) { const intl = useIntl(); const actions = useDeviceDetailsActions(); + const [isOpen, setIsOpen] = useState(false); const [autoShutDownDelayMs] = useDeviceAutoShutDownDelayMsAtom(); const stateful = useStatefulAction({ @@ -223,8 +299,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) { @@ -242,31 +321,42 @@ export function AutoShutDownListItem({ return { displayLabel: label, isLocked: locked }; }, [stateful.value, autoShutDownOptions, intl]); + const isDisabled = disabled || stateful.loading; + const handleOpen = useCallback(() => { + if (!isDisabled && !isOpen) { + setIsOpen(true); + } + }, [isDisabled, isOpen]); + return ( - ( - - )} - /> + )} + /> + ); } @@ -304,7 +394,7 @@ export function HapticFeedbackListItem() { id: ETranslations.global_vibration_haptic, })} titleProps={{ size: '$bodyMdMedium', color: '$text' }} - value={hapticFeedback} + value={hapticFeedback ?? false} onAction={onUpdateHapticFeedback} > {({ value, disabled, onChange }) => ( @@ -320,6 +410,18 @@ export function HapticFeedbackListItem() { ); } +function Pro2BrightnessListItem() { + const actions = useDeviceDetailsActions(); + const [brightness] = useDeviceBrightnessAtom(); + + return ( + + ); +} + function DeviceSectionGeneral() { const intl = useIntl(); const actions = useDeviceDetailsActions(); @@ -329,6 +431,17 @@ function DeviceSectionGeneral() { const [deviceType] = useDeviceTypeAtom(); 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 trezorFeatures = useMemo( () => (device?.featuresInfo ?? {}) as Record, [device?.featuresInfo], @@ -339,7 +452,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 +475,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 +500,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 +609,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 +632,21 @@ function DeviceSectionGeneral() { return null; } + const brightnessItem = isProtocolV2ProductType(deviceType) ? ( + + ) : ( + + ); + return ( ) : null} - {showBrightness ? ( - - ) : null} + {showBrightness ? brightnessItem : null} {showAutoLock ? ( ) : 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 ( { @@ -50,7 +55,17 @@ export function DeviceUpdateAlert({ type }: { type?: 'top' | 'bottom' }) { if (!detectResult?.shouldUpdate) return null; let message = 'New firmware is available'; - if (detectResult?.detectInfo?.toVersion) { + if (isProtocolV2ProductType(deviceType)) { + const safeOSVersion = + detectResult.detectInfo?.toVersion ?? deviceMetaStatic.firmwareVersion; + message = + safeOSVersion && safeOSVersion !== '0.0.0' + ? intl.formatMessage( + { id: ETranslations.update_firmware_version_available }, + { version: `SafeOS ${safeOSVersion}` }, + ) + : intl.formatMessage({ id: ETranslations.update_firmware_available }); + } else if (detectResult?.detectInfo?.toVersion) { const firmwareTypeLabel = getTargetFirmwareTypeLabel({ firmwareType: detectResult.detectInfo.toFirmwareType, intl, diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogDeviceAbout.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogDeviceAbout.tsx index c4835e4cf7cf..f0e0293fdd8b 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogDeviceAbout.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/dialog/DialogDeviceAbout.tsx @@ -12,6 +12,7 @@ import { YStack, useClipboard, } from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -108,67 +109,107 @@ function DialogDeviceSpecsContent({ data }: { data: IHwQrWalletWithDevice }) { ); const { result: deviceInfo } = usePromiseResult( async () => { - 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..4e58a3583a7a 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,82 @@ function DeviceDetailsModalV2Cmp({ hasLoadedDevice, }); - useEffect(() => { + const refreshCurrentDevice = useCallback(async () => { + if (!walletId) return; + // 设备详情页打开时优先展示已持久化的设备状态,避免页面聚焦就主动 + // 建立连接,尤其是在没有已绑定 BLE connectId 时触发后台搜索/配对。 + const data = await refresh(walletId, { skipDeviceStateSnapshot: true }); + 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; - const fn = async () => { - const data = await refresh(walletId); - if (!data) { - void handleBackPress?.(); - } + 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,24 +238,30 @@ function DeviceDetailsModalV2Cmp({ )} /> ) : null} - {showDeviceSettings ? ( + {showDeviceSettings && showInteractiveSections ? ( <> - {/* Wipe device is a OneKey-SDK op; the danger zone is wipe-only - for third-party (Trezor/Ledger), so hide it for them. */} - {vendorProfile?.isThirdParty ? null : ( - - )} ) : null} - {showPassphraseSettings ? : null} + {showPassphraseSettings && showInteractiveSections ? ( + + ) : null} {showDeviceConnection ? : null} + {/* Last of the user-facing sections so a destructive action is + never adjacent to the routine settings above. Wipe device is a + OneKey-SDK op; the danger zone is wipe-only for third-party + (Trezor/Ledger), so hide it for them. */} + {showDeviceSettings && !vendorProfile?.isThirdParty ? ( + + ) : 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..a7e23e82702b 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts @@ -1,10 +1,15 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { EHardwareVendor } from '@onekeyhq/shared/types/device'; import { buildDeviceDetailsVisibility, canOpenDeviceManagementDetails, canShowTrezorBleBinding, + getFirmwareTypeChangeAvailability, getTrezorAutoLockOptionsMs, + shouldShowDeviceInteractiveSections, + syncRelevantDeviceStateEvent, } from './utils'; describe('DeviceDetailsModal utils', () => { @@ -46,6 +51,80 @@ 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.each([EDeviceType.Pro, EDeviceType.Classic1s, EDeviceType.ClassicPure])( + 'enables firmware type switching for %s', + (deviceType) => { + expect(getFirmwareTypeChangeAvailability(deviceType)).toBe('enabled'); + }, + ); + + it.each([EDeviceType.Pro2, EDeviceType.Neo])( + 'hides firmware type switching for unsupported Protocol V2 device %s', + (deviceType) => { + expect(getFirmwareTypeChangeAvailability(deviceType)).toBe('hidden'); + }, + ); + + it.each([ + EDeviceType.Classic, + EDeviceType.Mini, + EDeviceType.Touch, + EDeviceType.Unknown, + ])('hides firmware type switching for %s', (deviceType) => { + expect(getFirmwareTypeChangeAvailability(deviceType)).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..dcaab3e97cbd 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts @@ -1,9 +1,13 @@ import { getVendorProfile } from '@onekeyhq/shared/src/hardware/vendorProfile'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import thirdPartyDeviceUtils from '@onekeyhq/shared/src/utils/thirdPartyDeviceUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EHardwareVendor } from '@onekeyhq/shared/types/device'; +import type { EDeviceType } from '@onekeyfe/hd-shared'; + type IDeviceConnectionInfo = { vendor?: EHardwareVendor; connectId?: string; @@ -63,6 +67,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 (isProtocolV2ProductType(deviceType)) { + return 'hidden'; + } + 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..9742ccf0fc17 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx @@ -25,6 +25,7 @@ import type { IWalletAvatarProps } from '@onekeyhq/kit/src/components/WalletAvat import { WalletAvatar } from '@onekeyhq/kit/src/components/WalletAvatar'; import { useHardwareWalletConnectStatus } from '@onekeyhq/kit/src/hooks/useHardwareWalletConnectStatus'; import { usePromiseResult } from '@onekeyhq/kit/src/hooks/usePromiseResult'; +import { isDeviceManagementWalletUsable } from '@onekeyhq/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement'; import { useNavigateToPickYourDevicePage } from '@onekeyhq/kit/src/views/Onboarding/hooks/useToOnBoardingPage'; import { useFirmwareUpdatesDetectStatusPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { @@ -233,6 +234,11 @@ function DeviceListItem({ w: 56, h: 56, }} + testID={ + isConnected + ? DeviceManagementTestIDs.deviceStatusConnected + : DeviceManagementTestIDs.deviceStatusDisconnected + } > = Object.values(r) .filter( (item): item is IHwQrWalletWithDevice => - Boolean(item.device) && !item.wallet.deprecated, + Boolean(item.device) && isDeviceManagementWalletUsable(item), ) .toSorted((a, b) => { const orderA = a.wallet.walletOrder || a.wallet.walletNo; diff --git a/packages/kit/src/views/DeviceManagement/testIDs.ts b/packages/kit/src/views/DeviceManagement/testIDs.ts index 0416cac603fb..275e04562a57 100644 --- a/packages/kit/src/views/DeviceManagement/testIDs.ts +++ b/packages/kit/src/views/DeviceManagement/testIDs.ts @@ -1,6 +1,8 @@ export const DeviceManagementTestIDs = { // --- Device List --- deviceListItem: 'device-mgmt-device-list-item', + deviceStatusConnected: 'device-mgmt-device-status-connected', + deviceStatusDisconnected: 'device-mgmt-device-status-disconnected', addNewDeviceBtn: 'device-mgmt-add-new-device-btn', // --- Device Details --- diff --git a/packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx b/packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx index 71aa735a076d..761eabe62cf8 100644 --- a/packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx +++ b/packages/kit/src/views/Discovery/pages/Browser/Browser.native.tsx @@ -591,7 +591,23 @@ function MobileBrowser() { {browserDashboardContent} {platformEnv.isNativeAndroid ? ( - {content} + + {content} + ) : null} diff --git a/packages/kit/src/views/Earn/EarnHome.tsx b/packages/kit/src/views/Earn/EarnHome.tsx index bcf7ad5638f9..cf7856330853 100644 --- a/packages/kit/src/views/Earn/EarnHome.tsx +++ b/packages/kit/src/views/Earn/EarnHome.tsx @@ -18,7 +18,10 @@ import { } from '@onekeyhq/shared/src/routes'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EAccountSelectorSceneName } from '@onekeyhq/shared/types'; -import type { IEarnAvailableAsset } from '@onekeyhq/shared/types/earn'; +import type { + IEarnAvailableAsset, + IEarnPageBannerListItem, +} from '@onekeyhq/shared/types/earn'; import { EAvailableAssetsTypeEnum } from '@onekeyhq/shared/types/earn'; import { EEarnLabels } from '@onekeyhq/shared/types/staking'; @@ -88,26 +91,72 @@ function BasicEarnHome({ const wasFocusedRef = useRef(false); const wasHiddenByModalRef = useRef(false); const shouldLogEnterEarnRef = useRef(false); - const { - result: earnPageBannerList, - isLoading: isEarnPageBannerLoading, - run: refetchEarnPageBannerList, - } = usePromiseResult( + // Banner list is plain state rather than usePromiseResult's result, because + // it has two independent writers and the later one must not be able to + // resurrect an older value: + // 1. simpleDb, read once on mount. The list a cold start paints comes from + // the previous session, so the banner is already at its real height + // instead of occupying 0pt and expanding when the network answers + // (OK-60299). Mirrors how the wallet home seeds its own banners. + // 2. the network, on every switch onto the DeFi tab. Overwrites the cached + // value, including with an empty list once the account genuinely has no + // banners. State also survives the re-runs that showContent triggers, + // so a re-entry starts from what is already on screen. + const [earnPageBannerList, setEarnPageBannerList] = useState< + IEarnPageBannerListItem[] + >([]); + const hasNetworkBannerListRef = useRef(false); + // usePromiseResult guards its own setResult against stale responses with a + // nonce, but that guard runs after the method body returns — a setState made + // inside the body is not covered by it. This hook has three triggers that do + // not cancel each other (the showContent dep, revalidateOnFocus, and the + // manual refetch in refreshEarnData), so two requests can be in flight at + // once and the result would otherwise be decided by whichever resolves last. + const bannerRequestSeqRef = useRef(0); + + useEffect(() => { + if (!platformEnv.isNative) { + return; + } + void (async () => { + const cached = + await backgroundApiProxy.serviceStaking.getEarnPageBannerListFromCache(); + // The request can win this race on a warm start; its answer is the + // current one and must not be replaced by what we read from disk. + if (hasNetworkBannerListRef.current || cached.length === 0) { + return; + } + setEarnPageBannerList(cached); + })(); + }, []); + + const { run: refetchEarnPageBannerList } = usePromiseResult( async () => { if (!platformEnv.isNative || showContent === false) { - return []; + return; } + const requestSeq = (bannerRequestSeqRef.current += 1); try { - return await backgroundApiProxy.serviceStaking.getEarnPageBannerList(); + const list = + await backgroundApiProxy.serviceStaking.getEarnPageBannerList(); + // Set outside the staleness check: its job is to stop the simpleDb + // seed from backfilling once the network has spoken at all, and a + // newer request is already on its way to write the real value. + hasNetworkBannerListRef.current = true; + if (requestSeq !== bannerRequestSeqRef.current) { + return; + } + setEarnPageBannerList(list); } catch { - return []; + // Keep whatever is on screen — the cached list, or the previous + // response. Rethrowing would take the whole Earn refresh down with it: + // usePromiseResult re-throws non-abort errors, and refreshEarnData + // awaits this inside a Promise.all with no catch, so a flaky banner + // request would skip the balance and portfolio refresh behind it. } }, [showContent], { - initResult: [], - watchLoading: true, - undefinedResultIfError: false, revalidateOnFocus: true, }, ); @@ -544,7 +593,7 @@ function BasicEarnHome({ ({ + openUrlExternal: jest.fn(), +})); +jest.mock('@onekeyhq/kit/src/background/instance/backgroundApiProxy', () => ({ + __esModule: true, + default: {}, +})); +jest.mock('./components/FirmwareUpdatePageLayout', () => ({ + FirmwareUpdatePageFooter: () => null, +})); +jest.mock('@onekeyhq/kit/src/components/HyperlinkText', () => ({ + HyperlinkText: () => null, +})); + +const usbPriorityMessage = 'Disconnect USB to continue using Bluetooth.'; +const deviceDisconnectedMessage = + 'The device has been disconnected. Please reconnect the device and try again.'; +const deviceDisconnectedTitle = 'Device disconnected'; + +const intlMessages: Record = { + [ETranslations.troubleshooting_desktop_bluetooth_usb_priority]: + usbPriorityMessage, + [ETranslations.hardware_third_party_device_disconnected]: + deviceDisconnectedTitle, + [ETranslations.update_device_disconnected_desc]: deviceDisconnectedMessage, + [ETranslations.global_retry]: 'Retry', +}; + +function IntlWrapper({ children }: { children: ReactNode }) { + return ( + + {children as never} + + ); +} + +describe('firmware update USB-priority errors', () => { + const error = new BluetoothUnavailableWhileUsbConnectedError(); + + it('uses the localized USB-priority message in the Protocol V2 error view', () => { + const { result } = renderHook( + () => + useFirmwareUpdateErrorsV2({ + error, + lastFirmwareTipMessage: undefined, + }), + { wrapper: IntlWrapper }, + ); + + expect(error.code).toBe(HardwareErrorCode.BleUnavailableWhileUsbConnected); + expect(result.current.errorMessage).toBe(usbPriorityMessage); + }); + + it('uses the localized USB-priority message in the legacy error view', () => { + const { result } = renderHook( + () => + useLegacyFirmwareUpdateErrors({ + error, + lastFirmwareTipMessage: undefined, + onRetry: undefined, + result: undefined, + }), + { wrapper: IntlWrapper }, + ); + const content = result.current.content as ReactElement<{ + message?: string; + }>; + + expect(content.props.message).toBe(usbPriorityMessage); + }); +}); + +describe('firmware update cancellation errors', () => { + const error: IOneKeyError = { + className: EOneKeyErrorClassNames.FirmwareUpdateTasksClear, + message: 'updateTasksClear: exitUpdateWorkflow', + }; + + it('does not expose exitUpdateWorkflow in the Protocol V2 error view', () => { + const { result } = renderHook( + () => + useFirmwareUpdateErrorsV2({ + error, + lastFirmwareTipMessage: undefined, + }), + { wrapper: IntlWrapper }, + ); + + expect(result.current.errorMessage).toBe(deviceDisconnectedMessage); + }); + + it('does not expose exitUpdateWorkflow in the legacy error view', () => { + const { result } = renderHook( + () => + useLegacyFirmwareUpdateErrors({ + error, + lastFirmwareTipMessage: undefined, + onRetry: undefined, + result: undefined, + }), + { wrapper: IntlWrapper }, + ); + const content = result.current.content as ReactElement<{ + message?: string; + title?: string; + }>; + + expect(content.props.title).toBe(deviceDisconnectedTitle); + expect(content.props.message).toBe(deviceDisconnectedMessage); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.test.ts b/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.test.ts new file mode 100644 index 000000000000..55c75b9580dd --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.test.ts @@ -0,0 +1,82 @@ +import { createActiveAccountFirmwareUpdateDetector } from './activeAccountFirmwareUpdateDetection'; + +describe('createActiveAccountFirmwareUpdateDetector', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('retries a busy detection through its throttle window and then stops', async () => { + const detect = jest + .fn() + .mockResolvedValueOnce({ status: 'busy', retryAfterMs: 1000 }) + .mockResolvedValueOnce({ status: 'throttled', retryAfterMs: 5000 }) + .mockResolvedValueOnce({ status: 'finished' }); + const detector = createActiveAccountFirmwareUpdateDetector({ detect }); + + detector.start(); + await Promise.resolve(); + expect(detect).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1000); + expect(detect).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(5000); + expect(detect).toHaveBeenCalledTimes(3); + + await jest.runOnlyPendingTimersAsync(); + expect(detect).toHaveBeenCalledTimes(3); + }); + + it('does not turn an initially throttled detection into periodic polling', async () => { + const detect = jest + .fn() + .mockResolvedValue({ status: 'throttled', retryAfterMs: 5000 }); + const detector = createActiveAccountFirmwareUpdateDetector({ detect }); + + detector.start(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(5000); + + expect(detect).toHaveBeenCalledTimes(1); + }); + + it('cancels a retry when the home route loses focus', async () => { + const detect = jest + .fn() + .mockResolvedValue({ status: 'busy', retryAfterMs: 1000 }); + const detector = createActiveAccountFirmwareUpdateDetector({ detect }); + + detector.start(); + await Promise.resolve(); + detector.cancel(); + await jest.advanceTimersByTimeAsync(1000); + + expect(detect).toHaveBeenCalledTimes(1); + }); + + it('does not schedule after an in-flight detection is cancelled', async () => { + let resolveDetection: + | ((result: { status: 'busy'; retryAfterMs: number }) => void) + | undefined; + const detect = jest.fn( + () => + new Promise<{ status: 'busy'; retryAfterMs: number }>((resolve) => { + resolveDetection = resolve; + }), + ); + const detector = createActiveAccountFirmwareUpdateDetector({ detect }); + + detector.start(); + detector.cancel(); + resolveDetection?.({ status: 'busy', retryAfterMs: 1000 }); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(1000); + + expect(detect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.ts b/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.ts new file mode 100644 index 000000000000..6874431f47ef --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/activeAccountFirmwareUpdateDetection.ts @@ -0,0 +1,53 @@ +import type { IDetectActiveAccountFirmwareUpdatesResult } from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate'; + +export function createActiveAccountFirmwareUpdateDetector({ + detect, +}: { + detect: () => Promise; +}) { + let active = true; + let started = false; + let retryTimer: ReturnType | undefined; + + const runDetection = async (continueAfterThrottle: boolean) => { + let result: IDetectActiveAccountFirmwareUpdatesResult; + try { + result = await detect(); + } catch { + return; + } + + if (!active) { + return; + } + + if ( + result.status !== 'busy' && + (!continueAfterThrottle || result.status !== 'throttled') + ) { + return; + } + + retryTimer = setTimeout(() => { + retryTimer = undefined; + void runDetection(true); + }, result.retryAfterMs); + }; + + return { + start: () => { + if (!active || started) { + return; + } + started = true; + void runDetection(false); + }, + cancel: () => { + active = false; + if (retryTimer !== undefined) { + clearTimeout(retryTimer); + retryTimer = undefined; + } + }, + }; +} diff --git a/packages/kit/src/views/FirmwareUpdate/components/FirmwareChangeLogView.tsx b/packages/kit/src/views/FirmwareUpdate/components/FirmwareChangeLogView.tsx index 8a3f00ab41e0..e1d73e6f4e64 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/FirmwareChangeLogView.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/FirmwareChangeLogView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { EFirmwareType } from '@onekeyfe/hd-shared'; import { useIntl } from 'react-intl'; @@ -25,11 +25,13 @@ import { import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { EFirmwareUpdateSteps, + useDevSettingsPersistAtom, useFirmwareUpdateStepInfoAtom, useSettingsPersistAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; import type { IBleFirmwareUpdateInfo, IBootloaderUpdateInfo, @@ -39,12 +41,66 @@ import type { } from '@onekeyhq/shared/types/device'; import { useFirmwareUpdateActions } from '../hooks/useFirmwareUpdateActions'; +import { useFirmwareVersionValid } from '../hooks/useFirmwareVersionValid'; import { FirmwareUpdateTestIDs } from '../testIDs'; +import { + getFirmwareUpdateUSBPreflightParams, + getProtocolV2FirmwareVersionDisplayItems, + getProtocolV2FirmwareVersionTitle, +} from '../utils'; import { FirmwareUpdateIntroduction } from './FirmwareUpdateIntroduction'; import { FirmwareUpdatePageFooter } from './FirmwareUpdatePageLayout'; import { FirmwareVersionProgressText } from './FirmwareVersionProgressBar'; +import type { IProtocolV2FirmwareVersionDisplayItem } from '../utils'; + +function FirmwareVersionOnlyProgressText({ + fromVersion = '', + toVersion, + active, +}: { + fromVersion?: string | null; + toVersion?: string | null; + active: boolean; +}) { + const { versionValid, unknownMessage } = useFirmwareVersionValid(); + const textColor = active ? '$text' : '$textSubdued'; + const versionTextProps = { + size: '$bodyLgMedium', + minWidth: 0, + flexShrink: 1, + numberOfLines: 1, + } as const; + const fromVersionText = versionValid(fromVersion ?? '') + ? fromVersion + : unknownMessage; + const toVersionText = versionValid(toVersion ?? '') + ? toVersion + : unknownMessage; + + return ( + + + {fromVersionText} + + {toVersion ? ( + <> + + + {toVersionText} + + + ) : null} + + ); +} + function ChangeLogMarkdown({ changelog, }: { @@ -73,9 +129,15 @@ function ChangeLogSection({ title, updateInfo, accordionValue, + versionOnly, + fromVersion, + toVersion, }: { title: string; accordionValue: string; + versionOnly?: boolean; + fromVersion?: string | null; + toVersion?: string | null; updateInfo: | IFirmwareUpdateInfo | IBleFirmwareUpdateInfo @@ -110,14 +172,7 @@ function ChangeLogSection({ > {({ open }: { open: boolean }) => ( <> - + {title} - + {versionOnly ? ( + + ) : ( + + )} + + {getProtocolV2FirmwareVersionTitle({ target: item.target, intl })} + + {item.releaseIdentifierOnly ? ( + + {item.targetVersion ?? + intl.formatMessage({ + id: ETranslations.hardware_status_update_available, + })} + + ) : ( + + )} + + ); +} + export function FirmwareChangeLogContentView({ result, ...rest @@ -174,12 +279,48 @@ export function FirmwareChangeLogContentView({ result: ICheckAllFirmwareReleaseResult | undefined; } & IStackProps) { const intl = useIntl(); - const defaultExpandedSections = useMemo(() => { - if (result?.updateInfos?.firmware?.hasUpgrade) return 'firmware'; - if (result?.updateInfos?.bootloader?.hasUpgrade) return 'bootloader'; + const [devSettings] = useDevSettingsPersistAtom(); + const protocolV2VersionItems = getProtocolV2FirmwareVersionDisplayItems( + result, + { includeComponents: devSettings.enabled }, + ); + const [safeOSItem, ...componentItems] = protocolV2VersionItems; + if (safeOSItem) { + return ( + + + + + {componentItems.map((item) => ( + + ))} + + ); + } + + const defaultExpandedSections = (() => { + if (result?.updateInfos?.firmware?.hasUpgrade) { + return 'firmware'; + } + if (result?.updateInfos?.bootloader?.hasUpgrade) { + return 'bootloader'; + } if (result?.updateInfos?.ble?.hasUpgrade) return 'ble'; return undefined; - }, [result?.updateInfos]); + })(); return ( @@ -310,23 +451,29 @@ export function FirmwareChangeLogView({ const { showCheckList } = useFirmwareUpdateActions(); const handleConfirmClick = useCallback(async () => { - const isUSBDeviceAvailable = - await backgroundApiProxy.serviceHardware.detectUSBDeviceAvailability(); - if (!isUSBDeviceAvailable) { - Dialog.show({ - icon: 'TypeCoutline', - title: intl.formatMessage({ - id: ETranslations.upgrade_use_usb, - }), - description: intl.formatMessage({ - id: ETranslations.upgrade_recommend_usb, - }), - onConfirmText: intl.formatMessage({ - id: ETranslations.global_got_it, - }), - showCancelButton: false, - }); - return; + if (platformEnv.isDesktop) { + const usbPreflightParams = + await getFirmwareUpdateUSBPreflightParams(result); + const isUSBDeviceAvailable = + await backgroundApiProxy.serviceHardware.detectUSBDeviceAvailability( + usbPreflightParams, + ); + if (!isUSBDeviceAvailable) { + Dialog.show({ + icon: 'TypeCoutline', + title: intl.formatMessage({ + id: ETranslations.upgrade_use_usb, + }), + description: intl.formatMessage({ + id: ETranslations.upgrade_recommend_usb, + }), + onConfirmText: intl.formatMessage({ + id: ETranslations.global_got_it, + }), + showCancelButton: false, + }); + return; + } } setStepInfo({ step: EFirmwareUpdateSteps.showCheckList, diff --git a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateCheckList.tsx b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateCheckList.tsx index c102354a0219..a62e5dddf802 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateCheckList.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateCheckList.tsx @@ -12,6 +12,7 @@ import { } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { toPlainErrorObject } from '@onekeyhq/shared/src/errors/utils/errorUtils'; +import { toUserFacingFirmwareUpdateError } from '@onekeyhq/shared/src/errors/utils/firmwareUpdateErrorUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import { parseFirmwareVersions } from '@onekeyhq/shared/src/logger/scopes/update/scenes/firmwareVersions'; @@ -22,6 +23,7 @@ import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; +import { isBluetoothFirmwareUpdateTransport } from '../firmwareUpdateTransportUtils'; import { FirmwareUpdateTestIDs } from '../testIDs'; export function FirmwareUpdateCheckList({ @@ -35,6 +37,9 @@ export function FirmwareUpdateCheckList({ const [, setWorkflowIsRunning] = useFirmwareUpdateWorkflowRunningAtom(); const [{ hardwareTransportType }] = useSettingsPersistAtom(); const isMountedRef = useRef(true); + const isBluetoothTransport = isBluetoothFirmwareUpdateTransport({ + isNative: platformEnv.isNative, + }); useEffect( () => () => { @@ -55,13 +60,13 @@ export function FirmwareUpdateCheckList({ { id: 'connection', label: intl.formatMessage({ - id: platformEnv.isNative + id: isBluetoothTransport ? ETranslations.update_device_connected_via_bluetooth : ETranslations.update_device_connected_via_usb, }), - emoji: platformEnv.isNative ? '📲' : '🔌', + emoji: isBluetoothTransport ? '📲' : '🔌', }, - ...(platformEnv.isNative + ...(isBluetoothTransport ? [] : [ { @@ -80,7 +85,7 @@ export function FirmwareUpdateCheckList({ }, ]), ], - [intl], + [intl, isBluetoothTransport], ); const [checkedMap, setCheckedMap] = useState>({}); const onCheckChanged = useCallback((id: string) => { @@ -218,7 +223,9 @@ export function FirmwareUpdateCheckList({ }, }); } catch (error) { - const err = toPlainErrorObject(error as any); + const err = toUserFacingFirmwareUpdateError( + toPlainErrorObject(error as any), + ); setStepInfo({ step: EFirmwareUpdateSteps.error, payload: { @@ -241,7 +248,8 @@ export function FirmwareUpdateCheckList({ fromFirmwareType: updateFirmwareInfo?.fromFirmwareType, toFirmwareType: updateFirmwareInfo?.toFirmwareType, status: 'failed', - errorCode: err?.code, + errorCode: + err?.code === undefined ? undefined : String(err.code), errorMessage: err?.message, retryCount: trackingInfo.retryCount, durationMs: trackingInfo.durationMs, diff --git a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateErrors.tsx b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateErrors.tsx index 6111dba9dfd8..651c85328414 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateErrors.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdateErrors.tsx @@ -26,6 +26,7 @@ import { type IOneKeyError, } from '@onekeyhq/shared/src/errors/types/errorTypes'; import { isHardwareErrorByCode } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { shouldHideFirmwareUpdateInternalError } from '@onekeyhq/shared/src/errors/utils/firmwareUpdateErrorUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { openUrlExternal } from '@onekeyhq/shared/src/utils/openUrlUtils'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; @@ -216,6 +217,10 @@ export function useFirmwareUpdateErrors({ const defaultRetryText = intl.formatMessage({ id: ETranslations.global_retry, }); + const firmwareUpdateCode = error?.payload?.params?.firmwareUpdateCode; + const hasInternalFirmwareUpdateFailureCode = + typeof firmwareUpdateCode === 'string' && + firmwareUpdateCode.startsWith('Firmware'); return useMemo<{ content: React.ReactNode; detail?: React.ReactNode; @@ -250,6 +255,26 @@ export function useFirmwareUpdateErrors({ }; } + if ( + isHardwareErrorByCode({ + error, + code: HardwareErrorCode.BleUnavailableWhileUsbConnected, + }) + ) { + return { + content: ( + + ), + onRetryHandler: onRetry, + retryText: defaultRetryText, + }; + } + if ( isHardwareErrorByCode({ error, @@ -273,6 +298,30 @@ export function useFirmwareUpdateErrors({ }; } + if ( + isHardwareErrorByCode({ + error, + code: HardwareErrorCode.FirmwareVerificationFailed, + }) || + hasInternalFirmwareUpdateFailureCode + ) { + return { + content: ( + + ), + onRetryHandler: onRetry, + retryText: defaultRetryText, + }; + } + if ( isHardwareErrorByCode({ error, @@ -476,6 +525,25 @@ export function useFirmwareUpdateErrors({ }; } + if (shouldHideFirmwareUpdateInternalError(error)) { + return { + content: ( + + ), + onRetryHandler: onRetry, + retryText: defaultRetryText, + }; + } + if (error) { let message = error?.message; @@ -503,7 +571,15 @@ export function useFirmwareUpdateErrors({ content: null, retryText: defaultRetryText, }; - }, [intl, error, lastFirmwareTipMessage, defaultRetryText, onRetry, result]); + }, [ + intl, + error, + lastFirmwareTipMessage, + defaultRetryText, + onRetry, + result, + hasInternalFirmwareUpdateFailureCode, + ]); } function WorkflowErrors({ diff --git a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdatePageLayout.tsx b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdatePageLayout.tsx index 5247d7a35491..a91cc1110b7b 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdatePageLayout.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/FirmwareUpdatePageLayout.tsx @@ -1,6 +1,5 @@ import { EDeviceType } from '@onekeyfe/hd-shared'; import { useIntl } from 'react-intl'; -import { useWindowDimensions } from 'react-native'; import type { IStackNavigationOptions, @@ -13,13 +12,15 @@ import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/devi import { DeviceAvatarWithColor } from '../../../components/DeviceAvatar'; import useAppNavigation from '../../../hooks/useAppNavigation'; -import { getTargetFirmwareTypeLabel } from '../utils'; +import { + getFirmwareUpdateDeviceTitle, + getTargetFirmwareTypeLabel, +} from '../utils'; export function FirmwareUpdatePageHeaderTitle(props: { result: ICheckAllFirmwareReleaseResult | undefined; }) { const intl = useIntl(); - const { width: windowWidth } = useWindowDimensions(); const { result } = props; if (!result) { return null; @@ -44,29 +45,8 @@ export function FirmwareUpdatePageHeaderTitle(props: { }, ); } else { - title = result.deviceName; + title = getFirmwareUpdateDeviceTitle(result); } - if (platformEnv.isNativeIOS) { - const titleWidth = Math.max(0, windowWidth - 220); - - return ( - - - - - - - {title} - - - - ); - } - return ( @@ -84,14 +64,16 @@ export function FirmwareUpdatePageHeaderTitle(props: { > {title} - - {result.deviceBleName} - + {result.deviceBleName ? ( + + {result.deviceBleName} + + ) : null} ); } diff --git a/packages/kit/src/views/FirmwareUpdate/components/FirmwareVersionProgressBar.tsx b/packages/kit/src/views/FirmwareUpdate/components/FirmwareVersionProgressBar.tsx index 3809600da65c..09a9f29d14ee 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/FirmwareVersionProgressBar.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/FirmwareVersionProgressBar.tsx @@ -69,13 +69,7 @@ export function FirmwareVersionProgressText({ : unknownMessage; return ( - + {fromVersionText} diff --git a/packages/kit/src/views/FirmwareUpdate/components/HomeFirmwareUpdateDetect.tsx b/packages/kit/src/views/FirmwareUpdate/components/HomeFirmwareUpdateDetect.tsx index cb991d4d4220..6e3d21d183ed 100644 --- a/packages/kit/src/views/FirmwareUpdate/components/HomeFirmwareUpdateDetect.tsx +++ b/packages/kit/src/views/FirmwareUpdate/components/HomeFirmwareUpdateDetect.tsx @@ -8,6 +8,7 @@ import { EAccountSelectorSceneName } from '@onekeyhq/shared/types'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; import { AccountSelectorProviderMirror } from '../../../components/AccountSelector'; import { useActiveAccount } from '../../../states/jotai/contexts/accountSelector'; +import { createActiveAccountFirmwareUpdateDetector } from '../activeAccountFirmwareUpdateDetection'; function HomeFirmwareUpdateDetectCmp() { const { activeAccount } = useActiveAccount({ num: 0 }); @@ -26,16 +27,24 @@ function HomeFirmwareUpdateDetectCmp() { ); useEffect(() => { - if (isHardware && connectId && isFocused) { - // TODO check firmware update only for current device or all device? - // TODO only works for home scene, TODO throttle - // get sdk instance will register device events automatically - void backgroundApiProxy.serviceFirmwareUpdate.detectActiveAccountFirmwareUpdates( - { - connectId, - }, - ); + if (!isHardware || !connectId || !isFocused) { + return undefined; } + + // TODO check firmware update only for current device or all device? + // TODO only works for home scene, TODO throttle + // get sdk instance will register device events automatically + const detector = createActiveAccountFirmwareUpdateDetector({ + detect: () => + backgroundApiProxy.serviceFirmwareUpdate.detectActiveAccountFirmwareUpdates( + { + connectId, + }, + ), + }); + detector.start(); + + return detector.cancel; }, [isHardware, connectId, isFocused]); return null; diff --git a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateErrorV2.tsx b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateErrorV2.tsx index e05e1bbdac41..92eb11867648 100644 --- a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateErrorV2.tsx +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateErrorV2.tsx @@ -15,6 +15,7 @@ import { type IOneKeyError, } from '@onekeyhq/shared/src/errors/types/errorTypes'; import { isHardwareErrorByCode } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { shouldHideFirmwareUpdateInternalError } from '@onekeyhq/shared/src/errors/utils/firmwareUpdateErrorUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { EFirmwareUpdateTipMessages } from '@onekeyhq/shared/types/device'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; @@ -71,6 +72,19 @@ export function useFirmwareUpdateErrors({ }; } + if ( + isHardwareErrorByCode({ + error, + code: HardwareErrorCode.BleUnavailableWhileUsbConnected, + }) + ) { + return { + errorMessage: intl.formatMessage({ + id: ETranslations.troubleshooting_desktop_bluetooth_usb_priority, + }), + }; + } + if ( isHardwareErrorByCode({ error, @@ -159,6 +173,14 @@ export function useFirmwareUpdateErrors({ }; } + if (shouldHideFirmwareUpdateInternalError(error)) { + return { + errorMessage: intl.formatMessage({ + id: ETranslations.update_device_disconnected_desc, + }), + }; + } + if (error) { let message = error?.message; diff --git a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx index e0ad6e2845f9..4bd9d7ea88a3 100644 --- a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx @@ -23,20 +23,38 @@ import { } from '@onekeyhq/components'; import { EFirmwareUpdateSteps, + useDevSettingsPersistAtom, useFirmwareUpdateResultVerifyAtom, + useFirmwareUpdateRetryAtom, useFirmwareUpdateStepInfoAtom, useHardwareUiStateAtom, + useHardwareUiStateCompletedAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { EFirmwareUpdateTipMessages } from '@onekeyhq/shared/types/device'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; +import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; import { FirmwareUpdatePromptWebUsbDevice } from '../components/FirmwareUpdatePromptWebUsbDevice'; import { useFirmwareVersionValid } from '../hooks/useFirmwareVersionValid'; +import { + getProtocolV2FirmwareVersionDisplayItems, + getProtocolV2FirmwareVersionTitle, + isPro2SafeOSFirmwareUpdate, +} from '../utils'; + +import { + PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, + PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, + calculateProgressInRange, + getNextEstimatedFirmwareProgress, + normalizeFirmwareUpdateProgressType, +} from './firmwareUpdateProgressUtils'; interface IFirmwareUpdateVersionInfo { fromVersion: string; @@ -45,6 +63,8 @@ interface IFirmwareUpdateVersionInfo { hasUpgrade: boolean; title: string; githubReleaseUrl?: string; + releaseIdentifierOnly?: boolean; + currentVersionOnly?: boolean; } interface IFirmwareUpdateVersions { @@ -68,23 +88,6 @@ type IProgressConfigItem = { const checkingMaxProgress = 10; -const calculateProgressInRange = ({ - startAt, - maxAt, - currentProgress, -}: { - startAt: number; - maxAt: number; - currentProgress: number | null | undefined; -}) => { - let newProgress = - startAt + (currentProgress ?? 0) * ((maxAt - startAt) / 100); - if (newProgress >= maxAt) { - newProgress = maxAt; - } - return newProgress; -}; - function FirmwareUpdateVersionItem({ title, fromVersion, @@ -93,6 +96,8 @@ function FirmwareUpdateVersionItem({ githubReleaseUrl, isDone, isVerified, + releaseIdentifierOnly, + currentVersionOnly, }: { title: string; fromVersion: string; @@ -101,10 +106,52 @@ function FirmwareUpdateVersionItem({ githubReleaseUrl?: string; isDone?: boolean; isVerified?: boolean; + releaseIdentifierOnly?: boolean; + currentVersionOnly?: boolean; }) { const { versionValid, unknownMessage } = useFirmwareVersionValid(); + if (releaseIdentifierOnly) { + return ( + + + {title} + + + {toVersion} + + + ); + } + if (currentVersionOnly) { + return ( + + + {title} + + + {versionValid(fromVersion) ? fromVersion : unknownMessage} + + + ); + } const renderToVersion = () => { - if (!isDone && !isVerified) { + if ((!isDone && !isVerified) || !verifyVersion) { return ( {versionValid(toVersion) ? toVersion : unknownMessage} @@ -204,6 +251,8 @@ export function FirmwareUpdateProgressBarView({ toVersion={version.info.toVersion} verifyVersion={version.info.verifyVersion ?? ''} githubReleaseUrl={version.info.githubReleaseUrl} + releaseIdentifierOnly={version.info.releaseIdentifierOnly} + currentVersionOnly={version.info.currentVersionOnly} /> {index < versions.length - 1 ? : null} @@ -236,6 +285,9 @@ export function FirmwareUpdateProgressBarV2({ const intl = useIntl(); const [stepInfo, setStepInfo] = useFirmwareUpdateStepInfoAtom(); const [state] = useHardwareUiStateAtom(); + const [completedState] = useHardwareUiStateCompletedAtom(); + const [retryInfo] = useFirmwareUpdateRetryAtom(); + const [devSettings] = useDevSettingsPersistAtom(); const [progress, setProgress] = useState(1); const [isDoneInternal, setIsDoneInternal] = useState(!!isDone); @@ -250,8 +302,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); @@ -259,6 +321,7 @@ export function FirmwareUpdateProgressBarV2({ const updateProgress = useCallback( (type: IProgressType) => { + const normalizedType = normalizeFirmwareUpdateProgressType(type); const progressConfig: IProgressConfigItem[] = [ { type: ['checking'], @@ -298,14 +361,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 +384,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, @@ -349,7 +425,9 @@ export function FirmwareUpdateProgressBarV2({ }, ]; - const index = progressConfig.findIndex((c) => c.type.includes(type)); + const index = progressConfig.findIndex((c) => + c.type.includes(normalizedType), + ); if (index >= 0) { const item = progressConfig[index]; const itemProgress = item.progress(); @@ -361,7 +439,7 @@ export function FirmwareUpdateProgressBarV2({ newProgress, itemProgress, currentProgress, - type, + type: normalizedType, }); progressRef.current = newProgress; return newProgress; @@ -377,7 +455,7 @@ export function FirmwareUpdateProgressBarV2({ } } }, - [intl], + [intl, result], ); const updateProgressRef = useRef(updateProgress); @@ -401,9 +479,24 @@ export function FirmwareUpdateProgressBarV2({ }, [isDone]); useEffect(() => { + if (stepInfo.step === EFirmwareUpdateSteps.installing) { + if (!lastFirmwareTipMessage && !isNumber(firmwareProgress)) { + updateProgressRef.current(EFirmwareUpdateTipMessages.StartTransferData); + } + return; + } + if (stepInfo.step !== EFirmwareUpdateSteps.updateStart) { + return; + } + if (stepInfo.payload.isDownloadingArtifacts) { + updateProgressRef.current( + EFirmwareUpdateTipMessages.StartDownloadFirmware, + ); + return; + } updateProgressRef.current('checking'); setDesc(defaultDesc()); - }, [defaultDesc]); + }, [defaultDesc, firmwareProgress, lastFirmwareTipMessage, stepInfo]); const installProgressList = useRef([]); useEffect(() => { @@ -414,13 +507,50 @@ export function FirmwareUpdateProgressBarV2({ useEffect(() => { if (isNumber(firmwareProgress)) { + if ( + firmwareProgress === 0 && + firmwareProgressType === 'installingFirmware' && + lastFirmwareTipMessage === EFirmwareUpdateTipMessages.ConfirmOnDevice + ) { + return; + } updateProgressRef.current( firmwareProgressType === 'installingFirmware' ? 'installing' : EFirmwareUpdateTipMessages.StartTransferData, ); } - }, [firmwareProgress, firmwareProgressType]); + }, [firmwareProgress, firmwareProgressType, lastFirmwareTipMessage]); + + const shouldEstimatePro2Progress = + isProtocolV2ProductType(result?.deviceType) && + 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); @@ -441,52 +571,88 @@ export function FirmwareUpdateProgressBarV2({ const upgradeVersions = useMemo(() => { if (!result?.updateInfos) return []; + const protocolV2VersionItems = getProtocolV2FirmwareVersionDisplayItems( + result, + { includeComponents: devSettings.enabled }, + ); + if (protocolV2VersionItems.length > 0) { + return protocolV2VersionItems.map((item) => { + let verifyVersion: string | undefined; + if (item.target === 'safeos') { + verifyVersion = resultVerifyVersions?.finalFirmwareVersion; + } else if (item.target === 'boot') { + verifyVersion = resultVerifyVersions?.finalBootloaderVersion; + } else if (item.target === 'coprocessor') { + verifyVersion = resultVerifyVersions?.finalBleVersion; + } + const title = getProtocolV2FirmwareVersionTitle({ + target: item.target, + intl, + }); + return { + type: item.target, + info: { + title, + fromVersion: item.currentVersion ?? '', + toVersion: item.targetVersion ?? '', + verifyVersion, + hasUpgrade: Boolean(item.targetVersion), + releaseIdentifierOnly: item.releaseIdentifierOnly, + currentVersionOnly: item.target === 'safeos' && !item.targetVersion, + }, + }; + }); + } + const versions: IFirmwareUpdateVersions[] = []; + const firmwareInfo = result.updateInfos.firmware; + const bootloaderInfo = result.updateInfos.bootloader; + const bleInfo = result.updateInfos.ble; - if (result.updateInfos.firmware?.hasUpgrade) { + if (firmwareInfo?.hasUpgrade) { versions.push({ type: 'Firmware', info: { title: intl.formatMessage({ id: ETranslations.global_firmware }), - fromVersion: result.updateInfos.firmware.fromVersion ?? '', - toVersion: result.updateInfos.firmware.toVersion ?? '', + 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 (bootloaderInfo?.hasUpgrade) { versions.push({ type: 'Bootloader', info: { title: intl.formatMessage({ id: ETranslations.global_bootloader }), - fromVersion: result.updateInfos.bootloader.fromVersion ?? '', - toVersion: result.updateInfos.bootloader.toVersion ?? '', + fromVersion: bootloaderInfo?.fromVersion ?? '', + toVersion: bootloaderInfo?.toVersion ?? '', verifyVersion: resultVerifyVersions?.finalBootloaderVersion, hasUpgrade: true, - githubReleaseUrl: result.updateInfos.bootloader.githubReleaseUrl, + githubReleaseUrl: bootloaderInfo?.githubReleaseUrl, }, }); } - if (result.updateInfos.ble?.hasUpgrade) { + if (bleInfo?.hasUpgrade) { versions.push({ type: 'Bluetooth', info: { title: intl.formatMessage({ id: ETranslations.global_bluetooth }), - fromVersion: result.updateInfos.ble.fromVersion ?? '', - toVersion: result.updateInfos.ble.toVersion ?? '', + fromVersion: bleInfo?.fromVersion ?? '', + toVersion: bleInfo?.toVersion ?? '', verifyVersion: resultVerifyVersions?.finalBleVersion, hasUpgrade: true, - githubReleaseUrl: result.updateInfos.ble.githubReleaseUrl, + githubReleaseUrl: bleInfo?.githubReleaseUrl, }, }); } return versions; - }, [result, intl, resultVerifyVersions]); + }, [devSettings.enabled, result, intl, resultVerifyVersions]); const previousStepInfo = useRef(stepInfo); useEffect(() => { 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..e5503df1195f --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.test.ts @@ -0,0 +1,74 @@ +import { EFirmwareUpdateTipMessages } from '@onekeyhq/shared/types/device'; + +import { + PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, + PRO2_RECONNECT_ESTIMATED_PROGRESS_MAX, + calculateProgressInRange, + getNextEstimatedFirmwareProgress, + normalizeFirmwareUpdateProgressType, +} from './firmwareUpdateProgressUtils'; + +describe('firmwareUpdateProgressUtils', () => { + test('将 bootloader 就绪事件归一到重启阶段,避免 UI 直接进入传输阶段', () => { + expect( + normalizeFirmwareUpdateProgressType( + EFirmwareUpdateTipMessages.GoToBootloaderSuccess, + ), + ).toBe(EFirmwareUpdateTipMessages.AutoRebootToBootloader); + }); + + 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..94d9580a3a4e --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/firmwareUpdateProgressUtils.ts @@ -0,0 +1,42 @@ +import { EFirmwareUpdateTipMessages } from '@onekeyhq/shared/types/device'; + +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 normalizeFirmwareUpdateProgressType(type: T) { + return type === EFirmwareUpdateTipMessages.GoToBootloaderSuccess + ? EFirmwareUpdateTipMessages.AutoRebootToBootloader + : type; +} + +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/firmwareUpdateTransportUtils.test.ts b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateTransportUtils.test.ts new file mode 100644 index 000000000000..279a00191155 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateTransportUtils.test.ts @@ -0,0 +1,19 @@ +import { isBluetoothFirmwareUpdateTransport } from './firmwareUpdateTransportUtils'; + +describe('isBluetoothFirmwareUpdateTransport', () => { + it('treats native transport as Bluetooth', () => { + expect( + isBluetoothFirmwareUpdateTransport({ + isNative: true, + }), + ).toBe(true); + }); + + it('keeps desktop firmware updates on the USB checklist', () => { + expect( + isBluetoothFirmwareUpdateTransport({ + isNative: false, + }), + ).toBe(false); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/firmwareUpdateTransportUtils.ts b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateTransportUtils.ts new file mode 100644 index 000000000000..1bcabd21c01a --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateTransportUtils.ts @@ -0,0 +1,7 @@ +export function isBluetoothFirmwareUpdateTransport({ + isNative, +}: { + isNative: boolean | undefined; +}) { + return Boolean(isNative); +} diff --git a/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.test.ts b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.test.ts new file mode 100644 index 000000000000..d1580178c389 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.test.ts @@ -0,0 +1,73 @@ +import { + getFirmwareUpdateWorkflowAlivePageCountForTest, + releaseFirmwareUpdateWorkflowPage, + resetFirmwareUpdateWorkflowLifetimeForTest, + retainFirmwareUpdateWorkflowPage, +} from './firmwareUpdateWorkflowLifetime'; + +describe('firmwareUpdateWorkflowLifetime', () => { + beforeEach(() => { + jest.useFakeTimers(); + resetFirmwareUpdateWorkflowLifetimeForTest(); + }); + + afterEach(() => { + resetFirmwareUpdateWorkflowLifetimeForTest(); + jest.useRealTimers(); + }); + + it('does not exit when navigating from changelog to install', () => { + const onReallyLeave = jest.fn(); + + retainFirmwareUpdateWorkflowPage(); + retainFirmwareUpdateWorkflowPage(); + releaseFirmwareUpdateWorkflowPage(onReallyLeave); + jest.advanceTimersByTime(1000); + + expect(onReallyLeave).not.toHaveBeenCalled(); + expect(getFirmwareUpdateWorkflowAlivePageCountForTest()).toBe(1); + }); + + it('does not exit when the install page remounts after a device reconnect', () => { + const onReallyLeave = jest.fn(); + + retainFirmwareUpdateWorkflowPage(); + releaseFirmwareUpdateWorkflowPage(onReallyLeave); + retainFirmwareUpdateWorkflowPage(); + jest.advanceTimersByTime(1000); + + expect(onReallyLeave).not.toHaveBeenCalled(); + expect(getFirmwareUpdateWorkflowAlivePageCountForTest()).toBe(1); + }); + + it('exits only after the last firmware-update page stays gone', () => { + const onReallyLeave = jest.fn(); + + retainFirmwareUpdateWorkflowPage(); + releaseFirmwareUpdateWorkflowPage(onReallyLeave); + jest.advanceTimersByTime(499); + expect(onReallyLeave).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(onReallyLeave).toHaveBeenCalledTimes(1); + }); + + it('keeps the install-page cancel when an error returns to changelog', () => { + const changelogLeave = jest.fn(); + const installLeave = jest.fn(); + + retainFirmwareUpdateWorkflowPage(); + retainFirmwareUpdateWorkflowPage(); + releaseFirmwareUpdateWorkflowPage(installLeave); + jest.advanceTimersByTime(1000); + + expect(installLeave).not.toHaveBeenCalled(); + expect(getFirmwareUpdateWorkflowAlivePageCountForTest()).toBe(1); + + releaseFirmwareUpdateWorkflowPage(changelogLeave); + jest.advanceTimersByTime(500); + + expect(installLeave).toHaveBeenCalledTimes(1); + expect(changelogLeave).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.ts b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.ts new file mode 100644 index 000000000000..31a92bbc5210 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/firmwareUpdateWorkflowLifetime.ts @@ -0,0 +1,50 @@ +const FIRMWARE_UPDATE_PAGE_LEAVE_DELAY_MS = 500; + +let alivePages = 0; +let leaveTimer: ReturnType | undefined; +const pendingLeaveCallbacks: Array<() => void | Promise> = []; + +export function resetFirmwareUpdateWorkflowLifetimeForTest() { + alivePages = 0; + pendingLeaveCallbacks.length = 0; + if (leaveTimer) { + clearTimeout(leaveTimer); + leaveTimer = undefined; + } +} + +export function retainFirmwareUpdateWorkflowPage() { + alivePages += 1; + if (leaveTimer) { + clearTimeout(leaveTimer); + leaveTimer = undefined; + } +} + +export function releaseFirmwareUpdateWorkflowPage( + onReallyLeave?: () => void | Promise, +) { + alivePages = Math.max(0, alivePages - 1); + if (onReallyLeave) { + pendingLeaveCallbacks.push(onReallyLeave); + } + if (alivePages > 0) { + return; + } + if (leaveTimer) { + clearTimeout(leaveTimer); + } + leaveTimer = setTimeout(() => { + leaveTimer = undefined; + if (alivePages === 0) { + const callbacks = pendingLeaveCallbacks.splice(0); + void Promise.all( + callbacks.map((callback) => Promise.resolve(callback())), + ); + } + }, FIRMWARE_UPDATE_PAGE_LEAVE_DELAY_MS); +} + +export function getFirmwareUpdateWorkflowAlivePageCountForTest() { + return alivePages; +} diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts new file mode 100644 index 000000000000..2b5e30a18a47 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts @@ -0,0 +1,71 @@ +import type { IDialogInstance } from '@onekeyhq/components'; + +import { BootloaderModeDialogManager } from './bootloaderModeDialogManager'; + +function createDialogMock() { + let onClose: (() => void) | undefined; + let onUpdate: (() => Promise) | undefined; + const dialog: IDialogInstance = { + close: jest.fn(async () => { + onClose?.(); + }), + getForm: jest.fn(() => undefined), + isExist: jest.fn(() => true), + }; + const createDialog = jest.fn((params) => { + onClose = params.onClose; + onUpdate = params.onUpdate; + return dialog; + }); + + return { + createDialog, + dialog, + runUpdate: async () => onUpdate?.(), + }; +} + +describe('BootloaderModeDialogManager', () => { + it('reuses the active dialog across independent callers', async () => { + const manager = new BootloaderModeDialogManager(); + const first = createDialogMock(); + const second = createDialogMock(); + const firstUpdate = jest.fn(async () => undefined); + const secondUpdate = jest.fn(async () => undefined); + + expect( + manager.show({ + createDialog: first.createDialog, + onUpdate: firstUpdate, + }), + ).toBe(first.dialog); + expect( + manager.show({ + createDialog: second.createDialog, + onUpdate: secondUpdate, + }), + ).toBe(first.dialog); + expect(first.createDialog).toHaveBeenCalledTimes(1); + expect(second.createDialog).not.toHaveBeenCalled(); + + await first.runUpdate(); + expect(firstUpdate).not.toHaveBeenCalled(); + expect(secondUpdate).toHaveBeenCalledTimes(1); + }); + + it('allows a new dialog after the active one closes', async () => { + const manager = new BootloaderModeDialogManager(); + const first = createDialogMock(); + const second = createDialogMock(); + const onUpdate = jest.fn(async () => undefined); + + manager.show({ createDialog: first.createDialog, onUpdate }); + await manager.close(); + + expect(first.dialog.close).toHaveBeenCalledTimes(1); + expect(manager.show({ createDialog: second.createDialog, onUpdate })).toBe( + second.dialog, + ); + expect(second.createDialog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts new file mode 100644 index 000000000000..9469e6b8464b --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts @@ -0,0 +1,50 @@ +import type { IDialogInstance } from '@onekeyhq/components'; + +type ICreateBootloaderModeDialog = (params: { + onClose: () => void; + onUpdate: () => Promise; +}) => IDialogInstance; + +export class BootloaderModeDialogManager { + private activeDialog: IDialogInstance | undefined; + + private updateAction: (() => Promise) | undefined; + + show({ + createDialog, + onUpdate, + }: { + createDialog: ICreateBootloaderModeDialog; + onUpdate: () => Promise; + }) { + this.updateAction = onUpdate; + if (this.activeDialog) { + return this.activeDialog; + } + + const dialog = createDialog({ + onClose: () => { + if (this.activeDialog === dialog) { + this.activeDialog = undefined; + this.updateAction = undefined; + } + }, + onUpdate: async () => { + await this.updateAction?.(); + }, + }); + this.activeDialog = dialog; + return dialog; + } + + async close() { + const dialog = this.activeDialog; + if (!dialog) { + return; + } + + await dialog.close(); + } +} + +export const bootloaderModeDialogManager = new BootloaderModeDialogManager(); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx index 99aba43800a5..b4cb792681aa 100644 --- a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx +++ b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx @@ -1,10 +1,17 @@ import { useCallback } from 'react'; +import { + type EDeviceType, + type EFirmwareType, + HardwareErrorCode, +} from '@onekeyfe/hd-shared'; import { StackActions } from '@react-navigation/routers'; import { useIntl } from 'react-intl'; import { useThrottledCallback } from 'use-debounce'; import { Dialog, resetToRoute, rootNavigationRef } from '@onekeyhq/components'; +import type { IOneKeyError } from '@onekeyhq/shared/src/errors/types/errorTypes'; +import { isHardwareErrorByCode } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { @@ -21,8 +28,11 @@ import useAppNavigation from '../../../hooks/useAppNavigation'; import { FirmwareUpdateCheckList } from '../components/FirmwareUpdateCheckList'; import { getTargetFirmwareTypeLabel } from '../utils'; +import { bootloaderModeDialogManager } from './bootloaderModeDialogManager'; + import type { AllFirmwareRelease } from '@onekeyfe/hd-core'; -import type { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; + +export type IBootloaderModeDialogHost = Pick; export function useFirmwareUpdateActions() { const intl = useIntl(); @@ -100,13 +110,22 @@ export function useFirmwareUpdateActions() { return; } - if (connectId) { + let resolvedConnectId = connectId; + if (resolvedConnectId) { try { - await backgroundApiProxy.serviceHardware.checkDeviceReachableForFirmwareUpdate( - { connectId }, - ); - } catch { - return; + resolvedConnectId = + await backgroundApiProxy.serviceHardware.checkDeviceReachableForFirmwareUpdate( + { connectId: resolvedConnectId }, + ); + } catch (error) { + if ( + !isHardwareErrorByCode({ + error: error as IOneKeyError, + code: HardwareErrorCode.BleUnavailableWhileUsbConnected, + }) + ) { + return; + } } } @@ -117,7 +136,7 @@ export function useFirmwareUpdateActions() { params: { screen: EModalFirmwareUpdateRoutes.ChangeLog, params: { - connectId, + connectId: resolvedConnectId, firmwareType, baseReleaseInfo, }, @@ -129,7 +148,7 @@ export function useFirmwareUpdateActions() { navigation.pushModal(EModalRoutes.FirmwareUpdateModal, { screen: EModalFirmwareUpdateRoutes.ChangeLog, params: { - connectId, + connectId: resolvedConnectId, firmwareType, baseReleaseInfo, }, @@ -163,10 +182,12 @@ export function useFirmwareUpdateActions() { connectId, existsFirmware, onBeforeUpdate, + dialogHost = Dialog, }: { connectId: string | undefined; existsFirmware?: boolean; onBeforeUpdate?: () => Promise; + dialogHost?: IBootloaderModeDialogHost; }) => { const handleUpdateClick = async () => { // Call onBeforeUpdate callback if provided (for onboarding USB preparation) @@ -182,43 +203,57 @@ export function useFirmwareUpdateActions() { }; if (existsFirmware) { - Dialog.show({ - title: intl.formatMessage({ - id: ETranslations.update_device_in_bootloader_mode, - }), - description: intl.formatMessage({ - id: ETranslations.update_hardware_wallet_in_bootloader_mode_restart, - }), - dismissOnOverlayPress: false, - onConfirm: async ({ close }) => { - void close?.(); - }, - onConfirmText: intl.formatMessage({ - id: ETranslations.global_got_it, - }), - onCancel: async () => { - await handleUpdateClick(); - }, - onCancelText: intl.formatMessage({ - id: ETranslations.update_update_now, - }), + bootloaderModeDialogManager.show({ + onUpdate: handleUpdateClick, + createDialog: ({ onClose, onUpdate }) => + dialogHost.show({ + trackID: 'firmware-bootloader-mode-dialog', + title: intl.formatMessage({ + id: ETranslations.update_device_in_bootloader_mode, + }), + description: intl.formatMessage({ + id: ETranslations.update_hardware_wallet_in_bootloader_mode_restart, + }), + dismissOnOverlayPress: false, + onConfirm: async ({ close }) => { + void close?.(); + }, + onConfirmText: intl.formatMessage({ + id: ETranslations.global_got_it, + }), + onCancel: onUpdate, + onCancelText: intl.formatMessage({ + id: ETranslations.update_update_now, + }), + cancelButtonProps: { + testID: 'firmware-bootloader-mode-update-btn', + }, + onClose, + }), }); } else { - Dialog.show({ - title: intl.formatMessage({ - id: ETranslations.update_device_in_bootloader_mode, - }), - description: intl.formatMessage({ - id: ETranslations.update_hardware_wallet_in_bootloader_mode, - }), - dismissOnOverlayPress: false, - showCancelButton: false, - onConfirm: async () => { - await handleUpdateClick(); - }, - onConfirmText: intl.formatMessage({ - id: ETranslations.update_update_now, - }), + bootloaderModeDialogManager.show({ + onUpdate: handleUpdateClick, + createDialog: ({ onClose, onUpdate }) => + dialogHost.show({ + trackID: 'firmware-bootloader-mode-dialog', + title: intl.formatMessage({ + id: ETranslations.update_device_in_bootloader_mode, + }), + description: intl.formatMessage({ + id: ETranslations.update_hardware_wallet_in_bootloader_mode, + }), + dismissOnOverlayPress: false, + showCancelButton: false, + onConfirm: onUpdate, + onConfirmText: intl.formatMessage({ + id: ETranslations.update_update_now, + }), + confirmButtonProps: { + testID: 'firmware-bootloader-mode-update-btn', + }, + onClose, + }), }); } }, diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateHooks.ts b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateHooks.ts index 4a917552544e..0d87e5bb104f 100644 --- a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateHooks.ts +++ b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateHooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useIntl } from 'react-intl'; import { Alert, BackHandler } from 'react-native'; @@ -13,6 +13,10 @@ import type { import useAppNavigation from '../../../hooks/useAppNavigation'; import { useAppRoute } from '../../../hooks/useAppRoute'; +import { + releaseFirmwareUpdateWorkflowPage, + retainFirmwareUpdateWorkflowPage, +} from '../firmwareUpdateWorkflowLifetime'; import { useFirmwareUpdateActions } from './useFirmwareUpdateActions'; @@ -141,6 +145,22 @@ export function useAppExitPrevent({ // TODO } +export function useFirmwareUpdateWorkflowLifetime({ + onReallyLeave, +}: { + onReallyLeave?: () => void | Promise; +} = {}) { + const onReallyLeaveRef = useRef(onReallyLeave); + onReallyLeaveRef.current = onReallyLeave; + + useEffect(() => { + retainFirmwareUpdateWorkflowPage(); + return () => { + releaseFirmwareUpdateWorkflowPage(() => onReallyLeaveRef.current?.()); + }; + }, []); +} + export function useExtensionUpdatingFromExpandTab() { const route = useAppRoute< IModalFirmwareUpdateParamList, diff --git a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateChangeLog.tsx b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateChangeLog.tsx index 88fb60f1c927..f0fbebab3fe2 100644 --- a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateChangeLog.tsx +++ b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateChangeLog.tsx @@ -1,12 +1,12 @@ -import { useMemo, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; -import { HeaderButtonGroup, Page, SizableText } from '@onekeyhq/components'; +import { Page } from '@onekeyhq/components'; import { EFirmwareUpdateSteps, useFirmwareUpdateStepInfoAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { toPlainErrorObject } from '@onekeyhq/shared/src/errors/utils/errorUtils'; -import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { toUserFacingFirmwareUpdateError } from '@onekeyhq/shared/src/errors/utils/firmwareUpdateErrorUtils'; import type { EModalFirmwareUpdateRoutes, IModalFirmwareUpdateParamList, @@ -32,6 +32,7 @@ import { FirmwareUpdatePageLayout, } from '../components/FirmwareUpdatePageLayout'; import { FirmwareUpdateWarningMessage } from '../components/FirmwareUpdateWarningMessage'; +import { useFirmwareUpdateWorkflowLifetime } from '../hooks/useFirmwareUpdateHooks'; function PageFirmwareUpdateChangeLog() { const route = useAppRoute< @@ -41,6 +42,7 @@ function PageFirmwareUpdateChangeLog() { const connectId = route?.params?.connectId; const firmwareType = route?.params?.firmwareType; const baseReleaseInfo = route?.params?.baseReleaseInfo; + const [activeConnectId, setActiveConnectId] = useState(connectId); const [stepInfo, setStepInfo] = useFirmwareUpdateStepInfoAtom(); @@ -62,11 +64,13 @@ function PageFirmwareUpdateChangeLog() { const { result, run, isLoading } = usePromiseResult( async () => { try { - const compatibleConnectId = - await backgroundApiProxy.serviceHardware.getCompatibleConnectId({ + const resolvedTransport = + await backgroundApiProxy.serviceHardware.resolveHardwareTransport({ connectId, hardwareCallContext: EHardwareCallContext.UPDATE_FIRMWARE, }); + const compatibleConnectId = resolvedTransport.connectId; + setActiveConnectId(compatibleConnectId); const r = await backgroundApiProxy.serviceFirmwareUpdate.checkAllFirmwareRelease( @@ -74,6 +78,7 @@ function PageFirmwareUpdateChangeLog() { connectId: compatibleConnectId, firmwareType, baseReleaseInfoCache: baseReleaseInfo, + resolvedTransportType: resolvedTransport.transportType, }, ); if (r?.hasUpgrade) { @@ -89,7 +94,9 @@ function PageFirmwareUpdateChangeLog() { setStepInfo({ step: EFirmwareUpdateSteps.checkReleaseError, payload: { - error: toPlainErrorObject(error as any), + error: toUserFacingFirmwareUpdateError( + toPlainErrorObject(error as any), + ), }, }); } @@ -103,17 +110,21 @@ function PageFirmwareUpdateChangeLog() { const shouldShowChangeLog = stepInfo.step === EFirmwareUpdateSteps.showChangeLog || stepInfo.step === EFirmwareUpdateSteps.showCheckList; + const isWorkflowError = + stepInfo.step === EFirmwareUpdateSteps.error || + stepInfo.step === EFirmwareUpdateSteps.checkReleaseError; + + useFirmwareUpdateWorkflowLifetime({ + onReallyLeave: () => + backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(), + }); const content = useMemo(() => { - // keep change log modal content when install modal back - if (confirmUpdateResult.current) { - return ; - } if (isLoading) { return ( <> - + ); } @@ -133,6 +144,10 @@ function PageFirmwareUpdateChangeLog() { ); } + // keep change log modal content when install modal back + if (confirmUpdateResult.current) { + return ; + } if (shouldShowChangeLog) { return ( ; }, [ - connectId, + activeConnectId, isLoading, result, run, @@ -155,37 +170,15 @@ function PageFirmwareUpdateChangeLog() { ]); return ( - { - console.log('PageFirmwareUpdateChangeLog unmounted'); - await backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(); - }} - > + ) : undefined } - headerRight={ - platformEnv.isNativeIOS && shouldShowChangeLog - ? () => ( - - - {result?.deviceBleName} - - - ) - : undefined - } containerStyle={{ - p: - stepInfo.step === EFirmwareUpdateSteps.checkReleaseError ? '$5' : 0, + p: isWorkflowError ? '$5' : 0, }} > diff --git a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstall.tsx b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstall.tsx index 50a877749d8d..a4eea6a800d9 100644 --- a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstall.tsx +++ b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstall.tsx @@ -21,6 +21,7 @@ import { } from '../components/FirmwareUpdateExitPrevent'; import { FirmwareUpdatePageLayout } from '../components/FirmwareUpdatePageLayout'; import { FirmwareUpdateWarningMessage } from '../components/FirmwareUpdateWarningMessage'; +import { useFirmwareUpdateWorkflowLifetime } from '../hooks/useFirmwareUpdateHooks'; function PageFirmwareUpdateInstall() { const route = useAppRoute< @@ -32,6 +33,18 @@ function PageFirmwareUpdateInstall() { const [stepInfo] = useFirmwareUpdateStepInfoAtom(); + useFirmwareUpdateWorkflowLifetime({ + onReallyLeave: async () => { + await backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(); + if (result?.originalConnectId) { + await backgroundApiProxy.serviceHardware.cancel({ + connectId: result.originalConnectId, + forceDeviceResetToHome: true, + }); + } + }, + }); + /* await backgroundApiProxy.serviceFirmwareUpdate.startFirmwareUpdateWorkflow( { @@ -83,19 +96,7 @@ function PageFirmwareUpdateInstall() { }, [stepInfo.step, navigation, result]); return ( - { - console.log('PageFirmwareUpdateInstall unmounted'); - await backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(); - if (result?.originalConnectId) { - await backgroundApiProxy.serviceHardware.cancel({ - connectId: result.originalConnectId, - forceDeviceResetToHome: true, - }); - } - }} - > + {content} diff --git a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstallV2.tsx b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstallV2.tsx index bce30f7439d1..9b25b6410e08 100644 --- a/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstallV2.tsx +++ b/packages/kit/src/views/FirmwareUpdate/pages/PageFirmwareUpdateInstallV2.tsx @@ -28,6 +28,7 @@ import { import { FirmwareInstallingViewV2 } from '../componentsV2/FirmwareInstallingViewV2'; import { FirmwareUpdateAlertInfoMessage } from '../componentsV2/FirmwareUpdateAlertInfoMessage'; import { useFirmwareUpdateActions } from '../hooks/useFirmwareUpdateActions'; +import { useFirmwareUpdateWorkflowLifetime } from '../hooks/useFirmwareUpdateHooks'; function PageFirmwareUpdateInstallV2() { const route = useAppRoute< @@ -40,6 +41,18 @@ function PageFirmwareUpdateInstallV2() { const navigation = useAppNavigation(); const actions = useFirmwareUpdateActions(); const [stepInfo] = useFirmwareUpdateStepInfoAtom(); + + useFirmwareUpdateWorkflowLifetime({ + onReallyLeave: async () => { + await backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(); + if (result?.originalConnectId) { + await backgroundApiProxy.serviceHardware.cancel({ + connectId: result.originalConnectId, + forceDeviceResetToHome: true, + }); + } + }, + }); const [isDoneInternal, setIsDoneInternal] = useState(false); const isDone = stepInfo.step === EFirmwareUpdateSteps.updateDone; const needOnboarding = @@ -152,19 +165,7 @@ function PageFirmwareUpdateInstallV2() { ]); return ( - { - console.log('PageFirmwareUpdateInstall unmounted'); - await backgroundApiProxy.serviceFirmwareUpdate.exitUpdateWorkflow(); - if (result?.originalConnectId) { - await backgroundApiProxy.serviceHardware.cancel({ - connectId: result.originalConnectId, - forceDeviceResetToHome: true, - }); - } - }} - > + { + it('uses the release device identity and Protocol V2 mode', async () => { + await expect( + getFirmwareUpdateUSBPreflightParams({ + deviceType: EDeviceType.Pro2, + updatingConnectId: 'PRO2_USB_ID', + } as ICheckAllFirmwareReleaseResult), + ).resolves.toEqual({ + connectId: 'PRO2_USB_ID', + connectProtocol: 'V2', + }); + }); + + it('prefers the USB serial resolved from release features', async () => { + const buildDeviceUSBConnectId = jest + .spyOn(deviceUtils, 'buildDeviceUSBConnectId') + .mockResolvedValue('PRO2_USB_SERIAL'); + + await expect( + getFirmwareUpdateUSBPreflightParams({ + deviceType: EDeviceType.Pro2, + features: {} as ICheckAllFirmwareReleaseResult['features'], + updatingConnectId: 'PRO2_BLE_ID', + } as ICheckAllFirmwareReleaseResult), + ).resolves.toEqual({ + connectId: 'PRO2_USB_SERIAL', + connectProtocol: 'V2', + }); + + buildDeviceUSBConnectId.mockRestore(); + }); +}); + +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); + }); +}); + +describe('getFirmwareUpdateDeviceTitle', () => { + it.each([EDeviceType.Pro2, NEO_DEVICE_TYPE, EDeviceType.Touch])( + '设备 %s 优先使用自定义名称', + (deviceType) => { + expect( + getFirmwareUpdateDeviceTitle({ + deviceType, + deviceName: '用户自定义名称', + } as ICheckAllFirmwareReleaseResult), + ).toBe('用户自定义名称'); + }, + ); + + it.each([ + [EDeviceType.Pro2, 'OneKey Pro 2'], + [NEO_DEVICE_TYPE, 'OneKey Neo'], + ])('设备 %s 缺少名称时回退到稳定型号', (deviceType, expected) => { + expect( + getFirmwareUpdateDeviceTitle({ + deviceType, + deviceName: undefined, + } as ICheckAllFirmwareReleaseResult), + ).toBe(expected); + }); +}); + +describe('Protocol V2 update target display', () => { + it('always puts SafeOS first and then shows selected component versions', () => { + const result = { + ...buildResult({ targets: ['app_v1', 'coprocessor', 'resource'] }), + protocolV2FirmwareVersionInfo: { + safeOS: { + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + components: [ + { + target: 'app_v1', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + { + target: 'coprocessor', + currentVersion: '1.0.20', + targetVersion: '1.0.21', + }, + ], + }, + pro2ResourceArchive: { + archiveSha256: '1234567890abcdef', + archiveSize: 1024, + }, + } as ICheckAllFirmwareReleaseResult; + + expect( + getProtocolV2FirmwareVersionDisplayItems(result, { + includeComponents: true, + }), + ).toEqual([ + { + target: 'safeos', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + { + target: 'app_v1', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + { + target: 'coprocessor', + currentVersion: '1.0.20', + targetVersion: '1.0.21', + }, + { + target: 'resource', + currentVersion: null, + targetVersion: 'SHA-256 1234567890ab', + releaseIdentifierOnly: true, + }, + ]); + }); + + it('keeps SafeOS visible for a resource-only update', () => { + const result = { + ...buildResult({ targets: ['resource'] }), + protocolV2FirmwareVersionInfo: { + safeOS: { + currentVersion: '1.0.0', + targetVersion: null, + }, + components: [], + }, + } as ICheckAllFirmwareReleaseResult; + + expect(getProtocolV2FirmwareVersionDisplayItems(result)[0]).toEqual({ + target: 'safeos', + currentVersion: '1.0.0', + targetVersion: null, + }); + }); + + it('uses the same SafeOS-first model for Neo', () => { + const result = { + ...buildResult({ deviceType: NEO_DEVICE_TYPE, targets: ['app_v2'] }), + protocolV2FirmwareVersionInfo: { + safeOS: { + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + components: [ + { + target: 'app_v2', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + ], + }, + } as ICheckAllFirmwareReleaseResult; + + expect( + getProtocolV2FirmwareVersionDisplayItems(result, { + includeComponents: true, + }).map((item) => item.target), + ).toEqual(['safeos', 'app_v2']); + }); + + it('hides component versions unless explicitly requested', () => { + const result = { + ...buildResult({ targets: ['app_v1', 'coprocessor', 'resource'] }), + protocolV2FirmwareVersionInfo: { + safeOS: { + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + components: [], + }, + } as ICheckAllFirmwareReleaseResult; + + expect( + getProtocolV2FirmwareVersionDisplayItems(result).map( + (item) => item.target, + ), + ).toEqual(['safeos']); + }); + + it('detects an independently selected coprocessor target', () => { + const result = buildResult({ targets: ['coprocessor'] }); + + expect(hasProtocolV2FirmwareUpdateTarget(result, 'coprocessor')).toBe(true); + expect(hasProtocolV2FirmwareUpdateTarget(result, 'resource')).toBe(false); + }); + + it('uses a stable short archive fingerprint for a resource-only update', () => { + const result = { + ...buildResult({ targets: ['resource'] }), + pro2ResourceArchive: { + archiveSha256: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + archiveSize: 1024, + }, + }; + + expect(getProtocolV2ResourceReleaseId(result)).toBe('SHA-256 1234567890ab'); + }); + + it('does not expose a resource fingerprint for unrelated updates', () => { + const result = { + ...buildResult({ targets: ['coprocessor'] }), + pro2ResourceArchive: { + archiveSha256: 'a'.repeat(64), + archiveSize: 1024, + }, + }; + + expect(getProtocolV2ResourceReleaseId(result)).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/utils.ts b/packages/kit/src/views/FirmwareUpdate/utils.ts index 1a78f08d682f..bd5503fdc4ca 100644 --- a/packages/kit/src/views/FirmwareUpdate/utils.ts +++ b/packages/kit/src/views/FirmwareUpdate/utils.ts @@ -1,9 +1,150 @@ import { EFirmwareType } from '@onekeyfe/hd-shared'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; +import type { + ICheckAllFirmwareReleaseResult, + IPro2FirmwareUpdateTarget, +} from '@onekeyhq/shared/types/device'; import type { IntlShape } from 'react-intl'; +export async function getFirmwareUpdateUSBPreflightParams( + result: ICheckAllFirmwareReleaseResult | undefined, +) { + const usbConnectId = await deviceUtils.buildDeviceUSBConnectId({ + features: result?.features, + }); + + return { + connectId: + usbConnectId ?? result?.updatingConnectId ?? result?.originalConnectId, + connectProtocol: isProtocolV2ProductType(result?.deviceType) + ? ('V2' as const) + : undefined, + }; +} + +export function getFirmwareUpdateDeviceTitle( + result: ICheckAllFirmwareReleaseResult, +) { + if (result.deviceName) { + return result.deviceName; + } + + return result.deviceType + ? deviceUtils.getDefaultDeviceLabel(result.deviceType) + : undefined; +} + +export function isPro2SafeOSFirmwareUpdate( + result: ICheckAllFirmwareReleaseResult | undefined, +) { + if (!result || !isProtocolV2ProductType(result.deviceType)) { + return false; + } + return ( + result.updateInfos?.firmware?.hasUpgrade === true || + result.pro2TargetsToUpdate?.some( + (target) => target === 'app_v1' || target === 'app_v2', + ) === true + ); +} + +export function hasProtocolV2FirmwareUpdateTarget( + result: ICheckAllFirmwareReleaseResult | undefined, + target: IPro2FirmwareUpdateTarget, +) { + return result?.pro2TargetsToUpdate?.includes(target) === true; +} + +export function getProtocolV2ResourceReleaseId( + result: ICheckAllFirmwareReleaseResult | undefined, +) { + if (!hasProtocolV2FirmwareUpdateTarget(result, 'resource')) { + return undefined; + } + const archiveSha256 = result?.pro2ResourceArchive?.archiveSha256; + if (!archiveSha256) { + return undefined; + } + return `SHA-256 ${archiveSha256.slice(0, 12)}`; +} + +export type IProtocolV2FirmwareVersionDisplayItem = { + target: 'safeos' | IPro2FirmwareUpdateTarget; + currentVersion: string | null; + targetVersion: string | null; + releaseIdentifierOnly?: boolean; +}; + +export function getProtocolV2FirmwareVersionTitle({ + target, + intl, +}: { + target: IProtocolV2FirmwareVersionDisplayItem['target']; + intl: IntlShape; +}) { + if (target === 'safeos') return 'SafeOS'; + if (target === 'boot') { + return intl.formatMessage({ id: ETranslations.global_bootloader }); + } + if (target === 'coprocessor') { + return intl.formatMessage({ id: ETranslations.global_bluetooth }); + } + if (target === 'resource') { + return intl.formatMessage({ id: ETranslations.global_resources }); + } + if (target === 'app_v1') return 'App P1'; + if (target === 'app_v2') return 'App P2'; + return target.toUpperCase(); +} + +export function getProtocolV2FirmwareVersionDisplayItems( + result: ICheckAllFirmwareReleaseResult | undefined, + { includeComponents = false }: { includeComponents?: boolean } = {}, +): IProtocolV2FirmwareVersionDisplayItem[] { + if (!result || !isProtocolV2ProductType(result.deviceType)) { + return []; + } + + const versionInfo = result.protocolV2FirmwareVersionInfo; + const items: IProtocolV2FirmwareVersionDisplayItem[] = [ + { + target: 'safeos', + currentVersion: versionInfo?.safeOS.currentVersion ?? null, + targetVersion: versionInfo?.safeOS.targetVersion ?? null, + }, + ]; + + if (!includeComponents) { + return items; + } + + for (const target of result.pro2TargetsToUpdate ?? []) { + if (target === 'resource') { + items.push({ + target, + currentVersion: null, + targetVersion: getProtocolV2ResourceReleaseId(result) ?? null, + releaseIdentifierOnly: true, + }); + } else { + const component = versionInfo?.components.find( + (item) => item.target === target, + ); + items.push({ + target, + currentVersion: component?.currentVersion ?? null, + targetVersion: component?.targetVersion ?? null, + }); + } + } + + return items; +} + 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..73199b95682e 100644 --- a/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx +++ b/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from 'react-intl'; import { Empty, Stack, YStack } from '@onekeyhq/components'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import type { IOneKeyDeviceType } from '@onekeyhq/shared/types/device'; type IWalletType = IOneKeyDeviceType | 'watching'; @@ -29,8 +30,10 @@ export function HomeSupportedWallet({ [EDeviceType.Mini]: 'Mini', [EDeviceType.Touch]: 'Touch', [EDeviceType.Pro]: 'Pro', + [EDeviceType.Pro2]: 'Pro 2', + [NEO_DEVICE_TYPE]: 'Neo', [EDeviceType.Unknown]: '', - 'watching': intl.formatMessage({ + 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..3ea6022c7d18 --- /dev/null +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +describe('TokenListBlock portfolio sync producer', () => { + it('checks the Protocol V2 device type before building the cross-runtime payload', () => { + const source = readFileSync(join(__dirname, 'TokenListBlock.tsx'), 'utf8'); + const gateIndex = source.indexOf( + 'isProtocolV2ProductType(device?.deviceType) &&', + ); + const buildIndex = source.indexOf( + 'const flattenedAggregateTokenMap = flattenAggregateTokensMap', + ); + const sendToBackgroundIndex = source.indexOf( + 'backgroundApiProxy.serviceHardwarePortfolioSync.notifyAllNetworksTokenListSettled', + ); + const emptySnapshotGateIndex = source.indexOf( + '!shouldDeferEmptyHardwarePortfolioSync({', + ); + + 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(source).toContain( + '!accountUtils.isQrWallet({ walletId: wallet.id })', + ); + expect(source).toContain('assetStatusCurrency &&'); + expect(source).toContain('if (!snapshot || isStaleOwnerRequest())'); + expect(source).toContain('totalFiatCurrency: assetStatusCurrency'); + expect(gateIndex).toBeGreaterThan(0); + expect(gateIndex).toBeLessThan(buildIndex); + expect(buildIndex).toBeLessThan(sendToBackgroundIndex); + expect(source).toContain('countFundedHardwarePortfolioTokens'); + expect(source).toContain('totalTokenCount: fundedTokenCount'); + expect(emptySnapshotGateIndex).toBeGreaterThan(buildIndex); + expect(emptySnapshotGateIndex).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..2ce123103baf 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx @@ -103,6 +103,7 @@ import accountUtils from '@onekeyhq/shared/src/utils/accountUtils'; import perfUtils, { EPerformanceTimerLogNames, } from '@onekeyhq/shared/src/utils/debug/perfUtils'; +import { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import networkUtils from '@onekeyhq/shared/src/utils/networkUtils'; import { buildTokenSelectorDappTokenFilterParams, @@ -111,6 +112,7 @@ import { import { buildAggregateTokenListData, calculateAccountTokensValue, + flattenAggregateTokensMap, getEmptyTokenData, getMergedDeriveTokenData, getMergedTokenData, @@ -137,10 +139,15 @@ import { evaluateWalletAssetStatus, getWalletAssetStatusCurrency, isWalletAssetStatusAggregationComplete, + shouldDeferEmptyHardwarePortfolioSync, shouldReportWalletAssetStatusChange, shouldReportWalletAssetStatusSnapshot, } from './assetStatusAnalytics'; import { buildHomeTokenListCacheIngestRound } from './buildHomeTokenListCacheIngestRound'; +import { + countFundedHardwarePortfolioTokens, + selectHardwarePortfolioTokens, +} from './selectHardwarePortfolioTokens'; import { useTokenListReactivePipeline } from './useTokenListReactivePipeline'; const networkIdsMap = getNetworkIdsMap(); @@ -243,6 +250,7 @@ function TokenListBlock({ accountName, network, wallet, + device, indexedAccount, isOthersWallet, deriveInfo, @@ -1687,7 +1695,7 @@ function TokenListBlock({ // merge-derive flags resolved inside. P0-b: the snapshot is RETURNED so the // worth write below can read `snapshot.accountsWorth` BEFORE the commit. const snapshot = await buildAuthoritativeSnapshot(); - if (isStaleOwnerRequest()) { + if (!snapshot || isStaleOwnerRequest()) { return; } @@ -1838,6 +1846,70 @@ function TokenListBlock({ worth: snapshot.accountsWorth, createAtNetworkWorth: snapshot.createAtNetworkWorth, }); + + if ( + assetStatusCurrency && + isProtocolV2ProductType(device?.deviceType) && + wallet && + accountUtils.isHwWallet({ walletId: wallet.id }) && + !accountUtils.isQrWallet({ walletId: wallet.id }) + ) { + const flattenedAggregateTokenMap = flattenAggregateTokensMap( + snapshot.aggregateTokenMap, + ); + const portfolioTokenMap = { + ...snapshot.mergeTokenListMap, + ...flattenedAggregateTokenMap, + }; + const portfolioTokens = selectHardwarePortfolioTokens({ + tokenMap: portfolioTokenMap, + tokens: [...snapshot.orderedTokens, ...snapshot.smallBalanceTokens], + ...cellsNonZeroInputs, + }); + // keepDefault includes zero-balance natives so the device matches Home. + // The empty-snapshot defer still needs a strict funded count, otherwise + // incomplete aggregation would upload those defaults too early. + const fundedTokenCount = countFundedHardwarePortfolioTokens({ + tokenMap: portfolioTokenMap, + tokens: portfolioTokens, + }); + + if ( + !shouldDeferEmptyHardwarePortfolioSync({ + aggregationComplete: assetStatusAggregationComplete, + totalTokenCount: fundedTokenCount, + }) + ) { + 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, + totalFiatCurrency: assetStatusCurrency, + 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 +1937,17 @@ function TokenListBlock({ isRefreshing: false, }); }, [ + account?.address, account?.id, account?.indexedAccountId, + accountName, + cellsNonZeroInputs, + device?.connectId, + device?.deviceType, + device?.id, indexedAccount?.id, + indexedAccount?.index, + indexedAccount?.name, mergeDeriveAddressData, allNetworkAccounts, allNetworksResult, @@ -1876,6 +1956,7 @@ function TokenListBlock({ commitAuthoritativeIngest, updateAccountWorth, updateTokenListState, + wallet, ]); // The legacy per-owner `renderedTokenListCache` pre-paint hydrator was REMOVED @@ -2763,7 +2844,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/Home/components/TokenListBlock/assetStatusAnalytics.test.ts b/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.test.ts index 54ac2b3612b0..84668339ffa5 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.test.ts +++ b/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.test.ts @@ -3,6 +3,7 @@ import { getWalletAssetStatusCurrency, getWalletAssetStatusFromTotalBalanceUsd, isWalletAssetStatusAggregationComplete, + shouldDeferEmptyHardwarePortfolioSync, shouldReportWalletAssetStatusChange, shouldReportWalletAssetStatusSnapshot, } from './assetStatusAnalytics'; @@ -99,6 +100,27 @@ describe('TokenListBlock asset status analytics', () => { ).toBe(false); }); + it('defers only empty hardware portfolio snapshots with incomplete aggregation', () => { + expect( + shouldDeferEmptyHardwarePortfolioSync({ + aggregationComplete: false, + totalTokenCount: 0, + }), + ).toBe(true); + expect( + shouldDeferEmptyHardwarePortfolioSync({ + aggregationComplete: true, + totalTokenCount: 0, + }), + ).toBe(false); + expect( + shouldDeferEmptyHardwarePortfolioSync({ + aggregationComplete: false, + totalTokenCount: 1, + }), + ).toBe(false); + }); + it('returns a currency only when every all-network result has the same currency tag', () => { expect( getWalletAssetStatusCurrency([ diff --git a/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.ts b/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.ts index 4612ba5fb507..976fd0159081 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.ts +++ b/packages/kit/src/views/Home/components/TokenListBlock/assetStatusAnalytics.ts @@ -160,6 +160,16 @@ export function isWalletAssetStatusAggregationComplete({ ); } +export function shouldDeferEmptyHardwarePortfolioSync({ + aggregationComplete, + totalTokenCount, +}: { + aggregationComplete: boolean; + totalTokenCount: number; +}) { + return totalTokenCount === 0 && !aggregationComplete; +} + export function getWalletAssetStatusCurrency( result: IWalletAssetStatusCurrencyRef[], ) { diff --git a/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.test.ts b/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.test.ts new file mode 100644 index 000000000000..0867a6d5f380 --- /dev/null +++ b/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.test.ts @@ -0,0 +1,223 @@ +import { ETokenDappType } from '@onekeyhq/shared/types/token'; +import type { + IAccountToken, + ICustomTokenItem, + IHomeDefaultToken, + ITokenFiat, +} from '@onekeyhq/shared/types/token'; + +import { + countFundedHardwarePortfolioTokens, + selectHardwarePortfolioTokens, +} from './selectHardwarePortfolioTokens'; + +function makeToken( + key: string, + overrides: Partial = {}, +): IAccountToken { + return { + $key: key, + address: `0x${key}`, + decimals: 18, + isNative: false, + name: key, + networkId: 'evm--1', + symbol: key.toUpperCase(), + ...overrides, + }; +} + +function makeFiat(overrides: Partial = {}): ITokenFiat { + return { + balance: '1', + balanceParsed: '1', + fiatValue: '10', + price: 1, + ...overrides, + }; +} + +describe('selectHardwarePortfolioTokens', () => { + it('keeps funded tokens and drops zero-balance tokens', () => { + const funded = makeToken('funded'); + const empty = makeToken('empty'); + + expect( + selectHardwarePortfolioTokens({ + keepDefault: true, + tokenMap: { + empty: makeFiat({ balance: '0', balanceParsed: '0', fiatValue: '0' }), + funded: makeFiat(), + }, + tokens: [funded, empty], + }).map((token) => token.$key), + ).toEqual(['funded']); + }); + + it('uses balance, not balanceParsed, to match home hideZero', () => { + const dust = makeToken('dust'); + + expect( + selectHardwarePortfolioTokens({ + keepDefault: true, + tokenMap: { + dust: makeFiat({ + balance: '0', + balanceParsed: '0.0001', + fiatValue: '0', + }), + }, + tokens: [dust], + }), + ).toEqual([]); + }); + + it('drops DeFi-marked tokens even when they have a balance', () => { + const walletToken = makeToken('usdc'); + const defiToken = makeToken('lp', { + dappName: 'uniswap', + defiMarked: true, + }); + + expect( + selectHardwarePortfolioTokens({ + keepDefault: true, + tokenMap: { + lp: makeFiat(), + usdc: makeFiat(), + }, + tokens: [walletToken, defiToken], + }).map((token) => token.$key), + ).toEqual(['usdc']); + }); + + it('keeps wallet-typed tokens that happen to carry a dappName', () => { + const walletToken = makeToken('eth', { + dappName: 'wallet', + dappType: ETokenDappType.WalletToken, + isNative: true, + }); + + expect( + selectHardwarePortfolioTokens({ + keepDefault: true, + tokenMap: { + eth: makeFiat(), + }, + tokens: [walletToken], + }).map((token) => token.$key), + ).toEqual(['eth']); + }); + + it('keeps a zero-balance default native when keepDefault is on', () => { + const native = makeToken('eth', { + isNative: true, + symbol: 'ETH', + }); + const homeDefaultTokenMap: Record = { + 'evm--1_ETH': { + logoURI: '', + networkId: 'evm--1', + order: 0, + symbol: 'ETH', + }, + }; + + expect( + selectHardwarePortfolioTokens({ + homeDefaultTokenMap, + keepDefault: true, + tokenMap: { + eth: makeFiat({ balance: '0', balanceParsed: '0', fiatValue: '0' }), + }, + tokens: [native], + }).map((token) => token.$key), + ).toEqual(['eth']); + }); + + it('keeps a zero-balance custom token when keepDefault is on', () => { + const custom = makeToken('custom'); + const customTokens: ICustomTokenItem[] = [custom]; + + expect( + selectHardwarePortfolioTokens({ + customTokens, + keepDefault: true, + tokenMap: { + custom: makeFiat({ + balance: '0', + balanceParsed: '0', + fiatValue: '0', + }), + }, + tokens: [custom], + }).map((token) => token.$key), + ).toEqual(['custom']); + }); + + it('counts only strictly funded tokens for the empty-snapshot defer guard', () => { + const native = makeToken('eth', { + isNative: true, + symbol: 'ETH', + }); + const funded = makeToken('usdc'); + const tokenMap = { + eth: makeFiat({ balance: '0', balanceParsed: '0', fiatValue: '0' }), + usdc: makeFiat(), + }; + const tokens = selectHardwarePortfolioTokens({ + homeDefaultTokenMap: { + 'evm--1_ETH': { + logoURI: '', + networkId: 'evm--1', + order: 0, + symbol: 'ETH', + }, + }, + keepDefault: true, + tokenMap, + tokens: [native, funded], + }); + + expect(tokens.map((token) => token.$key)).toEqual(['eth', 'usdc']); + expect( + countFundedHardwarePortfolioTokens({ + tokenMap, + tokens, + }), + ).toBe(1); + expect( + countFundedHardwarePortfolioTokens({ + tokenMap: { + eth: makeFiat({ balance: '0', balanceParsed: '0', fiatValue: '0' }), + }, + tokens: [native], + }), + ).toBe(0); + }); + + it('does not keep a zero-balance default native when keepDefault is off', () => { + const native = makeToken('eth', { + isNative: true, + symbol: 'ETH', + }); + + expect( + selectHardwarePortfolioTokens({ + homeDefaultTokenMap: { + 'evm--1_ETH': { + logoURI: '', + networkId: 'evm--1', + order: 0, + symbol: 'ETH', + }, + }, + keepDefault: false, + tokenMap: { + eth: makeFiat({ balance: '0', balanceParsed: '0', fiatValue: '0' }), + }, + tokens: [native], + }), + ).toEqual([]); + }); +}); diff --git a/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.ts b/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.ts new file mode 100644 index 000000000000..ab85a1dacc4b --- /dev/null +++ b/packages/kit/src/views/Home/components/TokenListBlock/selectHardwarePortfolioTokens.ts @@ -0,0 +1,54 @@ +import { + computeFundedIds, + computeNonZeroIds, +} from '@onekeyhq/kit-bg/src/states/jotai/contexts/tokenList/cellsPure/pure'; +import { isTokenSelectorDappToken } from '@onekeyhq/shared/src/utils/tokenSelectorFilterUtils'; +import type { + IAccountToken, + ICustomTokenItem, + IHomeDefaultToken, + ITokenFiat, +} from '@onekeyhq/shared/types/token'; + +export function selectHardwarePortfolioTokens({ + tokens, + tokenMap, + keepDefault, + homeDefaultTokenMap, + customTokens, +}: { + tokens: IAccountToken[]; + tokenMap: Record; + keepDefault: boolean; + homeDefaultTokenMap?: Record; + customTokens?: ICustomTokenItem[]; +}): IAccountToken[] { + const tokenByKey = new Map(tokens.map((token) => [token.$key, token])); + const nonZeroIds = new Set( + computeNonZeroIds({ + customTokens, + getFiat: (key) => tokenMap[key], + getMeta: (key) => tokenByKey.get(key), + homeDefaultTokenMap, + ids: tokens.map((token) => token.$key), + keepDefault, + }), + ); + + return tokens.filter( + (token) => nonZeroIds.has(token.$key) && !isTokenSelectorDappToken(token), + ); +} + +export function countFundedHardwarePortfolioTokens({ + tokens, + tokenMap, +}: { + tokens: IAccountToken[]; + tokenMap: Record; +}): number { + return computeFundedIds({ + getFiat: (key) => tokenMap[key], + ids: tokens.map((token) => token.$key), + }).length; +} diff --git a/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.test.ts b/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.test.ts index 46fb993e1d1f..151571f5698a 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.test.ts +++ b/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.test.ts @@ -156,6 +156,35 @@ describe('useTokenListReactivePipeline', () => { mockGetVaultSettings.mockClear(); }); + it('does not build an authoritative empty snapshot before any round materializes', async () => { + const { result } = render(true); + act(() => { + result.current.setEnabledKeys([OWNER]); + }); + + await expect( + result.current.buildAuthoritativeSnapshot(), + ).resolves.toBeUndefined(); + }); + + it('builds a legitimate empty snapshot after an empty live round materializes', async () => { + const { result } = render(true); + act(() => { + result.current.setEnabledKeys([OWNER]); + result.current.ingestLiveRound( + makeLiveRound({ + tokens: { data: [], keys: '', map: {} }, + }), + 1, + ); + }); + + const snapshot = await result.current.buildAuthoritativeSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot?.orderedTokens).toEqual([]); + expect(snapshot?.createAtNetworkWorth).toBe('0'); + }); + it('kill-switch: enabled:false → seedAndFlushCache does not ingest', async () => { const { result } = render(false); act(() => { @@ -346,7 +375,10 @@ describe('useTokenListReactivePipeline', () => { await act(async () => { const snap = await result.current.buildAuthoritativeSnapshot(); - result.current.commitAuthoritativeIngest(snap); + expect(snap).toBeDefined(); + if (snap) { + result.current.commitAuthoritativeIngest(snap); + } }); expect(mockIngestRound).toHaveBeenCalledTimes(1); expect( @@ -401,8 +433,9 @@ describe('useTokenListReactivePipeline', () => { generation: 1, }); const snap = await result.current.buildAuthoritativeSnapshot(); + expect(snap).toBeDefined(); worth = - snap.accountsWorth[ + snap?.accountsWorth[ accountUtils.buildAccountValueKey({ accountId: OWNER.accountId, networkId: OWNER.networkId, @@ -437,7 +470,10 @@ describe('useTokenListReactivePipeline', () => { // authoritative commit lands first (bumps the epoch) await act(async () => { const snap = await result.current.buildAuthoritativeSnapshot(); - result.current.commitAuthoritativeIngest(snap); + expect(snap).toBeDefined(); + if (snap) { + result.current.commitAuthoritativeIngest(snap); + } }); mockIngestRound.mockClear(); // now let the throttled flush fire — it must abort (epoch superseded) @@ -510,7 +546,10 @@ describe('useTokenListReactivePipeline', () => { generation: 1, }); const snap = await result.current.buildAuthoritativeSnapshot(); - result.current.commitAuthoritativeIngest(snap); + expect(snap).toBeDefined(); + if (snap) { + result.current.commitAuthoritativeIngest(snap); + } }); mockIngestRound.mockClear(); diff --git a/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.ts b/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.ts index a02300c98429..645c8fe7c11a 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.ts +++ b/packages/kit/src/views/Home/components/TokenListBlock/useTokenListReactivePipeline.ts @@ -130,7 +130,9 @@ export interface ITokenListReactivePipeline { /** LWW-ingest a settled live round (L2) + schedule a throttled flush. */ ingestLiveRound: (result: ILiveRound, generation: number) => void; /** materialize ∩ enabledKeys → resolve merge flags → build the merged snapshot. */ - buildAuthoritativeSnapshot: () => Promise; + buildAuthoritativeSnapshot: () => Promise< + IMergedAllNetworkSnapshot | undefined + >; /** Ingest the authoritative snapshot + clear timer + bump epoch. */ commitAuthoritativeIngest: (snapshot: IMergedAllNetworkSnapshot) => void; } @@ -440,19 +442,26 @@ export function useTokenListReactivePipeline( [ownerAccountId, ownerNetworkId], ); - const buildAuthoritativeSnapshot = - useCallback(async (): Promise => { - const viewRounds = progressiveViewRef.current.materialize( - enabledKeysRef.current, - ); - const roundsWithFlag = await resolveRoundsWithMergeFlag(viewRounds); - return buildMergedAllNetworkSnapshot({ - rounds: roundsWithFlag, - mergeDeriveAssetsByNetworkId: {}, - accountId: ownerAccountId, - createAtNetwork: ownerCreateAtNetwork, - }); - }, [ownerAccountId, ownerCreateAtNetwork, resolveRoundsWithMergeFlag]); + const buildAuthoritativeSnapshot = useCallback(async (): Promise< + IMergedAllNetworkSnapshot | undefined + > => { + const viewRounds = progressiveViewRef.current.materialize( + enabledKeysRef.current, + ); + // An empty materialized view means the pipeline was reset and has not + // received cache or live rounds for the current run yet. It is not an + // authoritative empty wallet snapshot. + if (!viewRounds.length) { + return undefined; + } + const roundsWithFlag = await resolveRoundsWithMergeFlag(viewRounds); + return buildMergedAllNetworkSnapshot({ + rounds: roundsWithFlag, + mergeDeriveAssetsByNetworkId: {}, + accountId: ownerAccountId, + createAtNetwork: ownerCreateAtNetwork, + }); + }, [ownerAccountId, ownerCreateAtNetwork, resolveRoundsWithMergeFlag]); const commitAuthoritativeIngest = useCallback( (snapshot: IMergedAllNetworkSnapshot) => { diff --git a/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/ConnectYourDevice.tsx index 7ba174ec30ef..980360229007 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'; @@ -38,41 +38,27 @@ import { OpenBleSettingsDialog, RequireBlePermissionDialog, } from '@onekeyhq/kit/src/components/Hardware/HardwareDialog'; -import { HyperlinkText } from '@onekeyhq/kit/src/components/HyperlinkText'; import { ListItem } from '@onekeyhq/kit/src/components/ListItem'; import { MultipleClickStack } from '@onekeyhq/kit/src/components/MultipleClickStack'; import type { ITutorialsListItem } from '@onekeyhq/kit/src/components/TutorialsList'; import { TutorialsList } from '@onekeyhq/kit/src/components/TutorialsList'; import useAppNavigation from '@onekeyhq/kit/src/hooks/useAppNavigation'; import { useHelpLink } from '@onekeyhq/kit/src/hooks/useHelpLink'; +import { useOnboardingDeviceScanErrorHandler } from '@onekeyhq/kit/src/hooks/useOnboardingDeviceScanErrorHandler'; import { usePromptWebDeviceAccess } from '@onekeyhq/kit/src/hooks/usePromptWebDeviceAccess'; import { useRouteIsFocused as useIsFocused } from '@onekeyhq/kit/src/hooks/useRouteIsFocused'; import { useUserWalletProfile } from '@onekeyhq/kit/src/hooks/useUserWalletProfile'; import { useAccountSelectorActions } from '@onekeyhq/kit/src/states/jotai/contexts/accountSelector/actions'; import type { IDBCreateHwWalletParamsBase } from '@onekeyhq/kit-bg/src/dbs/local/types'; import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; -import { - HARDWARE_BRIDGE_DOWNLOAD_URL, - ONEKEY_BUY_HARDWARE_URL, -} from '@onekeyhq/shared/src/config/appConfig'; +import { ONEKEY_BUY_HARDWARE_URL } from '@onekeyhq/shared/src/config/appConfig'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; -import { - BleLocationServiceError, - BridgeTimeoutError, - BridgeTimeoutErrorForDesktop, - ConnectTimeoutError, - DeviceMethodCallTimeout, - InitIframeLoadFail, - InitIframeTimeout, - NeedBluetoothPermissions, - NeedBluetoothTurnedOn, - NeedOneKeyBridge, - OneKeyHardwareError, -} from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; -import { convertDeviceError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { OneKeyHardwareError } from '@onekeyhq/shared/src/errors/errors/hardwareErrors'; +import { isOneKeyHardwareError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; 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 +78,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 +86,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 +139,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 = @@ -346,23 +338,6 @@ function ConnectByQrCodeComingSoon() { ); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function BridgeNotInstalledDialogContent(_props: { error: NeedOneKeyBridge }) { - return ( - - - - ); -} - enum EConnectionStatus { init = 'init', searching = 'searching', @@ -380,7 +355,6 @@ function useDeviceConnection({ tabValue, onDeviceConnect, }: IDeviceConnectionProps) { - const intl = useIntl(); const [connectStatus, setConnectStatus] = useState(EConnectionStatus.init); const [searchedDevices, setSearchedDevices] = useState([]); const [isCheckingDeviceLoading, setIsChecking] = useState(false); @@ -434,6 +408,19 @@ function useDeviceConnection({ currentTabValueRef.current = tabValue; }, [tabValue, deviceScanner]); + const stopScan = useCallback(() => { + isSearchingRef.current = false; + deviceScanner.stopScan(); + }, [deviceScanner]); + + const stopScanAfterError = useCallback(() => { + setConnectStatus(EConnectionStatus.init); + stopScan(); + }, [stopScan]); + + const { handleScanError, resetScanError } = + useOnboardingDeviceScanErrorHandler({ stopScan: stopScanAfterError }); + const scanDevice = useCallback(async () => { if (isSearchingRef.current) { return; @@ -451,75 +438,9 @@ function useDeviceConnection({ deviceScanner.startDeviceScan( (response) => { if (!response.success) { - const error = convertDeviceError(response.payload); - if (platformEnv.isNative) { - if ( - !(error instanceof NeedBluetoothTurnedOn) && - !(error instanceof NeedBluetoothPermissions) && - !(error instanceof BleLocationServiceError) - ) { - Toast.error({ - title: error.message || 'DeviceScanError', - }); - } else { - deviceScanner.stopScan(); - } - } else if ( - error instanceof InitIframeLoadFail || - error instanceof InitIframeTimeout - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_network_error, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if ( - error instanceof BridgeTimeoutError || - error instanceof BridgeTimeoutErrorForDesktop - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_connection_failed, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if ( - error instanceof ConnectTimeoutError || - error instanceof DeviceMethodCallTimeout - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_connection_failed, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if (error instanceof NeedOneKeyBridge) { - Dialog.confirm({ - icon: 'OnekeyBrand', - title: intl.formatMessage({ - id: ETranslations.onboarding_install_onekey_bridge, - }), - renderContent: , - onConfirmText: intl.formatMessage({ - id: ETranslations.global_download_and_install, - }), - onConfirm: () => openUrlExternal(HARDWARE_BRIDGE_DOWNLOAD_URL), - }); - - deviceScanner.stopScan(); - } return; } + resetScanError(); const sortedDevices = response.payload.toSorted((a, b) => natsort({ insensitive: true })( @@ -530,18 +451,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'); } @@ -552,13 +466,10 @@ function useDeviceConnection({ undefined, // pollIntervalRate undefined, // pollInterval undefined, // maxTryCount + undefined, // vendor + { onError: handleScanError }, ); - }, [deviceScanner, intl, tabValue]); - - const stopScan = useCallback(() => { - isSearchingRef.current = false; - deviceScanner.stopScan(); - }, [deviceScanner]); + }, [deviceScanner, handleScanError, resetScanError, tabValue]); const ensureStopScan = useCallback(async () => { // Force stop scanning and wait for any ongoing search to complete @@ -1037,7 +948,13 @@ function ConnectByBluetooth({ onDeviceConnect: handleBluetoothDeviceConnect, }); - const { devicesData, scanDevice, stopScan } = deviceConnection; + const { connectStatus, setConnectStatus, devicesData, scanDevice, stopScan } = + deviceConnection; + + const listingDevice = useCallback(async () => { + setConnectStatus(EConnectionStatus.listing); + await scanDevice(); + }, [scanDevice, setConnectStatus]); const handleOpenPrivacySettings = useCallback(() => { void globalThis.desktopApiProxy.bluetooth.openPrivacySettings(); @@ -1079,11 +996,11 @@ function ConnectByBluetooth({ // Start scanning when bluetooth is enabled and focused useEffect(() => { if (isFocused && bluetoothStatus === 'enabled') { - void scanDevice(); + void listingDevice(); } else if (!isFocused) { stopScan(); } - }, [bluetoothStatus, isFocused, scanDevice, stopScan]); + }, [bluetoothStatus, isFocused, listingDevice, stopScan]); // Cleanup on unmount useEffect( @@ -1160,15 +1077,29 @@ function ConnectByBluetooth({ <> - + {connectStatus === EConnectionStatus.init ? ( + + + + ) : ( + + )} ); } @@ -1326,9 +1257,10 @@ export function ConnectYourDevicePage() { try { return await backgroundApiProxy.serviceHardware.connect({ device, + forceProtocolDetection: true, }); } catch (error: any) { - if (error instanceof OneKeyHardwareError) { + if (isOneKeyHardwareError(error)) { const { code, message } = error; if ( code === HardwareErrorCode.CallMethodNeedUpgradeFirmware || @@ -1346,16 +1278,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 +1290,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 +1329,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 +1341,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 +1362,11 @@ export function ConnectYourDevicePage() { await actions.current.updateHwWalletsDeprecatedStatus({ connectId: device.connectId ?? '', - deviceId: features.device_id || device.deviceId || '', + deviceId: deviceUtils.getRawDeviceId({ + device, + features, + deviceState, + }), }); } catch (error) { errorToastUtils.toastIfError(error); @@ -1495,6 +1395,7 @@ export function ConnectYourDevicePage() { const selectAddWalletType = useCallback( async ({ device, + features: connectedFeatures, isFirmwareVerified, }: { device: SearchDevice; @@ -1508,24 +1409,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 +1446,7 @@ export function ConnectYourDevicePage() { deviceState, ); }, - [ - extractDeviceState, - determineWalletCreationStrategy, - createHwWallet, - closeDialogAndReturn, - ], + [determineWalletCreationStrategy, createHwWallet, closeDialogAndReturn], ); // Shared device connection handler @@ -1600,13 +1502,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 +1562,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..9a207e015f88 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, @@ -203,6 +206,9 @@ function useFirmwareVerifyBase({ case HardwareErrorCode.NewFirmwareForceUpdate: void dialogInstance.close(); break; + case HardwareErrorCode.BleUnavailableWhileUsbConnected: + void dialogInstance.close(); + break; case HardwareErrorCode.NetworkError: case HardwareErrorCode.BridgeNetworkError: setContentType( @@ -1033,6 +1039,8 @@ export function FirmwareAuthenticationDialogContent({ ); } +export type IFirmwareVerifyDialogHost = Pick; + export function useFirmwareVerifyDialog() { const [isLoading, setIsLoading] = useState(false); const showFirmwareVerifyDialog = useCallback( @@ -1043,6 +1051,7 @@ export function useFirmwareVerifyDialog() { onContinue, onDevSkipVerificationPress, onClose, + dialogHost = Dialog, }: { device: SearchDevice | IDBDevice; features: IOneKeyDeviceFeatures | undefined; @@ -1050,7 +1059,18 @@ export function useFirmwareVerifyDialog() { onClose: () => Promise | void; onVerified?: (params: { checked: boolean }) => Promise | void; onDevSkipVerificationPress?: () => void; + // A page-owned dialog host (useInPageDialog) renders this dialog into the + // page's own portal instead of the global full-window overlay. On iOS the + // global overlay stacks children by render order only, so a retry loop + // that re-mounts this dialog while the hardware checking Sheet is still + // exiting can strand a backdrop above it that swallows every tap. + dialogHost?: IFirmwareVerifyDialogHost; }) => { + if (!deviceUtils.isFirmwareVerifySupported(device.deviceType)) { + await onContinue({ checked: false }); + return; + } + const onCloseFn = async () => { await onClose?.(); setIsLoading(false); @@ -1085,7 +1105,7 @@ export function useFirmwareVerifyDialog() { } finally { // await backgroundApiProxy.serviceApp.hideDialogLoading(); } - const firmwareAuthenticationDialog = Dialog.show({ + const firmwareAuthenticationDialog = dialogHost.show({ tone: 'success', icon: 'DocumentSearch2Outline', title: ' ', 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..91585f2764ad --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.test.ts @@ -0,0 +1,152 @@ +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 unlockedState = buildState({ unlocked: true }); + const settingsState = { + ...unlockedState, + identity: { label: 'My Pro 2' }, + } as IOneKeyDeviceState; + let resolveUnlock!: (state: IOneKeyDeviceState) => void; + const unlockPromise = new Promise((resolve) => { + resolveUnlock = resolve; + }); + const getDeviceState = jest.fn().mockResolvedValue(settingsState); + const getDeviceStateWithUnlock = jest.fn().mockReturnValue(unlockPromise); + + const resultPromise = getWalletCreationDeviceState({ + serviceHardware: { getDeviceState, getDeviceStateWithUnlock }, + connectId: 'pro2-connect', + connectProtocol: 'V2', + }); + + expect(getDeviceStateWithUnlock).toHaveBeenCalledWith({ + connectId: 'pro2-connect', + pinType: DeviceSessionPinType.Any, + params: { connectProtocol: 'V2', scope: 'runtime' }, + }); + expect(getDeviceState).not.toHaveBeenCalled(); + + resolveUnlock(unlockedState); + await expect(resultPromise).resolves.toBe(settingsState); + + expect(getDeviceState).toHaveBeenCalledWith({ + connectId: 'pro2-connect', + params: { connectProtocol: 'V2', scope: 'settings' }, + }); + }); + + it('Protocol V1 直接使用 settings scope 读取完整状态', async () => { + const state = buildState({ unlocked: true }); + const settingsState = { + ...state, + identity: { label: 'My Classic' }, + } as IOneKeyDeviceState; + const getDeviceState = jest.fn(); + const getDeviceStateWithUnlock = jest.fn().mockResolvedValue(settingsState); + + await expect( + getWalletCreationDeviceState({ + serviceHardware: { getDeviceState, getDeviceStateWithUnlock }, + connectId: 'classic-connect', + connectProtocol: 'V1', + }), + ).resolves.toBe(settingsState); + + expect(getDeviceStateWithUnlock).toHaveBeenCalledWith({ + connectId: 'classic-connect', + params: { connectProtocol: 'V1', scope: 'settings' }, + }); + 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..639a3d245a66 --- /dev/null +++ b/packages/kit/src/views/Onboarding/pages/ConnectHardwareWallet/walletCreationMode.ts @@ -0,0 +1,98 @@ +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: 'settings'; + }; + }) => Promise; + getDeviceStateWithUnlock: (params: { + connectId: string; + pinType?: DeviceSessionPinType; + params: { + connectProtocol?: HardwareConnectProtocol; + scope: 'runtime' | 'settings'; + }; + }) => Promise; +}; + +export async function getWalletCreationDeviceState({ + serviceHardware, + connectId, + connectProtocol, +}: { + serviceHardware: IWalletCreationHardwareService; + connectId: string; + connectProtocol?: HardwareConnectProtocol; +}): Promise { + const isProtocolV2 = connectProtocol === 'V2'; + const unlockedState = await serviceHardware.getDeviceStateWithUnlock({ + connectId, + ...(isProtocolV2 ? { pinType: DeviceSessionPinType.Any } : {}), + params: { + connectProtocol, + scope: isProtocolV2 ? 'runtime' : 'settings', + }, + }); + + // Protocol V1 has no scoped state and returns the full state in one call. + if (!isProtocolV2) { + return unlockedState; + } + + // Protocol V2 settings reads are rejected while locked, so read them only + // after the runtime-scoped unlock flow completes. + return serviceHardware.getDeviceState({ + connectId, + params: { connectProtocol, scope: 'settings' }, + }); +} + +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/deviceLabel.ts b/packages/kit/src/views/Onboardingv2/deviceLabel.ts new file mode 100644 index 000000000000..3dbaf07fb402 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/deviceLabel.ts @@ -0,0 +1,29 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +export const getDeviceLabel = ( + deviceTypeItems: EDeviceType[], + separator = '/', +) => { + const labels = deviceTypeItems.map((deviceType) => { + switch (deviceType) { + // Pro 2 / Neo are not public yet; keep the shared OneKey Pro USB copy. + case EDeviceType.Pro: + case EDeviceType.Pro2: + case EDeviceType.Neo: + return 'OneKey Pro'; + case EDeviceType.Classic: + return 'OneKey Classic'; + case EDeviceType.Classic1s: + return 'OneKey Classic 1S'; + case EDeviceType.ClassicPure: + return '1S Pure'; + case EDeviceType.Mini: + return 'OneKey Mini'; + case EDeviceType.Touch: + return 'OneKey Touch'; + default: + return deviceType; + } + }); + return Array.from(new Set(labels)).join(separator); +}; diff --git a/packages/kit/src/views/Onboardingv2/getDeviceLabel.test.ts b/packages/kit/src/views/Onboardingv2/getDeviceLabel.test.ts new file mode 100644 index 000000000000..6f4bccea6d2b --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/getDeviceLabel.test.ts @@ -0,0 +1,22 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { getDeviceLabel } from './deviceLabel'; + +describe('getDeviceLabel', () => { + it('hides unpublished Protocol V2 products behind OneKey Pro', () => { + expect(getDeviceLabel([EDeviceType.Pro])).toBe('OneKey Pro'); + expect(getDeviceLabel([EDeviceType.Pro2])).toBe('OneKey Pro'); + expect(getDeviceLabel([EDeviceType.Neo])).toBe('OneKey Pro'); + expect( + getDeviceLabel([EDeviceType.Pro, EDeviceType.Pro2, EDeviceType.Neo]), + ).toBe('OneKey Pro'); + }); + + it('keeps the other product labels joined by the separator', () => { + expect( + getDeviceLabel([EDeviceType.Classic1s, EDeviceType.ClassicPure]), + ).toBe('OneKey Classic 1S/1S Pure'); + expect(getDeviceLabel([EDeviceType.Touch])).toBe('OneKey Touch'); + expect(getDeviceLabel([EDeviceType.Mini])).toBe('OneKey Mini'); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts new file mode 100644 index 000000000000..eadcd9c5fce1 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts @@ -0,0 +1,115 @@ +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import { + resolveFirmwareReconnectDevice, + selectFirmwareReconnectDevice, +} from './firmwareReconnectUtils'; + +import type { SearchDevice } from '@onekeyfe/hd-core'; + +const createDevice = ( + overrides: Partial & { + mode?: string; + features?: IOneKeyDeviceFeatures; + } = {}, +) => + ({ + connectId: 'CLA45F0023', + serialNo: 'CLA45F0023', + uuid: 'CLA45F0023', + deviceId: 'device-id', + deviceType: 'classic1s', + name: 'OneKey Classic 1S', + commType: 'webusb', + mode: 'normal', + ...overrides, + }) as SearchDevice; + +describe('firmwareReconnectUtils', () => { + const bootloaderDevice = createDevice({ + connectId: '000000000000000000000000', + serialNo: '', + uuid: '', + deviceId: null, + mode: 'bootloader', + features: { bootloader_mode: true } as IOneKeyDeviceFeatures, + }); + const normalDevice = createDevice(); + + it('selects the unique normal device after a bootloader identity change', () => { + expect( + selectFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [bootloaderDevice, normalDevice], + }), + ).toBe(normalDevice); + }); + + it('reads fresh features with the newly enumerated connectId', async () => { + const features = { + bootloader_mode: null, + device_id: 'device-id', + } as IOneKeyDeviceFeatures; + const getFeatures = jest.fn().mockResolvedValue(features); + const onConnectId = jest.fn(); + + await expect( + resolveFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [normalDevice], + getFeatures, + onConnectId, + }), + ).resolves.toEqual({ device: normalDevice, features }); + expect(onConnectId).toHaveBeenCalledWith('CLA45F0023'); + expect(getFeatures).toHaveBeenCalledWith('CLA45F0023'); + expect(getFeatures).not.toHaveBeenCalledWith('000000000000000000000000'); + }); + + it('uses stable identity when more than one same-model device is present', () => { + const previousNormalDevice = createDevice(); + const otherNormalDevice = createDevice({ + connectId: 'CLA45F0099', + serialNo: 'CLA45F0099', + uuid: 'CLA45F0099', + deviceId: 'other-device-id', + }); + + expect( + selectFirmwareReconnectDevice({ + previousDevice: previousNormalDevice, + devices: [otherNormalDevice, normalDevice], + }), + ).toBe(normalDevice); + }); + + it('keeps retrying while the only device is still in bootloader mode', async () => { + const getFeatures = jest.fn().mockResolvedValue({ + bootloader_mode: true, + } as IOneKeyDeviceFeatures); + + await expect( + resolveFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [bootloaderDevice], + getFeatures, + }), + ).rejects.toThrow('Firmware device is still in bootloader mode'); + }); + + it('fails closed when multiple same-model devices cannot be distinguished', () => { + const secondNormalDevice = createDevice({ + connectId: 'CLA45F0099', + serialNo: 'CLA45F0099', + uuid: 'CLA45F0099', + deviceId: 'other-device-id', + }); + + expect( + selectFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [normalDevice, secondNormalDevice], + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts new file mode 100644 index 000000000000..2f0d413e2f98 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts @@ -0,0 +1,117 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import type { SearchDevice } from '@onekeyfe/hd-core'; + +type IFirmwareSearchDevice = SearchDevice & { + mode?: string; + features?: IOneKeyDeviceFeatures; +}; + +function isMeaningfulIdentifier(value: string | null | undefined) { + return Boolean(value && !/^0+$/.test(value)); +} + +function getDeviceIdentifiers(device: SearchDevice) { + return [ + device.serialNo, + device.deviceId, + device.uuid, + device.connectId, + ].filter(isMeaningfulIdentifier) as string[]; +} + +function isBootloaderDevice(device: IFirmwareSearchDevice) { + return ( + device.mode === 'bootloader' || device.features?.bootloader_mode === true + ); +} + +function findUniqueDevice( + devices: IFirmwareSearchDevice[], + predicate: (device: IFirmwareSearchDevice) => boolean, +) { + const matches = devices.filter(predicate); + return matches.length === 1 ? matches[0] : undefined; +} + +export function selectFirmwareReconnectDevice({ + previousDevice, + devices, +}: { + previousDevice: SearchDevice; + devices: SearchDevice[]; +}) { + const availableDevices = (devices as IFirmwareSearchDevice[]).filter( + (device) => Boolean(device.connectId), + ); + const previousIdentifiers = new Set(getDeviceIdentifiers(previousDevice)); + const hasMatchingIdentifier = (device: SearchDevice) => + getDeviceIdentifiers(device).some((identifier) => + previousIdentifiers.has(identifier), + ); + const hasSameTransport = (device: SearchDevice) => + device.commType === previousDevice.commType; + const hasSameDeviceType = (device: SearchDevice) => + device.deviceType === previousDevice.deviceType; + const normalDevices = availableDevices.filter( + (device) => !isBootloaderDevice(device), + ); + + return ( + findUniqueDevice( + normalDevices, + (device) => hasMatchingIdentifier(device) && hasSameTransport(device), + ) || + findUniqueDevice(normalDevices, hasMatchingIdentifier) || + findUniqueDevice( + normalDevices, + (device) => hasSameDeviceType(device) && hasSameTransport(device), + ) || + findUniqueDevice(normalDevices, hasSameDeviceType) || + findUniqueDevice( + availableDevices, + (device) => hasMatchingIdentifier(device) && hasSameTransport(device), + ) || + findUniqueDevice(availableDevices, hasMatchingIdentifier) || + findUniqueDevice( + availableDevices, + (device) => hasSameDeviceType(device) && hasSameTransport(device), + ) + ); +} + +export async function resolveFirmwareReconnectDevice({ + previousDevice, + devices, + getFeatures, + onConnectId, +}: { + previousDevice: SearchDevice; + devices: SearchDevice[]; + getFeatures: (connectId: string) => Promise; + onConnectId?: (connectId: string) => void; +}) { + const device = selectFirmwareReconnectDevice({ previousDevice, devices }); + if (!device?.connectId) { + throw new OneKeyLocalError('Firmware device was not uniquely identified'); + } + + onConnectId?.(device.connectId); + const features = await getFeatures(device.connectId); + if (features.bootloader_mode) { + throw new OneKeyLocalError('Firmware device is still in bootloader mode'); + } + + if ( + isMeaningfulIdentifier(previousDevice.deviceId) && + isMeaningfulIdentifier(features.device_id) && + previousDevice.deviceId !== features.device_id + ) { + throw new OneKeyLocalError( + 'Firmware device identity changed after reconnect', + ); + } + + return { device, features }; +} diff --git a/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx b/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx index a575277f344d..b33721713d6a 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'; @@ -16,22 +19,25 @@ import { OneKeyHardwareError, OneKeyLocalError, } from '@onekeyhq/shared/src/errors'; +import { isOneKeyHardwareError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import errorToastUtils from '@onekeyhq/shared/src/errors/utils/errorToastUtils'; 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, @@ -43,16 +49,28 @@ import backgroundApiProxy from '../../../background/instance/backgroundApiProxy' import { ListItem } from '../../../components/ListItem'; import useAppNavigation from '../../../hooks/useAppNavigation'; import { useUserWalletProfile } from '../../../hooks/useUserWalletProfile'; +import { hardwareUiStateDialogLifecycle } from '../../../provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; import { useAccountSelectorActions } from '../../../states/jotai/contexts/accountSelector/actions'; -import { useFirmwareUpdateActions } from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; +import { bootloaderModeDialogManager } from '../../FirmwareUpdate/hooks/bootloaderModeDialogManager'; +import { + type IBootloaderModeDialogHost, + 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, trackHardwareWalletConnection, } from '../utils'; +import { resolveFirmwareReconnectDevice } from './firmwareReconnectUtils'; import { usePrepareUSBConnectForFirmwareUpdate } from './usePrepareUSBConnectForFirmwareUpdate'; import type { IDeviceType, SearchDevice } from '@onekeyfe/hd-core'; @@ -107,9 +125,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, @@ -132,10 +150,12 @@ async function createLedgerHwWallet({ export function useDeviceConnect({ setCurrentDevice, + getBootloaderDialogHost, }: { setCurrentDevice?: React.Dispatch< React.SetStateAction >; + getBootloaderDialogHost?: () => IBootloaderModeDialogHost | undefined; } = {}) { const intl = useIntl(); const actions = useAccountSelectorActions(); @@ -200,18 +220,35 @@ export function useDeviceConnect({ async ( device: SearchDevice, hardwareCallContext?: EHardwareCallContext, + connectProtocol?: HardwareConnectProtocol, + forceProtocolDetection?: boolean, + forceFeaturesRefresh?: boolean, ) => { await ensureStopScan(); try { const features = await backgroundApiProxy.serviceHardware.connect({ + connectProtocol, device, + forceFeaturesRefresh, + 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) { + if (isOneKeyHardwareError(error)) { const { code, message } = error; if ( code === HardwareErrorCode.CallMethodNeedUpgradeFirmware || @@ -234,11 +271,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; @@ -248,11 +292,10 @@ export function useDeviceConnect({ isSameHardware(device, activeDeviceRef.current) && activeFeaturesRef.current ) { + await bootloaderModeDialogManager.close(); return activeFeaturesRef.current; } - // Clear bootloader mode flag when reconnecting - wasInBootloaderModeRef.current = false; let hardwareCallContext: EHardwareCallContext | undefined; let isBootMode = false; if ( @@ -264,9 +307,30 @@ export function useDeviceConnect({ isBootMode = true; } - const features = await connectDevice(device, hardwareCallContext); - // If device was in bootloader mode and connectId is empty, search for the updated device - if (device.connectId === '' && isBootMode && !features?.bootloader_mode) { + const features = await connectDevice( + device, + hardwareCallContext, + options?.connectProtocol, + options?.forceProtocolDetection, + Boolean(shouldForceReconnect), + ); + let isConnectedBootloaderMode = false; + if (features) { + isConnectedBootloaderMode = + await deviceUtils.isBootloaderModeByFeatures({ features }); + wasInBootloaderModeRef.current = isConnectedBootloaderMode; + if (!isConnectedBootloaderMode) { + await bootloaderModeDialogManager.close(); + } + } + const hasPlaceholderConnectId = + !device.connectId || /^0+$/.test(device.connectId); + if ( + hasPlaceholderConnectId && + isBootMode && + features && + !isConnectedBootloaderMode + ) { const searchedDevices = await backgroundApiProxy.serviceHardware.searchDevices(); if (searchedDevices.success && searchedDevices.payload.length === 1) { @@ -282,6 +346,53 @@ export function useDeviceConnect({ [connectDevice, isSameHardware, setCurrentDevice], ); + const rebindDeviceAfterFirmwareUpdate = useCallback( + async ( + previousDevice: SearchDevice, + onConnectId?: (connectId: string) => void, + ) => { + wasInBootloaderModeRef.current = true; + await ensureStopScan(); + const searchedDevices = + await backgroundApiProxy.serviceHardware.searchDevices(); + if (!searchedDevices.success) { + throw new OneKeyLocalError( + 'Unable to search for device after firmware update', + ); + } + + const result = await resolveFirmwareReconnectDevice({ + previousDevice, + devices: searchedDevices.payload, + getFeatures: (connectId) => + backgroundApiProxy.serviceHardware.getFeaturesWithoutCache({ + connectId, + params: { + retryCount: 1, + skipWebDevicePrompt: true, + }, + }), + onConnectId, + }); + activeDeviceRef.current = { ...result.device }; + activeFeaturesRef.current = result.features; + wasInBootloaderModeRef.current = false; + await bootloaderModeDialogManager.close(); + setCurrentDevice?.(result.device); + defaultLogger.hardware.sdkLog.log( + 'Firmware reconnect succeeded', + JSON.stringify({ + deviceType: result.device.deviceType, + commType: result.device.commType, + connectIdChanged: + previousDevice.connectId !== result.device.connectId, + }), + ); + return result; + }, + [ensureStopScan, setCurrentDevice], + ); + const getActiveDevice = useCallback(() => { return activeDeviceRef.current ?? undefined; }, []); @@ -457,11 +568,21 @@ export function useDeviceConnect({ } let connectionFailureTracked = false; + let bootloaderDialogShown = false; let forceTransportType: EHardwareTransportType | undefined; + const confirmedConnectProtocol = device.connectProtocol; try { - void backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ - connectId: device.connectId ?? '', - }); + const showCheckingDeviceDialog = () => + backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ + connectId: device.connectId ?? '', + }); + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.openAndWait( + showCheckingDeviceDialog, + ); + } else { + void showCheckingDeviceDialog(); + } const handleBootloaderMode = async (existsFirmware: boolean) => { // Set bootloader mode flag so retry will force reconnect @@ -496,11 +617,26 @@ export function useDeviceConnect({ return usbPrepareResult.connectId ?? device.connectId ?? undefined; }; + // Wait until the hardware dialog has left the global iOS overlay before + // mounting the page-owned bootloader dialog. + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.closeAndWait(async () => + backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ + connectId: device.connectId ?? undefined, + skipDeviceCancel: true, + skipDelayClose: true, + reason: 'open bootloader mode dialog', + }), + ); + } + fwUpdateActions.showBootloaderMode({ connectId: device.connectId ?? undefined, existsFirmware, onBeforeUpdate: prepareUSBForUpdate, + dialogHost: getBootloaderDialogHost?.(), }); + bootloaderDialogShown = true; console.log('Device is in bootloader mode', device); // Bootloader mode hands off to the firmware-update flow, so the throw // below is not a connection failure — suppress the catch-block tracking. @@ -525,19 +661,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,23 +732,47 @@ export function useDeviceConnect({ await backgroundApiProxy.serviceHardware.shouldAuthenticateFirmware({ device: { ...latestDevice, - deviceId: latestDevice.deviceId || features.device_id, + deviceId: deviceUtils.getRawDeviceId({ + device: latestDevice, + features, + }), }, }); if (shouldAuthenticateFirmware) { - void backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ - connectId: latestDevice.connectId ?? '', - hardClose: false, - skipDelayClose: true, - deviceResetToHome: false, - }); + const closeCheckingDialogForVerify = async () => + backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ + connectId: latestDevice.connectId ?? '', + hardClose: false, + skipDelayClose: true, + deviceResetToHome: false, + }); + // Same handoff rule as the bootloader dialog above: wait until the + // hardware checking dialog has fully left the global iOS overlay + // before mounting the firmware verify dialog. Mounting while the old + // Sheet is still exiting can strand its overlay above the new dialog, + // and after a few genuine-check retries every tap gets swallowed. + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.closeAndWait( + closeCheckingDialogForVerify, + ); + } else { + void closeCheckingDialogForVerify(); + } let isVerified: boolean | undefined; const result = await new Promise( (resolve, reject) => { void showFirmwareVerifyDialog({ device: latestDevice, features, + // iOS only, matching the closeAndWait gate above: the page + // portal sits below the global overlay on every platform, so + // without the awaited close an in-page dialog would sit under + // the exiting checking sheet on Android/desktop. Those + // platforms keep the global host (pre-existing behavior). + dialogHost: platformEnv.isNativeIOS + ? getBootloaderDialogHost?.() + : undefined, onVerified: ({ checked }: { checked: boolean }) => { isVerified = checked; setTimeout(() => { @@ -677,7 +840,8 @@ export function useDeviceConnect({ // } return { - verified: true, + verified: false, + skipVerification: true, device: latestDevice, payload: { deviceType: latestDevice.deviceType, @@ -690,8 +854,11 @@ export function useDeviceConnect({ }, }; } catch (error) { - // Clear force transport type on device connection error - void backgroundApiProxy.serviceHardwareUI.cleanHardwareUiState(); + // The hardware dialog was already closed before the bootloader dialog + // mounted. A late cleanup write here can race with that handoff on iOS. + if (!platformEnv.isNativeIOS || !bootloaderDialogShown) { + void backgroundApiProxy.serviceHardwareUI.cleanHardwareUiState(); + } console.error('handleDeviceConnect error:', error); if (!connectionFailureTracked) { // Fire-and-forget; an analytics rejection must not mask the original error @@ -717,19 +884,10 @@ export function useDeviceConnect({ showFirmwareVerifyDialog, prepareUSBConnect, getActiveDevice, + getBootloaderDialogHost, ], ); - 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 +899,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 +938,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 +951,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 +973,11 @@ export function useDeviceConnect({ await actions.current.updateHwWalletsDeprecatedStatus({ connectId: device.connectId ?? '', - deviceId: features.device_id || device.deviceId || '', + deviceId: deviceUtils.getRawDeviceId({ + device, + features, + deviceState, + }), }); } catch (error) { errorToastUtils.toastIfError(error); @@ -870,10 +1008,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 +1028,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 +1076,13 @@ export function useDeviceConnect({ features, isFirmwareVerified, deviceState, + resolvedConnectProtocol, ); }, [ ensureActiveConnection, getActiveDevice, - extractDeviceState, + getActiveDeviceFeatures, determineWalletCreationStrategy, createHwWallet, closeDialogAndReturn, @@ -953,6 +1102,7 @@ export function useDeviceConnect({ onSelectAddWalletType, createHWWallet: onSelectAddWalletType, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, getActiveDeviceFeatures, }), @@ -962,6 +1112,7 @@ export function useDeviceConnect({ verifyHardware, onSelectAddWalletType, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, getActiveDeviceFeatures, ], @@ -971,35 +1122,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..610c87712de4 100644 --- a/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/hooks/usePrepareUSBConnectForFirmwareUpdate.tsx @@ -7,7 +7,8 @@ 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 { isProtocolV2ProductType } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; @@ -35,9 +36,29 @@ export function usePrepareUSBConnectForFirmwareUpdate() { device: SearchDevice; features: IOneKeyDeviceFeatures | undefined; }): Promise => { + const connectProtocol = isProtocolV2ProductType(device.deviceType) + ? '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 +80,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 +90,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..f3c150fbd95c 100644 --- a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import pRetry, { AbortError } from 'p-retry'; @@ -11,11 +19,13 @@ import { Button, Dialog, DialogContainer, + EInPageDialogType, HeightTransition, SizableText, Theme, XStack, YStack, + useInPageDialog, } from '@onekeyhq/components'; import { ANIMATE_ONLY_OPACITY_TRANSFORM } from '@onekeyhq/components/src/utils/animationConstants'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; @@ -33,7 +43,10 @@ import { EHardwareCallContext } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; import { AccountSelectorProviderMirror } from '../../../components/AccountSelector'; import useAppNavigation from '../../../hooks/useAppNavigation'; -import { useFirmwareUpdateActions } from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; +import { + type IBootloaderModeDialogHost, + useFirmwareUpdateActions, +} from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; import { CheckStepIllustration, type ICheckStepIllustrationTone, @@ -48,21 +61,19 @@ import { usePrepareUSBConnectForFirmwareUpdate } from '../hooks/usePrepareUSBCon import { OnboardingTestIDs } from '../testIDs'; import { getForceTransportType } from '../utils'; -import type { Features, KnownDevice, SearchDevice } from '@onekeyfe/hd-core'; - -enum ECheckAndUpdateStepState { - Idle = 'idle', - InProgress = 'inProgress', - Warning = 'warning', - Skipped = 'skipped', - Success = 'success', - Error = 'error', -} +import { + ECheckAndUpdateStepId, + ECheckAndUpdateStepState, + armPostUpdateRecheck, + beginPostUpdateRecheck, + isCheckAndUpdateReady, + isCheckAndUpdateRetryDisabled, + isCheckAndUpdateStepAccepted, + keepPostUpdateGenuineRecheckArmed, +} from './checkAndUpdateStepState'; +import { createFirmwareRecheckTimer } from './firmwareRecheckUtils'; -enum ECheckAndUpdateStepId { - GenuineCheck = 'genuine-check', - FirmwareCheck = 'firmware-check', -} +import type { Features, KnownDevice, SearchDevice } from '@onekeyfe/hd-core'; // Illustration glyph tint per step state (idle / in progress stay neutral). const STEP_STATE_TONE: Partial< @@ -81,6 +92,14 @@ const STEP_STATE_TONE: Partial< const STEP_TIMEOUT_MS = 30 * 1000; const POST_UPDATE_STEP_TIMEOUT_MS = 90 * 1000; +const BootloaderDialogHostBridge = forwardRef( + function BootloaderDialogHostBridge(_props, ref) { + const dialogHost = useInPageDialog(EInPageDialogType.inOnboardingPage); + useImperativeHandle(ref, () => dialogHost, [dialogHost]); + return null; + }, +); + function CheckAndUpdatePage({ route: routeParams, }: IPageScreenProps< @@ -88,8 +107,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); @@ -100,15 +118,29 @@ function CheckAndUpdatePage({ // write its late result, error, or timeout over the state a newer retry // round owns — step state alone can't tell two rounds apart. const firmwareCheckRunIdRef = useRef(0); - // One-shot marker for the focus-effect auto-recheck after a firmware - // update: consumed when the scheduled recheck fires (or cleared by an - // explicit user Skip). Only drives scheduling/delay math. - const firmwareUpdateFinishTimeRef = useRef(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. + // One-shot timestamp for the focus-effect recheck after firmware runtime state + // changes. Clear it when the timer fires or the user explicitly skips; it only + // drives scheduling and delay calculations. + const [firmwareRuntimeChangeTime, setFirmwareRuntimeChangeTime] = useState< + number | null + >(null); + const firmwareRecheckCancelRef = useRef<(() => void) | null>(null); + // Track "the device may still be rebooting" separately. Set it when an update + // succeeds, and clear it only after a check completes successfully. + // While set, every check, including manual retries, uses the patient reconnect path. const pendingPostUpdateReconnectRef = useRef(false); + const bootloaderDialogHostRef = useRef(null); + const getBootloaderDialogHost = useCallback( + () => bootloaderDialogHostRef.current ?? undefined, + [], + ); + const [isFirmwareRecheckPending, setIsFirmwareRecheckPending] = + useState(false); + + const cancelFirmwareRecheck = useCallback(() => { + firmwareRecheckCancelRef.current?.(); + firmwareRecheckCancelRef.current = null; + }, []); const [currentDevice, setCurrentDevice] = useState( deviceData.device as SearchDevice | undefined, @@ -129,16 +161,19 @@ function CheckAndUpdatePage({ if (!deviceType) { return deviceLabel; } - return deviceUtils.getDeviceModelNameByType(deviceType) || deviceLabel; + return deviceUtils.getDefaultDeviceLabel(deviceType) || deviceLabel; }, [currentDevice, deviceLabel]); const { verifyHardware, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, + getActiveDeviceFeatures, ensureStopScan, } = useDeviceConnect({ setCurrentDevice, + getBootloaderDialogHost, }); const { prepareUSBConnect, restoreOriginalTransport } = usePrepareUSBConnectForFirmwareUpdate(); @@ -146,13 +181,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< { @@ -192,26 +242,38 @@ function CheckAndUpdatePage({ ]); const [celebrate, setCelebrate] = useState(false); - // The flow may proceed once the firmware step is terminal-ok: Success or - // Skipped both reveal the "Continue" button, but only a real Success fires - // the celebratory confetti — skipping a check is not a pass. + // Both checks must be terminal-ok before setup can continue. Bootloader + // recovery can temporarily restart the two checks in a different order. + const genuineStepState = steps.find( + (step) => step.id === ECheckAndUpdateStepId.GenuineCheck, + )?.state; const firmwareStepState = steps.find( (step) => step.id === ECheckAndUpdateStepId.FirmwareCheck, )?.state; - const isReady = - firmwareStepState === ECheckAndUpdateStepState.Success || - firmwareStepState === ECheckAndUpdateStepState.Skipped; + const isReady = isCheckAndUpdateReady(steps); + const isAnyStepInProgress = isCheckAndUpdateRetryDisabled( + steps, + isFirmwareRecheckPending, + ); useEffect(() => { - if (firmwareStepState === ECheckAndUpdateStepState.Success) { - setCelebrate(true); - } - }, [firmwareStepState]); + setCelebrate( + isReady && firmwareStepState === ECheckAndUpdateStepState.Success, + ); + }, [firmwareStepState, isReady]); // Lets the focus-effect guard read the latest firmware step state without // joining the focus callback's dependency array. const firmwareStepStateRef = useRef(firmwareStepState); useEffect(() => { firmwareStepStateRef.current = firmwareStepState; }, [firmwareStepState]); + const genuineStepStateRef = useRef(genuineStepState); + useEffect(() => { + genuineStepStateRef.current = genuineStepState; + }, [genuineStepState]); + const [ + shouldReverifyGenuineAfterUpdate, + setShouldReverifyGenuineAfterUpdate, + ] = useState(false); const actions = useFirmwareUpdateActions(); const toFirmwareUpgradePage = useCallback(async () => { @@ -282,23 +344,45 @@ 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( - async (connectId: string, isStale: () => boolean) => { + async ( + previousDevice: SearchDevice, + isStale: () => boolean, + onConnectId: (connectId: string) => void, + ) => { try { - await pRetry( + return await pRetry( async (attemptCount) => { if (isStale()) { // A newer round took over — stop touching the device from this @@ -306,18 +390,16 @@ function CheckAndUpdatePage({ throw new AbortError('stale firmware check round'); } console.log( - `Attempting to connect to device after firmware update (attempt ${attemptCount}/5)...`, + `Attempting to reconnect device after firmware update (attempt ${attemptCount}/11)...`, ); - await backgroundApiProxy.serviceHardware.getFeaturesWithoutCache({ - connectId, - params: { - retryCount: 1, - skipWebDevicePrompt: true, - }, - }); + const result = await rebindDeviceAfterFirmwareUpdate( + previousDevice, + onConnectId, + ); console.log('Device connection successful after firmware update'); + return result.device; }, { retries: 10, @@ -355,7 +437,7 @@ function CheckAndUpdatePage({ ); } }, - [intl], + [intl, rebindDeviceAfterFirmwareUpdate], ); const checkFirmwareUpdate = useCallback( @@ -405,6 +487,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, @@ -414,37 +497,46 @@ function CheckAndUpdatePage({ await ensureTransportType(); const baseDevice = getActiveDevice() ?? currentDevice ?? deviceData.device; - if (!baseDevice?.connectId) { + if (!baseDevice || (!checkAfterUpdate && !baseDevice.connectId)) { setDeviceNotFoundErrorMessageStep(); return; } - watchdogConnectId = baseDevice.connectId; - await ensureActiveConnection(baseDevice as SearchDevice); - const latestDevice = getActiveDevice() ?? baseDevice; - setCurrentDevice(latestDevice as SearchDevice); + watchdogConnectId = baseDevice.connectId ?? undefined; + let latestDevice: SearchDevice; + if (checkAfterUpdate) { + latestDevice = await retryDeviceConnectionAfterUpdate( + baseDevice as SearchDevice, + isStale, + (connectId) => { + watchdogConnectId = connectId; + }, + ); + } else { + await ensureActiveConnection(baseDevice as SearchDevice); + latestDevice = (getActiveDevice() ?? baseDevice) as SearchDevice; + } + setCurrentDevice(latestDevice); if (!latestDevice?.connectId) { setDeviceNotFoundErrorMessageStep(); return; } - const compatibleConnectId = - await backgroundApiProxy.serviceHardware.getCompatibleConnectId({ + const resolvedTransport = + await backgroundApiProxy.serviceHardware.resolveHardwareTransport({ connectId: latestDevice.connectId, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); + const compatibleConnectId = resolvedTransport.connectId; watchdogConnectId = compatibleConnectId; - // Wait for hardware to restart after firmware update - if (checkAfterUpdate) { - await retryDeviceConnectionAfterUpdate(compatibleConnectId, isStale); - } - const r = await backgroundApiProxy.serviceFirmwareUpdate.checkAllFirmwareRelease( { connectId: compatibleConnectId, skipCancel: true, + checkFirmwareHash: checkAfterUpdate, firmwareType: undefined, + resolvedTransportType: resolvedTransport.transportType, }, ); if (isStale()) { @@ -530,23 +622,23 @@ function CheckAndUpdatePage({ const FIRMWARE_RECHECK_DELAY = 10_000; // 10 seconds - // Listen to firmware update completion event and record timestamp + // Refresh the live runtime state after an update succeeds. useEffect(() => { - const handleFirmwareUpdateFinish = () => { - console.log('Firmware update finished, recording timestamp...'); - firmwareUpdateFinishTimeRef.current = Date.now(); + const handleFirmwareRuntimeChanged = () => { + console.log('Firmware update runtime changed, recording timestamp...'); + setFirmwareRuntimeChangeTime(Date.now()); pendingPostUpdateReconnectRef.current = true; }; appEventBus.on( EAppEventBusNames.FinishFirmwareUpdate, - handleFirmwareUpdateFinish, + handleFirmwareRuntimeChanged, ); return () => { appEventBus.off( EAppEventBusNames.FinishFirmwareUpdate, - handleFirmwareUpdateFinish, + handleFirmwareRuntimeChanged, ); }; }, []); @@ -558,7 +650,7 @@ function CheckAndUpdatePage({ await restoreOriginalTransport(); })(); - const finishTime = firmwareUpdateFinishTimeRef.current; + const finishTime = firmwareRuntimeChangeTime; if (!finishTime) { return; } @@ -575,33 +667,58 @@ 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; - }); + setShouldReverifyGenuineAfterUpdate((wasArmed) => + keepPostUpdateGenuineRecheckArmed( + wasArmed, + genuineStepStateRef.current, + ), + ); + setSteps((prev) => armPostUpdateRecheck(prev)); // 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 + ) { + setFirmwareRuntimeChangeTime(null); + return; + } + // One-shot: consume the timestamp when the recheck actually fires. + // The patient-path upgrade for later rounds is carried by + // pendingPostUpdateReconnectRef instead. + setFirmwareRuntimeChangeTime(null); + setSteps((prev) => beginPostUpdateRecheck(prev)); + void checkFirmwareUpdate({ + checkAfterUpdate: true, + }); + }, + }); + firmwareRecheckCancelRef.current = cancel; return () => { - clearTimeout(timeoutId); + cancel(); + if (firmwareRecheckCancelRef.current === cancel) { + firmwareRecheckCancelRef.current = null; + } }; - }, [checkFirmwareUpdate, restoreOriginalTransport]), + }, [ + cancelFirmwareRecheck, + checkFirmwareUpdate, + firmwareRuntimeChangeTime, + restoreOriginalTransport, + ]), ); useEffect(() => { @@ -613,97 +730,124 @@ function CheckAndUpdatePage({ return unsubscribe; }, [reactNavigation]); - const handleVerifyHardware = useCallback(async () => { - // Double-check: ensure device scanning is fully stopped before starting verification - await ensureStopScan(); - await ensureTransportType(); - - setSteps((prev) => { - const newSteps = [...prev]; - newSteps[0] = { - ...newSteps[0], - state: ECheckAndUpdateStepState.InProgress, - errorMessage: undefined, - }; - return newSteps; - }); + const runGenuineCheck = useCallback( + async (checkFirmwareAfterSuccess: boolean) => { + // Double-check: ensure device scanning is fully stopped before starting verification + await ensureStopScan(); + await ensureTransportType(); - try { - const [result] = await Promise.all([ - verifyHardware(currentDevice as SearchDevice, tabValue), - new Promise((resolve) => { - setTimeout(resolve, 1200); - }), - ]); - const latestDevice = - getActiveDevice() ?? - currentDevice ?? - (deviceData.device as SearchDevice | undefined); - setCurrentDevice(latestDevice); - console.log('verifyHardware', result); - if (!result) { - throw new OneKeyLocalError( - intl.formatMessage({ id: ETranslations.global_unknown_error }), - ); - } - // Skipping (dev skip or "continue anyway" when the verify service is - // 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) { - genuineState = ECheckAndUpdateStepState.Skipped; - } - const shouldContinueToFirmwareCheck = - genuineState !== ECheckAndUpdateStepState.Error; setSteps((prev) => { const newSteps = [...prev]; newSteps[0] = { ...newSteps[0], - state: genuineState, - errorMessage: - genuineState === ECheckAndUpdateStepState.Error - ? result.result?.message - : undefined, + state: ECheckAndUpdateStepState.InProgress, + errorMessage: undefined, }; - if (shouldContinueToFirmwareCheck) { - newSteps[1] = { - ...newSteps[1], - state: ECheckAndUpdateStepState.InProgress, - }; - } return newSteps; }); - if (shouldContinueToFirmwareCheck) { - setTimeout(() => { - void checkFirmwareUpdate(); - }, 150); + + try { + const verificationDevice = + getActiveDevice() ?? currentDevice ?? deviceData.device; + const [result] = await Promise.all([ + verifyHardware(verificationDevice as SearchDevice, tabValue), + new Promise((resolve) => { + setTimeout(resolve, 1200); + }), + ]); + const latestDevice = + getActiveDevice() ?? + currentDevice ?? + (deviceData.device as SearchDevice | undefined); + setCurrentDevice(latestDevice); + console.log('verifyHardware', result); + if (!result) { + throw new OneKeyLocalError( + intl.formatMessage({ id: ETranslations.global_unknown_error }), + ); + } + // Skipping (dev skip or "continue anyway" when the verify service is + // 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) { + genuineState = ECheckAndUpdateStepState.Skipped; + } + const shouldContinueToFirmwareCheck = + genuineState !== ECheckAndUpdateStepState.Error; + setSteps((prev) => { + const newSteps = [...prev]; + newSteps[0] = { + ...newSteps[0], + state: genuineState, + errorMessage: + genuineState === ECheckAndUpdateStepState.Error + ? result.result?.message + : undefined, + }; + if (shouldContinueToFirmwareCheck && checkFirmwareAfterSuccess) { + newSteps[1] = { + ...newSteps[1], + state: ECheckAndUpdateStepState.InProgress, + }; + } + return newSteps; + }); + if (shouldContinueToFirmwareCheck && checkFirmwareAfterSuccess) { + setTimeout(() => { + void checkFirmwareUpdate(); + }, 150); + } + isFirmwareVerifiedRef.current = !!result.verified; + } catch (_error) { + setSteps((prev) => { + const newSteps = [...prev]; + newSteps[0] = { + ...newSteps[0], + state: ECheckAndUpdateStepState.Error, + }; + return newSteps; + }); } - isFirmwareVerifiedRef.current = !!result.verified; - } catch (_error) { - setSteps((prev) => { - const newSteps = [...prev]; - newSteps[0] = { - ...newSteps[0], - state: ECheckAndUpdateStepState.Error, - }; - return newSteps; - }); + }, + [ + ensureStopScan, + ensureTransportType, + verifyHardware, + deviceData.device, + tabValue, + intl, + checkFirmwareUpdate, + getActiveDevice, + currentDevice, + ], + ); + + const handleVerifyHardware = useCallback(async () => { + if (isAnyStepInProgress) { + return; } - }, [ - ensureStopScan, - ensureTransportType, - verifyHardware, - deviceData.device, - tabValue, - intl, - checkFirmwareUpdate, - getActiveDevice, - currentDevice, - ]); + await runGenuineCheck(true); + }, [isAnyStepInProgress, runGenuineCheck]); + + useEffect(() => { + if ( + !shouldReverifyGenuineAfterUpdate || + !isCheckAndUpdateStepAccepted(firmwareStepState) + ) { + return; + } + + setShouldReverifyGenuineAfterUpdate(false); + void runGenuineCheck(false); + }, [firmwareStepState, runGenuineCheck, shouldReverifyGenuineAfterUpdate]); const handleRetry = useCallback(async () => { + if (isAnyStepInProgress) { + return; + } const currentErrorStep = steps.find( (step) => step.state === ECheckAndUpdateStepState.Error, ); @@ -712,11 +856,18 @@ function CheckAndUpdatePage({ return; } if (currentErrorStep.id === ECheckAndUpdateStepId.GenuineCheck) { - await handleVerifyHardware(); + await runGenuineCheck(!isCheckAndUpdateStepAccepted(firmwareStepState)); } else if (currentErrorStep.id === ECheckAndUpdateStepId.FirmwareCheck) { await checkFirmwareUpdate(); } - }, [checkFirmwareUpdate, handleVerifyHardware, steps]); + }, [ + checkFirmwareUpdate, + firmwareStepState, + handleVerifyHardware, + isAnyStepInProgress, + runGenuineCheck, + steps, + ]); const handleSkipUpdate = useCallback(() => { Dialog.show({ @@ -747,7 +898,9 @@ 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. - firmwareUpdateFinishTimeRef.current = null; + cancelFirmwareRecheck(); + setIsFirmwareRecheckPending(false); + setFirmwareRuntimeChangeTime(null); setSteps((prev) => { const newSteps = [...prev]; newSteps[1] = { @@ -762,7 +915,7 @@ function CheckAndUpdatePage({ ), }); - }, [intl]); + }, [cancelFirmwareRecheck, intl]); useConnectDeviceError( useCallback( @@ -787,7 +940,9 @@ function CheckAndUpdatePage({ const handleSkipCurrentStep = useCallback(() => { let currentStepId: ECheckAndUpdateStepId | undefined; // Skipping also cancels any pending focus-effect auto-recheck. - firmwareUpdateFinishTimeRef.current = null; + cancelFirmwareRecheck(); + setIsFirmwareRecheckPending(false); + setFirmwareRuntimeChangeTime(null); setSteps((prev) => { const index = prev.findIndex( (step) => step.state === ECheckAndUpdateStepState.Error, @@ -813,7 +968,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 @@ -824,7 +979,10 @@ function CheckAndUpdatePage({ onPress: () => void; label: string; } | null = null; - if (!steps.some((step) => step.state !== ECheckAndUpdateStepState.Idle)) { + if ( + !isAnyStepInProgress && + !steps.some((step) => step.state !== ECheckAndUpdateStepState.Idle) + ) { bottomCta = { key: 'verify', testID: OnboardingTestIDs.checkAndUpdateVerifyBtn, @@ -855,6 +1013,7 @@ function CheckAndUpdatePage({ contentContainerProps={{ gap: '$10', pt: '$5' }} foregroundLayer={celebrate ? : null} > + {steps.map((step, index) => { // On Success, collapse the row to a single celebratory title and hide // the description. The genuine title interpolates the product model @@ -883,15 +1042,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 +1135,10 @@ function CheckAndUpdatePage({ : 'firmware' } tone={illustrationTone} - beaming={step.state === ECheckAndUpdateStepState.InProgress} + beaming={ + step.state === ECheckAndUpdateStepState.InProgress || + isFirmwareRecheckWaiting + } /> {displayTitle} @@ -984,7 +1152,8 @@ function CheckAndUpdatePage({ {/* update */} {step.id === ECheckAndUpdateStepId.FirmwareCheck && - step.state === ECheckAndUpdateStepState.Warning ? ( + step.state === ECheckAndUpdateStepState.Warning && + !isFirmwareRecheckPending ? ( {intl.formatMessage({ id: ETranslations.global_retry, diff --git a/packages/kit/src/views/Onboardingv2/pages/ConnectQRCode.tsx b/packages/kit/src/views/Onboardingv2/pages/ConnectQRCode.tsx index f22c2d64ce5c..73cd218c137d 100644 --- a/packages/kit/src/views/Onboardingv2/pages/ConnectQRCode.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/ConnectQRCode.tsx @@ -30,6 +30,19 @@ import { trackHardwareWalletConnection } from '../utils'; import { ConnectionIndicator } from './ConnectionIndicator'; +function getCreatedQrWalletDeviceType(result: unknown) { + if (!result || typeof result !== 'object' || !('device' in result)) { + return EDeviceType.Pro; + } + const { device } = result; + if (!device || typeof device !== 'object' || !('deviceType' in device)) { + return EDeviceType.Pro; + } + return device.deviceType === EDeviceType.Pro2 + ? EDeviceType.Pro2 + : EDeviceType.Pro; +} + function ConnectQRCodePage() { const { createQrWallet } = useCreateQrWallet(); const { isSoftwareWalletOnlyUser } = useUserWalletProfile(); @@ -55,7 +68,7 @@ function ConnectQRCodePage() { }, isSoftwareWalletOnlyUser, }); - await createQrWallet({ + const result = await createQrWallet({ isOnboarding: true, isOnboardingV2: true, onFinalizeWalletSetupError: () => { @@ -72,7 +85,7 @@ function ConnectQRCodePage() { void trackHardwareWalletConnection({ status: 'success', - deviceType: EDeviceType.Pro, + deviceType: getCreatedQrWalletDeviceType(result), isSoftwareWalletOnlyUser, hardwareTransportType: 'QRCode', }); diff --git a/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx b/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx index 701602429e50..198ebfa72e4c 100644 --- a/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx @@ -3,7 +3,7 @@ import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; import { useIsFocused } from '@react-navigation/core'; import { useNavigation } from '@react-navigation/native'; -import { get, isString } from 'lodash'; +import { get } from 'lodash'; import natsort from 'natsort'; import { useIntl } from 'react-intl'; import { StyleSheet } from 'react-native'; @@ -20,7 +20,6 @@ import { Popover, SegmentControl, SizableText, - Stack, Toast, Video, XStack, @@ -28,28 +27,14 @@ import { useMedia, usePopoverContext, } from '@onekeyhq/components'; +import { useOnboardingDeviceScanErrorHandler } from '@onekeyhq/kit/src/hooks/useOnboardingDeviceScanErrorHandler'; import { usePromptWebDeviceAccess } from '@onekeyhq/kit/src/hooks/usePromptWebDeviceAccess'; import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; -import { - HARDWARE_BRIDGE_DOWNLOAD_URL, - HARDWARE_TROUBLESHOOTING_URL, -} from '@onekeyhq/shared/src/config/appConfig'; -import { - BleLocationServiceError, - BridgeTimeoutError, - BridgeTimeoutErrorForDesktop, - ConnectTimeoutError, - DeviceMethodCallTimeout, - InitIframeLoadFail, - InitIframeTimeout, - NeedBluetoothPermissions, - NeedBluetoothTurnedOn, - NeedOneKeyBridge, - OneKeyHardwareError, -} from '@onekeyhq/shared/src/errors'; -import { convertDeviceError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; +import { HARDWARE_TROUBLESHOOTING_URL } from '@onekeyhq/shared/src/config/appConfig'; +import { isOneKeyHardwareError } from '@onekeyhq/shared/src/errors/utils/deviceErrorUtils'; import bleManagerInstance from '@onekeyhq/shared/src/hardware/bleManager'; import { checkBLEPermissions } from '@onekeyhq/shared/src/hardware/blePermissions'; +import { BLE_ONBOARDING_ENSURE_CONNECTED_TIMEOUT_MS } from '@onekeyhq/shared/src/hardware/connectionTimeouts'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import { showIntercom } from '@onekeyhq/shared/src/modules3rdParty/intercom'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; @@ -60,6 +45,10 @@ import { getDeviceAvatarImage, } from '@onekeyhq/shared/src/utils/avatarUtils'; import deviceUtils from '@onekeyhq/shared/src/utils/deviceUtils'; +import { + isProtocolV2ProductType, + supportsHardwareQrWallet, +} from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import { openUrlExternal } from '@onekeyhq/shared/src/utils/openUrlUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { @@ -76,25 +65,23 @@ import { OpenBleSettingsDialog, RequireBlePermissionDialog, } from '../../../components/Hardware/HardwareDialog'; -import { HyperlinkText } from '../../../components/HyperlinkText'; import { ListItem } from '../../../components/ListItem'; import { WalletAvatar } from '../../../components/WalletAvatar'; import useAppNavigation from '../../../hooks/useAppNavigation'; +import { hardwareUiStateDialogLifecycle } from '../../../provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; import { OnboardingPage } from '../components/Layout'; +import { getDeviceLabel } from '../deviceLabel'; import { EBluetoothStatus, useDesktopBluetoothStatusPolling, } from '../hooks/useDeviceConnect'; import { OnboardingTestIDs } from '../testIDs'; -import { - getDeviceLabel, - getForceTransportType, - sortDevicesData, -} from '../utils'; +import { getForceTransportType, sortDevicesData } from '../utils'; import { ConnectionIndicator } from './ConnectionIndicator'; import type { IDeviceType, SearchDevice } from '@onekeyfe/hd-core'; +import type { HardwareConnectProtocol } from '@onekeyfe/hd-shared'; import type { ReactVideoSource } from 'react-native-video'; const LedgerConnectionFlow = lazy(() => import('./ConnectionFlowLedger')); @@ -106,22 +93,6 @@ enum EConnectionStatus { listing = 'listing', } -function BridgeNotInstalledDialogContent(_props: { error: NeedOneKeyBridge }) { - return ( - - - - ); -} - interface IDeviceConnectionProps { tabValue: EConnectDeviceChannel; deviceTypeItems: EDeviceType[]; @@ -142,7 +113,6 @@ function useDeviceConnection({ onDeviceSelect?: (item: IConnectYourDeviceItem) => Promise | void; vendor?: EHardwareVendor; }) { - const intl = useIntl(); const [connectStatus, setConnectStatus] = useState(EConnectionStatus.init); const [searchedDevices, setSearchedDevices] = useState([]); const [isCheckingDeviceLoading, setIsChecking] = useState(false); @@ -196,6 +166,19 @@ function useDeviceConnection({ currentTabValueRef.current = tabValue; }, [tabValue, deviceScanner]); + const stopScan = useCallback(() => { + isSearchingRef.current = false; + deviceScanner.stopScan(); + }, [deviceScanner]); + + const stopScanAfterError = useCallback(() => { + setConnectStatus(EConnectionStatus.init); + stopScan(); + }, [stopScan]); + + const { handleScanError, resetScanError } = + useOnboardingDeviceScanErrorHandler({ stopScan: stopScanAfterError }); + const scanDevice = useCallback(async () => { if (isSearchingRef.current) { return; @@ -208,80 +191,19 @@ function useDeviceConnection({ forceTransportType, }); } + const transportType = + forceTransportType === EHardwareTransportType.BLE || + forceTransportType === EHardwareTransportType.DesktopWebBle + ? 'ble' + : 'usb'; isSearchingRef.current = true; deviceScanner.startDeviceScan( (response) => { if (!response.success) { - const error = convertDeviceError(response.payload); - if (platformEnv.isNative) { - if ( - !(error instanceof NeedBluetoothTurnedOn) && - !(error instanceof NeedBluetoothPermissions) && - !(error instanceof BleLocationServiceError) - ) { - Toast.error({ - title: error.message || 'DeviceScanError', - }); - } else { - deviceScanner.stopScan(); - } - } else if ( - error instanceof InitIframeLoadFail || - error instanceof InitIframeTimeout - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_network_error, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if ( - error instanceof BridgeTimeoutError || - error instanceof BridgeTimeoutErrorForDesktop - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_connection_failed, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if ( - error instanceof ConnectTimeoutError || - error instanceof DeviceMethodCallTimeout - ) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.global_connection_failed, - }), - message: error.message || 'DeviceScanError', - }); - deviceScanner.stopScan(); - } - - if (error instanceof NeedOneKeyBridge) { - Dialog.confirm({ - icon: 'OnekeyBrand', - title: intl.formatMessage({ - id: ETranslations.onboarding_install_onekey_bridge, - }), - renderContent: , - onConfirmText: intl.formatMessage({ - id: ETranslations.global_download_and_install, - }), - onConfirm: () => openUrlExternal(HARDWARE_BRIDGE_DOWNLOAD_URL), - }); - - deviceScanner.stopScan(); - } return; } + resetScanError(); const sortedDevices = response.payload.toSorted((a, b) => natsort({ insensitive: true })( @@ -292,18 +214,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,13 +230,9 @@ function useDeviceConnection({ undefined, // pollInterval undefined, // maxTryCount vendor, + { transportType, onError: handleScanError }, ); - }, [deviceScanner, intl, tabValue, vendor]); - - const stopScan = useCallback(() => { - isSearchingRef.current = false; - deviceScanner.stopScan(); - }, [deviceScanner]); + }, [deviceScanner, handleScanError, resetScanError, tabValue, vendor]); const ensureStopScan = useCallback(async () => { // Force stop scanning and wait for any ongoing search to complete @@ -336,10 +247,12 @@ function useDeviceConnection({ console.log( 'ensureStopScan: Device scan stopped and all ongoing searches completed', ); + return true; } catch (error) { console.error('ensureStopScan: Error while stopping scan:', error); - // Fallback: just stop scan without waiting + // 仅停止 UI 轮询不足以证明 Noble 已停止;本次不继续连接。 deviceScanner.stopScan(); + return false; } }, [deviceScanner]); @@ -359,7 +272,12 @@ function useDeviceConnection({ if (!item.device) { return; } - void ensureStopScan(); + // Noble 不能同时稳定地执行设备枚举和定向连接。必须等当前扫描及其 + // stopScanning 回调完成,再把已发现的 peripheral 交给连接流程。 + const scanStopped = await ensureStopScan(); + if (!scanStopped) { + return; + } if (onDeviceSelect) { await onDeviceSelect(item); } @@ -544,6 +462,11 @@ function BluetoothCard({ } function DeviceVideo({ deviceTypeItems }: { deviceTypeItems: EDeviceType[] }) { + const isProtocolV2Product = useMemo( + () => deviceTypeItems.some(isProtocolV2ProductType), + [deviceTypeItems], + ); + const isTouch = useMemo(() => { return deviceTypeItems.find( (deviceType) => deviceType === EDeviceType.Touch, @@ -568,6 +491,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 (isProtocolV2Product) { + 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 +504,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, isProtocolV2Product, isTouch]); return (