diff --git a/apps/cli/package.json b/apps/cli/package.json index 5e890f0b9f5b..de4e0414db23 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -44,9 +44,9 @@ }, "dependencies": { "@napi-rs/keyring": "^1.3.0", - "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.77", - "@onekeyfe/hd-core": "1.2.0-alpha.77", - "@onekeyfe/hd-transport-usb": "1.2.0-alpha.77", + "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.99", + "@onekeyfe/hd-core": "1.2.0-alpha.99", + "@onekeyfe/hd-transport-usb": "1.2.0-alpha.99", "proper-lockfile": "^4.1.2" } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4c7b5385adc1..a3c0adfc5179 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/web-embed/postbuild.sh b/apps/web-embed/postbuild.sh index 82862c3eabec..d66780aa39d0 100755 --- a/apps/web-embed/postbuild.sh +++ b/apps/web-embed/postbuild.sh @@ -21,4 +21,3 @@ echo "Syncing web-build to iOS assets..." rsync -r -c -v ./web-build/ ../mobile/ios/OneKeyWallet/web-embed/ echo "Postbuild completed successfully." - diff --git a/package.json b/package.json index 7f263f8f0431..776f346c75c5 100644 --- a/package.json +++ b/package.json @@ -158,21 +158,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.2.0-alpha.77", - "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.77", - "@onekeyfe/hd-core": "1.2.0-alpha.77", - "@onekeyfe/hd-shared": "1.2.0-alpha.77", - "@onekeyfe/hd-transport": "1.2.0-alpha.77", - "@onekeyfe/hd-transport-electron": "1.2.0-alpha.77", - "@onekeyfe/hd-web-sdk": "1.2.0-alpha.77", - "@onekeyfe/hwk-adapter-core": "1.2.0-alpha.77", - "@onekeyfe/hwk-ledger-adapter": "1.2.0-alpha.77", - "@onekeyfe/hwk-ledger-connector-ble": "1.2.0-alpha.77", - "@onekeyfe/hwk-ledger-connector-webhid": "1.2.0-alpha.77", - "@onekeyfe/hwk-trezor-adapter": "1.2.0-alpha.77", - "@onekeyfe/hwk-trezor-connector-electron-ble": "1.2.0-alpha.77", - "@onekeyfe/hwk-trezor-connector-rn-ble": "1.2.0-alpha.77", - "@onekeyfe/hwk-trezor-connector-webusb": "1.2.0-alpha.77", + "@onekeyfe/hd-ble-sdk": "1.2.0-alpha.99", + "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.99", + "@onekeyfe/hd-core": "1.2.0-alpha.99", + "@onekeyfe/hd-shared": "1.2.0-alpha.99", + "@onekeyfe/hd-transport": "1.2.0-alpha.99", + "@onekeyfe/hd-transport-electron": "1.2.0-alpha.99", + "@onekeyfe/hd-web-sdk": "1.2.0-alpha.99", + "@onekeyfe/hwk-adapter-core": "1.2.0-alpha.99", + "@onekeyfe/hwk-ledger-adapter": "1.2.0-alpha.99", + "@onekeyfe/hwk-ledger-connector-ble": "1.2.0-alpha.99", + "@onekeyfe/hwk-ledger-connector-webhid": "1.2.0-alpha.99", + "@onekeyfe/hwk-trezor-adapter": "1.2.0-alpha.99", + "@onekeyfe/hwk-trezor-connector-electron-ble": "1.2.0-alpha.99", + "@onekeyfe/hwk-trezor-connector-rn-ble": "1.2.0-alpha.99", + "@onekeyfe/hwk-trezor-connector-webusb": "1.2.0-alpha.99", "@onekeyfe/onekey-cross-webview": "2.2.73", "@polkadot/extension-inject": "0.54.1", "@polkadot/types": "14.3.1", @@ -416,11 +416,11 @@ "@reown/appkit-ethers5-react-native": "https://github.com/OneKeyHQ/app-modules#9d96daccc13625e5b3c8b236f9357956b049b884", "@reown/appkit-scaffold-react-native": "https://github.com/OneKeyHQ/app-modules#ef39e1c6682f8b50dc019a851f6e2211392d353a", "@reown/appkit-scaffold-utils-react-native": "https://github.com/OneKeyHQ/app-modules#aa31ef69e5058bb822c40f0a706ee9bdd191b005", - "@onekeyfe/hd-core": "1.2.0-alpha.77", - "@onekeyfe/hd-shared": "1.2.0-alpha.77", - "@onekeyfe/hd-transport": "1.2.0-alpha.77", - "@onekeyfe/hd-transport-http": "1.2.0-alpha.77", - "@onekeyfe/hd-transport-web-device": "1.2.0-alpha.77", + "@onekeyfe/hd-core": "1.2.0-alpha.99", + "@onekeyfe/hd-shared": "1.2.0-alpha.99", + "@onekeyfe/hd-transport": "1.2.0-alpha.99", + "@onekeyfe/hd-transport-http": "1.2.0-alpha.99", + "@onekeyfe/hd-transport-web-device": "1.2.0-alpha.99", "promise": "^8.3.0", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts index e0ab68e74729..b7047b44256e 100644 --- a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts @@ -27,13 +27,18 @@ describe('DesktopApiFirmwareArtifact URL admission', () => { 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-d5c080673b4e4e9dae7e03680340378d.r2.dev/firmware.bin', + 'https://pub-568cac7a13bf4c42b7a8113ffffc6793.r2.dev/firmware.bin', ), ), - ).toBe(true); + ).toBe(false); expect( isFirmwareArtifactUrlAllowed( new URL('https://web.onekey-asset.com.evil.test/firmware.bin'), @@ -46,6 +51,90 @@ describe('DesktopApiFirmwareArtifact URL admission', () => { ).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, @@ -63,6 +152,54 @@ describe('DesktopApiFirmwareArtifact URL admission', () => { }); }); + 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'); + // Skipping hostname pinning requires pinned content. + await expect( + adapter.download({ + ...input, + expectedSha256: undefined, + allowPreReleaseHosts: true, + }), + ).rejects.toThrow('ARTIFACT_INVALID_INPUT'); + // 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, diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts index 5991d967c525..f558c206cbab 100644 --- a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts @@ -50,7 +50,6 @@ 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', - 'pub-d5c080673b4e4e9dae7e03680340378d.r2.dev', 'web.onekey-asset.com', ]); @@ -91,13 +90,23 @@ const assertSafeInteger = (value: number, label: string): void => { } }; -export const isFirmwareArtifactUrlAllowed = (url: URL): boolean => +// 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. The +// structural checks below still apply, and validateDownloadInput additionally +// requires a pinned SHA-256 for these downloads, so every admitted artifact is +// covered by a reviewed hostname or by pinned content. +export const isFirmwareArtifactUrlAllowed = ( + url: URL, + options?: { allowPreReleaseHosts?: boolean }, +): boolean => url.protocol === 'https:' && url.port === '' && !url.username && !url.password && !url.hash && - FIRMWARE_ARTIFACT_HOSTNAMES.has(url.hostname.toLowerCase()); + (options?.allowPreReleaseHosts === true || + FIRMWARE_ARTIFACT_HOSTNAMES.has(url.hostname.toLowerCase())); const validatePortableEntryName = ( name: string, @@ -487,7 +496,16 @@ class DesktopApiFirmwareArtifact implements IFirmwareArtifactAdapter { private validateDownloadInput(input: IDownloadInput): void { const url = new URL(input.url); - if (!isFirmwareArtifactUrlAllowed(url)) { + const allowPreReleaseHosts = input.allowPreReleaseHosts === true; + // Dropping hostname pinning is only acceptable against pinned content, so + // a pre-release artifact without a pinned digest must never be admitted. + if (allowPreReleaseHosts && input.expectedSha256 === undefined) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Pre-release firmware artifacts require a pinned SHA-256', + ); + } + if (!isFirmwareArtifactUrlAllowed(url, { allowPreReleaseHosts })) { throw new FirmwareArtifactDesktopError( 'ARTIFACT_INVALID_INPUT', 'Firmware URL is outside the reviewed artifact host allowlist', diff --git a/packages/kit-bg/src/services/ServiceDevSetting.ts b/packages/kit-bg/src/services/ServiceDevSetting.ts index 9630b71f6f22..c42d22f4face 100644 --- a/packages/kit-bg/src/services/ServiceDevSetting.ts +++ b/packages/kit-bg/src/services/ServiceDevSetting.ts @@ -24,6 +24,7 @@ import { devSettingsPersistAtom, firmwareUpdateDevSettingsPersistAtom, getDevSettingsNetworkThrottleEnabled, + getGatedFirmwareUpdateDevSetting, } from '../states/jotai/atoms/devSettings'; import ServiceBase from './ServiceBase'; @@ -308,12 +309,7 @@ class ServiceDevSetting extends ServiceBase { public async getFirmwareUpdateDevSettings< T extends IFirmwareUpdateDevSettingsKeys, >(key: T): Promise { - const dev = await devSettingsPersistAtom.get(); - if (!dev.enabled) { - return undefined; - } - const fwDev = await firmwareUpdateDevSettingsPersistAtom.get(); - return fwDev[key]; + return getGatedFirmwareUpdateDevSetting(key); } @backgroundMethod() diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts index 31d0c3fe50d5..b4110a19137b 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts @@ -48,6 +48,9 @@ export interface IFirmwareArtifactAdapter { 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: { diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts index 7387e4cd43fa..832d4f8445c4 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts @@ -1,5 +1,7 @@ import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { getGatedFirmwareUpdateDevSetting } from '../../states/jotai/atoms/devSettings'; + import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; @@ -113,10 +115,6 @@ export type IPreparedFirmwareArtifacts = { IFirmwareArtifactReference > >; - resourceBundleArtifacts: { - name: string; - artifact: IFirmwareArtifactReference; - }[]; }; artifactReader: IFirmwareArtifactReader; }; @@ -205,6 +203,10 @@ export const downloadFirmwareArtifact = async ({ 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, @@ -219,6 +221,7 @@ export const downloadFirmwareArtifact = async ({ ...(artifact.expectedSha256 ? { expectedSha256: artifact.expectedSha256 } : {}), + ...(allowPreReleaseHosts ? { allowPreReleaseHosts } : {}), maxBytes: artifact.expectedSize ?? MAX_ARTIFACT_BYTES, overallDeadlineSeconds: remainingMs / 1000, }); @@ -409,10 +412,46 @@ const getBridgeBinaryPlanArtifacts = ( ); }; +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', @@ -430,6 +469,11 @@ export async function prepareBridgeFirmwareBinaries( 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, @@ -439,11 +483,6 @@ export async function prepareBridgeFirmwareBinaries( leaseRef, deadlineAt, }); - if (planArtifact.target === 'resource') { - throw new OneKeyLocalError( - 'Desktop Bridge resource binaries are not in the small artifact scope', - ); - } targetBinaries[planArtifact.target] = await readFirmwareArtifact(receipt); } completed = true; @@ -580,8 +619,9 @@ export async function prepareFirmwareArtifacts( const componentArtifacts: IPreparedFirmwareArtifacts['selected']['componentArtifacts'] = {}; - const resourceBundleArtifacts: IPreparedFirmwareArtifacts['selected']['resourceBundleArtifacts'] = - []; + const resourceEntries: NonNullable< + IPreparedFirmwareArtifacts['selected']['resourceEntries'] + > = []; for (const planArtifact of plan.artifacts) { const artifact = artifactsById[planArtifact.artifactId]; if (!artifact) { @@ -599,16 +639,13 @@ export async function prepareFirmwareArtifacts( } componentArtifacts[target] = artifact; } - if (planArtifact.role === 'resourceBundle') { - if (!planArtifact.logicalName) { - throw new OneKeyLocalError( - 'Firmware resource bundle has no logical name', - ); - } - resourceBundleArtifacts.push({ - name: planArtifact.logicalName, - artifact, - }); + if ( + planArtifact.target === 'resource' && + planArtifact.container === 'zip' + ) { + resourceEntries.push( + ...(resourceEntriesByArtifactId[planArtifact.artifactId] ?? []), + ); } } const preparedPlan = preparePlan({ @@ -641,41 +678,9 @@ export async function prepareFirmwareArtifacts( firmware: artifactsById.firmware, ble: artifactsById.ble, bootloader: artifactsById.bootloader, - resourceEntries: resourceEntriesByArtifactId.resource, + resourceEntries: resourceEntries.length ? resourceEntries : undefined, componentArtifacts, - resourceBundleArtifacts, }, artifactReader: createArtifactReader(), }; } - -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; - } -} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts index 9c6e66a29bdf..f71b1676333a 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts @@ -158,7 +158,6 @@ const createController = async ({ selected: { ...(artifactId === 'firmware' ? { firmware: receipt } : {}), componentArtifacts: {}, - resourceBundleArtifacts: [], }, artifactReader: { open, diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts index 2af52133bb1a..0293183bea04 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts @@ -172,6 +172,15 @@ export const getFirmwareArtifactSelfTestArtifact = async ( 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, diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts index 633846a79d23..fbcddd8afc45 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts @@ -123,7 +123,7 @@ describe('FirmwareManifestProvider', () => { expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(2); }); - it('keeps the last known-good snapshot when a refresh fails', async () => { + it('does not use a stale snapshot for an explicit firmware check', async () => { mockedFetchFirmwareConfig .mockResolvedValueOnce(remoteConfig) .mockRejectedValueOnce(new Error('transport failed')); @@ -134,6 +134,6 @@ describe('FirmwareManifestProvider', () => { preRelease: true, forceRefresh: true, }), - ).resolves.toBe(remoteConfig); + ).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 index dab64e28fcc5..7241410e3951 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts @@ -24,8 +24,10 @@ export function clearFirmwareManifestSnapshotCache(): void { async function refreshFirmwareManifestSnapshot({ preRelease, + allowStaleFallback, }: { preRelease: boolean; + allowStaleFallback: boolean; }): Promise { const cached = firmwareManifestCache.get(preRelease); try { @@ -41,7 +43,7 @@ async function refreshFirmwareManifestSnapshot({ // Keep using the last known-good remote snapshot below. } - if (cached) { + if (allowStaleFallback && cached) { defaultLogger.ipTable.request.info({ info: '[FirmwareManifest] config_fetch route=cache outcome=stale_fallback', }); @@ -68,11 +70,12 @@ export async function getFirmwareManifestSnapshot({ return pendingRefresh; } - const refresh = refreshFirmwareManifestSnapshot({ preRelease }).finally( - () => { - firmwareManifestRefreshes.delete(preRelease); - }, - ); + 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 index df4c346e8a94..9b023449be37 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts @@ -22,7 +22,7 @@ import type { import type { CoreApi, FirmwareUpdatePlan, - FirmwareUpdatePlanForceTarget, + FirmwareUpdatePlanTarget, } from '@onekeyfe/hd-core'; export type IFirmwareWorkflowArtifacts = @@ -64,14 +64,11 @@ const getPreparedArtifactTraceSummary = ( firmwareBytes: prepared.selected.firmware?.size, bleBytes: prepared.selected.ble?.size, bootloaderBytes: prepared.selected.bootloader?.size, - resourceCount: - resourceEntries.length + prepared.selected.resourceBundleArtifacts.length, - resourceBytes: - resourceEntries.reduce((total, entry) => total + entry.artifact.size, 0) + - prepared.selected.resourceBundleArtifacts.reduce( - (total, entry) => total + entry.artifact.size, - 0, - ), + resourceCount: resourceEntries.length, + resourceBytes: resourceEntries.reduce( + (total, entry) => total + entry.artifact.size, + 0, + ), integrityVerified: artifactReferences.length > 0 && artifactReferences.every( @@ -102,12 +99,32 @@ const getBridgeArtifactTraceSummary = (bridge: IBridgeFirmwareBinaries) => { }; }; +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 FirmwareUpdatePlanForceTarget[]; + expectedTargets: readonly FirmwareUpdatePlanTarget[]; }): void => { if ( !Array.isArray(plan.artifacts) || @@ -124,6 +141,7 @@ const assertFirmwareUpdatePlanCoverage = ({ 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)) @@ -132,10 +150,22 @@ const assertFirmwareUpdatePlanCoverage = ({ '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: FirmwareUpdatePlanForceTarget, - ) => { + const coversExpectedTarget = (expectedTarget: FirmwareUpdatePlanTarget) => { switch (expectedTarget) { case 'firmware': return plan.artifacts.some( @@ -144,7 +174,10 @@ const assertFirmwareUpdatePlanCoverage = ({ (artifact.role === 'component' && artifact.target !== 'resource'), ); case 'ble': - return plan.artifacts.some((artifact) => artifact.role === 'ble'); + return plan.artifacts.some( + (artifact) => + artifact.role === 'ble' || artifact.target === 'coprocessor', + ); case 'bootloader': return plan.artifacts.some( (artifact) => @@ -157,7 +190,7 @@ const assertFirmwareUpdatePlanCoverage = ({ artifact.role === 'resource' || artifact.role === 'resourceBundle', ); default: - return false; + return planTargets.has(expectedTarget); } }; @@ -177,9 +210,11 @@ export class FirmwarePreparedArtifactController { private hostBindings = new Map(); - constructor( - private readonly dependencies: IFirmwarePreparedArtifactControllerDependencies, - ) {} + private readonly dependencies: IFirmwarePreparedArtifactControllerDependencies; + + constructor(dependencies: IFirmwarePreparedArtifactControllerDependencies) { + this.dependencies = dependencies; + } private async getExternalSdk( connectId: string | undefined, @@ -218,7 +253,7 @@ export class FirmwarePreparedArtifactController { plan: FirmwareUpdatePlan; connectId: string | undefined; transportType: EHardwareTransportType; - expectedTargets?: readonly FirmwareUpdatePlanForceTarget[]; + expectedTargets?: readonly FirmwareUpdatePlanTarget[]; }): Promise { assertFirmwareUpdatePlanCoverage({ plan, expectedTargets }); this.plans.set(plan.planDigest, plan); @@ -245,23 +280,39 @@ export class FirmwarePreparedArtifactController { connectId, transportType, expectedTargets = [], + requirePreparedPlan = false, }: { hasUpgrade: boolean | undefined; plan: FirmwareUpdatePlan | undefined; connectId: string | undefined; transportType: EHardwareTransportType; - expectedTargets?: readonly FirmwareUpdatePlanForceTarget[]; + expectedTargets?: readonly FirmwareUpdatePlanTarget[]; + requirePreparedPlan?: boolean; }): Promise { - return hasUpgrade && - plan && - (await this.cachePlanIfPreparedSupported({ - plan, - connectId, - transportType, - expectedTargets, - })) - ? plan.planDigest - : undefined; + 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 { @@ -281,6 +332,10 @@ export class FirmwarePreparedArtifactController { 'Firmware update plan does not match the selected device', ); } + assertProtocolV2PlanTargetsMatch({ + plan, + expectedTargets: releaseResult.pro2TargetsToUpdate ?? [], + }); return plan; } @@ -289,12 +344,9 @@ export class FirmwarePreparedArtifactController { if (existing) { existing.sdk.unregisterFirmwareUpdateHostBinding(existing.generation); } - const generation = ( - sdk.registerFirmwareUpdateHostBinding as unknown as (binding: { - artifactReader: IPreparedFirmwareArtifacts['artifactReader']; - }) => number - )({ + const generation = sdk.registerFirmwareUpdateHostBinding({ artifactReader: prepared.artifactReader, + preparedPlanDigest: prepared.preparedPlan.preparedPlanDigest, }); if (!Number.isSafeInteger(generation) || generation <= 0) { throw new OneKeyLocalError( diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts index a318ca5469f8..47c4bad210bd 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts @@ -290,42 +290,26 @@ export const executePreparedFirmwareUpdateV4 = ({ firmwareType: EFirmwareType | undefined; targetsToUpdate: FirmwareUpdateV4Target[]; forcedUpdateRes: boolean; -} & IFirmwareExecutionArtifacts) => - sdk.firmwareUpdateV4(connectId, { +} & IFirmwareExecutionArtifacts) => { + if (preparedArtifacts) { + return sdk.firmwareUpdateV4(connectId, { + platform, + preparedPlan: preparedArtifacts.preparedPlan, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + + return sdk.firmwareUpdateV4(connectId, { platform, firmwareType, targetsToUpdate, ...getBridgeFirmwareV4BinaryParams(bridgeBinaries), - ...(!preparedArtifacts ? { forcedUpdateRes } : {}), - ...(preparedArtifacts - ? { - preparedPlan: preparedArtifacts.preparedPlan, - expectedDeviceId: preparedArtifacts.plan.deviceIdentity, - expectedTargetVersions: Object.fromEntries( - preparedArtifacts.plan.artifacts.flatMap((artifact) => - artifact.targetVersion && - FIRMWARE_UPDATE_V4_TARGETS.has( - artifact.target as FirmwareUpdateV4Target, - ) - ? [ - [ - artifact.target as FirmwareUpdateV4Target, - artifact.targetVersion, - ] as const, - ] - : [], - ), - ), - componentArtifacts: preparedArtifacts.selected.componentArtifacts, - resourceBundleArtifacts: - preparedArtifacts.selected.resourceBundleArtifacts, - hostBindingGeneration: requireHostBindingGeneration( - preparedArtifacts, - hostBindingGeneration, - ), - } - : {}), + forcedUpdateRes, }); +}; export const executePreparedFirmwareUpdateV3 = ({ sdk, diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts index 17bba2657c2f..e86f240e7c80 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts @@ -6,6 +6,8 @@ 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, @@ -22,6 +24,7 @@ import { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactCo import { executePreparedFirmwareUpdateV2, executePreparedFirmwareUpdateV3, + executePreparedFirmwareUpdateV4, } from './FirmwarePreparedExecution'; import type { IPreparedFirmwareArtifacts } from './FirmwareArtifactPreflight'; @@ -32,6 +35,10 @@ import type { FirmwareUpdatePreparedPlan, } from '@onekeyfe/hd-core'; +jest.mock('../../states/jotai/atoms/devSettings', () => ({ + getGatedFirmwareUpdateDevSetting: jest.fn(async () => undefined), +})); + const ready = { planSchemaVersion: 2, preparedPlanSchemaVersion: 2, @@ -133,7 +140,6 @@ describe('prepared firmware execution', () => { }, selected: { componentArtifacts: {}, - resourceBundleArtifacts: [], ...selected, }, }) as unknown as IPreparedFirmwareArtifacts; @@ -325,6 +331,31 @@ describe('prepared firmware execution', () => { ); }); + 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('logs and passes Desktop Bridge binaries without claiming a resource input', async () => { const firmwareUpdateV3 = jest.fn(); const sdk = { firmwareUpdateV3 } as unknown as CoreApi; @@ -474,6 +505,75 @@ describe('prepared firmware execution', () => { } }); + 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('rejects empty and partial Desktop Bridge plans before preparation', async () => { const previousPlatform = { isDesktop: platformEnv.isDesktop, @@ -545,6 +645,188 @@ describe('prepared firmware execution', () => { 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', () => { @@ -596,6 +878,37 @@ describe('downloadFirmwareArtifact', () => { 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 () => { @@ -700,6 +1013,34 @@ describe('Desktop Bridge firmware binaries', () => { 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({ @@ -743,10 +1084,8 @@ describe('Desktop Bridge firmware binaries', () => { }); }); - test('downloads a remote plan URL without requiring an App-bundled catalog', async () => { + test('downloads a verified remote plan URL without requiring an App-bundled catalog', async () => { const { plan } = createBridgePlan(); - delete plan.artifacts[0].expectedSize; - delete plan.artifacts[0].expectedSha256; jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ firmwareArtifactProtocolVersion: 4, maxReadBytes: 256 * 1024, @@ -756,14 +1095,14 @@ describe('Desktop Bridge firmware binaries', () => { jest .spyOn(firmwareArtifactAdapter, 'createLease') .mockResolvedValue({ leaseRef: 'fwlease:catalog' }); - const actualSha256 = '2'.repeat(64); + const actualSha256 = plan.artifacts[0].expectedSha256; const download = jest .spyOn(firmwareArtifactAdapter, 'download') .mockResolvedValue({ artifactRef: `fw:${actualSha256}`, size: testFirmwareArtifact.expectedSize, sha256: actualSha256, - expectedSha256Verified: false, + expectedSha256Verified: true, }); jest.spyOn(firmwareArtifactAdapter, 'open').mockResolvedValue({ readerId: 'reader', @@ -784,11 +1123,11 @@ describe('Desktop Bridge firmware binaries', () => { expect.objectContaining({ artifactId: 'bootloader', url: plan.artifacts[0].url, - maxBytes: 512 * 1024 * 1024, + expectedSize: plan.artifacts[0].expectedSize, + expectedSha256: plan.artifacts[0].expectedSha256, + maxBytes: plan.artifacts[0].expectedSize, }), ); - expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSize'); - expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSha256'); }); test('cancels an active preparation after a download failure', async () => { @@ -947,8 +1286,8 @@ describe('external firmware artifact preparation', () => { platform: 'native', artifacts: [ { - artifactId: 'resource', - role: 'resource', + artifactId: 'resource:archive', + role: 'resourceBundle', target: 'resource', url: 'https://firmware.example/resources.zip', container: 'zip', @@ -1019,7 +1358,7 @@ describe('external firmware artifact preparation', () => { expect.objectContaining({ artifacts: [ expect.objectContaining({ - artifactId: 'resource', + artifactId: 'resource:archive', materializedEntries: [ { entryName: 'resource.bin', @@ -1064,11 +1403,12 @@ describe('external firmware artifact preparation', () => { 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: jest.fn(() => 9), + registerFirmwareUpdateHostBinding, unregisterFirmwareUpdateHostBinding, } as unknown as CoreApi; jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); @@ -1111,6 +1451,11 @@ describe('external firmware artifact preparation', () => { expect(validateFirmwareUpdatePreparedPlan).toHaveBeenCalledWith( preparedPlan, ); + expect(registerFirmwareUpdateHostBinding).toHaveBeenCalledWith( + expect.objectContaining({ + preparedPlanDigest: preparedPlan.preparedPlanDigest, + }), + ); expect(unregisterFirmwareUpdateHostBinding).toHaveBeenCalledWith(9); expect(releaseLease).toHaveBeenCalledWith({ leaseRef: 'fwlease:session', 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/ServiceFirmwareUpdate.detect.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts index bfe97606f057..a7ff6b5cc1cb 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts @@ -2,22 +2,33 @@ import { EDeviceType } from '@onekeyfe/hd-shared'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +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, { buildPro2TargetsToUpdate, + buildProtocolV2FirmwareVersionInfo, + buildProtocolV2PlanForceTargets, + shouldForceProtocolV2ResourceUpdate, + supportsFirmwareUpdateWorkflowV2, } from './ServiceFirmwareUpdate'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; @@ -71,6 +82,8 @@ jest.mock('../../dbs/local/localDb', () => ({ jest.mock('../../states/jotai/atoms', () => ({ EFirmwareUpdateSteps: { init: 'init', + installing: 'installing', + updateStart: 'updateStart', }, EHardwareUiStateAction: {}, firmwareUpdateResultVerifyAtom: { @@ -81,6 +94,10 @@ jest.mock('../../states/jotai/atoms', () => ({ set: jest.fn(), }, firmwareUpdateStepInfoAtom: { + get: jest.fn().mockResolvedValue({ + step: 'updateStart', + payload: { startAtTime: 1 }, + }), set: jest.fn(), }, firmwareUpdateWorkflowRunningAtom: { @@ -133,6 +150,7 @@ describe('ServiceFirmwareUpdate firmware manifest refresh', () => { connectId: 'device-1', firmwareType: undefined, skipChangeTransportType: true, + protocolV2ForceUpdateTargets: ['app_v1', 'coprocessor'], }); expect(getSDKInstance).toHaveBeenCalledWith({ @@ -140,6 +158,12 @@ describe('ServiceFirmwareUpdate firmware manifest refresh', () => { forceFirmwareManifestRefresh: true, }); expect(checkAllFirmwareRelease).toHaveBeenCalledTimes(1); + expect(checkAllFirmwareRelease).toHaveBeenCalledWith( + 'device-1', + expect.objectContaining({ + protocolV2ForceUpdateTargets: ['app_v1', 'coprocessor'], + }), + ); }); }); @@ -180,6 +204,149 @@ 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', () => { @@ -207,14 +374,6 @@ describe('buildPro2TargetsToUpdate', () => { ).toEqual(['se01', 'resource']); }); - it('keeps boot resources independent from stable resources', () => { - expect( - buildPro2TargetsToUpdate({ - sdkTargets: ['resource', 'boot_resources'], - }), - ).toEqual(['resource', 'boot_resources']); - }); - it('merges and deduplicates developer force targets after SDK targets', () => { expect( buildPro2TargetsToUpdate({ @@ -225,6 +384,242 @@ describe('buildPro2TargetsToUpdate', () => { }); }); +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(); @@ -246,11 +641,15 @@ describe('ServiceFirmwareUpdate Pro2 developer settings', () => { }); describe('ServiceFirmwareUpdate Pro2 resource update options', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { jest.restoreAllMocks(); }); - it('keeps an SDK-selected resource target on the incremental inventory path', async () => { + it('does not pass Protocol V2 resource binaries outside PreparedPlan', async () => { const firmwareUpdateV4 = jest.fn().mockResolvedValue({ success: true, payload: {}, @@ -302,6 +701,10 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { targetsToUpdate: ['resource'], }), ); + expect(firmwareUpdateStepInfoAtom.set).toHaveBeenCalledWith({ + step: 'installing', + payload: { installingTarget: {} }, + }); }); it('keeps the BLE peripheral ID when the active transport is desktop BLE', async () => { @@ -326,7 +729,7 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { .fn() .mockResolvedValue(EHardwareTransportType.DesktopWebBle), }, - // 持久化值可能仍是上一轮 USB;升级必须以连接管理器的实际值为准。 + // The persisted value may be stale; the active connection is authoritative. serviceSetting: { getHardwareTransportType: jest .fn() @@ -361,6 +764,85 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { }); }); +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 workflow tracking', () => { beforeEach(() => { jest.clearAllMocks(); @@ -671,11 +1153,33 @@ describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { expect(runtimeHost).not.toHaveBeenCalled(); }); - test('keeps the legacy Pro2 rejection when no Plan exists', async () => { + test('rejects a resource-only Protocol V2 update without PreparedPlan', async () => { const service = createService(); - const updatingFirmwareV4 = jest - .spyOn(service, 'updatingFirmwareV4') - .mockResolvedValue({ message: 'ok' }); + 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({ @@ -683,11 +1187,41 @@ describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { usbConnected: true, releaseResult: { deviceType: EDeviceType.Pro2, + pro2TargetsToUpdate: ['boot', 'app_v1', 'resource'], updateInfos: {}, } as ICheckAllFirmwareReleaseResult, }), - ).rejects.toThrow('Do not support update firmware for this device'); + ).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(); }); @@ -695,7 +1229,7 @@ describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { const service = createService(); const plan = { executor: 'v4', - targetsToUpdate: ['boot', 'app_v1'], + targetsToUpdate: ['boot', 'app_v1', 'resource'], }; jest .spyOn( @@ -722,6 +1256,7 @@ describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { releaseResult: { deviceType: EDeviceType.Pro2, firmwareUpdatePlanDigest: 'c'.repeat(64), + pro2TargetsToUpdate: ['boot', 'app_v1', 'resource'], updateInfos: {}, } as ICheckAllFirmwareReleaseResult, }); @@ -729,7 +1264,7 @@ describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { expect(updatingFirmwareV4).toHaveBeenCalledWith( expect.objectContaining({ requirePreparedArtifacts: true, - targetsToUpdate: ['boot', 'app_v1'], + targetsToUpdate: ['boot', 'app_v1', 'resource'], }), undefined, ); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts index 4de9d27648e4..6f49716ad52b 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts @@ -64,6 +64,7 @@ import type { IHardwareBridgeReleasePayload, IOneKeyDeviceFeatures, IPro2FirmwareUpdateTarget, + IProtocolV2FirmwareVersionInfo, IResourceUpdateInfo, } from '@onekeyhq/shared/types/device'; import { @@ -146,26 +147,136 @@ 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 IPro2FirmwareUpdateTarget[] | undefined; + sdkTargets: readonly FirmwareUpdateV4Target[] | undefined; forceTargets?: readonly IPro2FirmwareUpdateTarget[]; }) { return Array.from( new Set([ - ...(sdkTargets ?? []), + ...(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, }: { @@ -423,59 +534,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({ @@ -650,20 +790,38 @@ class ServiceFirmwareUpdate extends ServiceBase { 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?.firmwareUpdatePlan - ? releaseInfoCache - : await this.loadBaseFirmwareRelease({ - connectId: originalConnectId, - firmwareType, - skipChangeTransportType: true, - checkFirmwareHash, - resolvedTransportType: currentTransportType, - forceUpdateTargets: forceUpdateTargetsForDevice, - }); + const releaseInfo = + 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 as unknown as @@ -695,12 +853,15 @@ class ServiceFirmwareUpdate extends ServiceBase { bridge.shouldUpdate = true; } } + const shouldCheckProtocolV2Bootloader = + isProtocolV2ProductType(deviceType) && Boolean(releaseInfo.bootloader); if ( !bridge?.shouldUpdate && releaseInfo.bootloader && - firmware?.toVersion && - (firmware.hasUpgrade || - forceUpdateTargetsForDevice.includes('bootloader')) + (shouldCheckProtocolV2Bootloader || + (firmware?.toVersion && + (firmware.hasUpgrade || + forceUpdateTargetsForDevice.includes('bootloader')))) ) { bootloader = await this.checkBootloaderRelease({ connectId: updatingConnectId, @@ -708,7 +869,10 @@ class ServiceFirmwareUpdate extends ServiceBase { firmwareUpdateInfo: firmware, bootloaderReleasePayload: releaseInfo.bootloader as unknown as IBootloaderReleasePayload, - forceUpdate: forceUpdateTargetsForDevice.includes('bootloader'), + forceUpdate: + forceUpdateTargetsForDevice.includes('bootloader') || + pro2ForceTargets?.includes('boot'), + currentVersion: releaseInfo.currentVersions?.bootloader, }); } @@ -718,7 +882,10 @@ class ServiceFirmwareUpdate extends ServiceBase { features, bleReleasePayload: releaseInfo.ble as unknown as IBleFirmwareReleasePayload, - forceUpdate: forceUpdateTargetsForDevice.includes('ble'), + forceUpdate: + forceUpdateTargetsForDevice.includes('ble') || + pro2ForceTargets?.includes('coprocessor'), + currentVersion: releaseInfo.currentVersions?.ble, }); } @@ -805,22 +972,18 @@ class ServiceFirmwareUpdate extends ServiceBase { } } - const pro2ForceTargets = isProtocolV2ProductType(deviceType) - ? [ - ...((await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'pro2ForceUpdateTargets', - )) ?? []), - ...((await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'pro2ForceUpdateOnceTargets', - )) ?? []), - ] - : undefined; const pro2TargetsToUpdate = isProtocolV2ProductType(deviceType) ? buildPro2TargetsToUpdate({ sdkTargets: releaseInfo.targetsToUpdate, forceTargets: pro2ForceTargets, }) : undefined; + const protocolV2FirmwareVersionInfo = pro2TargetsToUpdate + ? buildProtocolV2FirmwareVersionInfo({ + releaseInfo, + targetsToUpdate: pro2TargetsToUpdate, + }) + : undefined; let fixedUpdatingConnectId = updatingConnectId; try { @@ -840,21 +1003,29 @@ class ServiceFirmwareUpdate extends ServiceBase { 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: releaseInfo.firmwareUpdatePlan, + plan: executableFirmwareUpdatePlan, connectId: updatingConnectId, transportType: currentTransportType, - expectedTargets: [ - ...new Set([ - ...forceUpdateTargetsForDevice, - ...(firmware?.hasUpgrade ? (['firmware'] as const) : []), - ...(ble?.hasUpgrade ? (['ble'] as const) : []), - ...(bootloader?.hasUpgrade ? (['bootloader'] as const) : []), - ]), - ], + 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), }); const result = { @@ -879,6 +1050,13 @@ class ServiceFirmwareUpdate extends ServiceBase { }, 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. @@ -935,6 +1113,7 @@ class ServiceFirmwareUpdate extends ServiceBase { checkFirmwareHash, resolvedTransportType, forceUpdateTargets, + protocolV2ForceUpdateTargets, forceFirmwareManifestRefresh, }: { connectId: string | undefined; @@ -945,6 +1124,7 @@ class ServiceFirmwareUpdate extends ServiceBase { checkFirmwareHash?: boolean; resolvedTransportType?: EHardwareTransportType; forceUpdateTargets?: FirmwareUpdatePlanForceTarget[]; + protocolV2ForceUpdateTargets?: IPro2FirmwareUpdateTarget[]; forceFirmwareManifestRefresh?: boolean; }) { const hardwareSDK = await this.getSDKInstance({ @@ -969,6 +1149,7 @@ class ServiceFirmwareUpdate extends ServiceBase { retryCount, checkFirmwareHash, forceUpdateTargets, + protocolV2ForceUpdateTargets, }; const result = await convertDeviceResponse( () => @@ -1025,11 +1206,13 @@ class ServiceFirmwareUpdate extends ServiceBase { features, bleReleasePayload, forceUpdate, + currentVersion, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; bleReleasePayload: IBleFirmwareReleasePayload; forceUpdate?: boolean; + currentVersion?: string | null; }): Promise { const releasePayload: IBleFirmwareReleasePayload = { ...bleReleasePayload, @@ -1040,7 +1223,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, forceUpdate); + return this.setBleFirmwareUpdateInfo( + releasePayload, + forceUpdate, + currentVersion, + ); } // TODO only for classic and mini? @@ -1051,26 +1238,31 @@ class ServiceFirmwareUpdate extends ServiceBase { 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( @@ -1307,6 +1499,7 @@ class ServiceFirmwareUpdate extends ServiceBase { async setBleFirmwareUpdateInfo( payload: IBleFirmwareReleasePayload, forceUpdate?: boolean, + currentVersion?: string | null, ) { serviceHardwareUtils.hardwareLog('showBleFirmwareReleaseInfo', payload); if (!payload.features) { @@ -1315,11 +1508,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({ @@ -1720,7 +1914,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, @@ -1825,7 +2020,8 @@ 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.getActiveTransportType(); @@ -1974,174 +2170,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.getActiveTransportType(); - await this.backgroundApi.serviceHardware.setForceTransportType({ - forceTransportType: currentTransportType, - }); - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow: locked transport type', - currentTransportType, - ); + 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); - 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); - - 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'); + // 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, + }); + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow: locked transport type', + currentTransportType, + ); + + // 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); + + 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; + } + }; - let shouldRebootAfterUpdate = false; + // ** bootloader update + await this.cancelUpdateWorkflowIfExit(); + if ( + params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade + ) { + await waitRebootDelayForNextPhase(); + await this.startUpdateBootloaderTask( + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; - const waitRebootDelayForNextPhase = async () => { - if (shouldRebootAfterUpdate) { + // await hardware boot install and reboot + // move sdk await this.waitDeviceRestart({ - actionType: 'nextPhase', + actionType: 'boot-done', 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 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, + ); - // 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; + } - 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, + ); - await this.waitDeviceRestart({ - actionType: 'ble-done', - releaseResult: params.releaseResult, - }); - } + shouldRebootAfterUpdate = true; - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow DONE', - params, - ); + await this.waitDeviceRestart({ + actionType: 'ble-done', + releaseResult: params.releaseResult, + }); + } - 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( + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', params, ); - 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), + + 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, + ); + } + }, + ); + } 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', + ); + } }, - 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() @@ -2300,63 +2506,71 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.validateShouldUpdateBridge(params); 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'); - - await this.cancelUpdateWorkflowIfExit(); - - const deviceType = params?.releaseResult?.deviceType; - if ( - deviceType !== EDeviceType.Pro && - !isProtocolV2ProductType(deviceType) - ) { - throw new OneKeyLocalError( - 'Do not support update firmware for this device', - ); - } - const updateResult = - await this.startUpdateFirmwareTaskForNewBootVersion( - params, - firmwareArtifacts, + 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, ); - 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( + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', params, ); - await this.clearOnceUpdateDevSettings(); - appEventBus.emit( - EAppEventBusNames.FinishFirmwareUpdate, - undefined, - ); - } - // wait verify - await timerUtils.wait(2000); - }, - ); + + 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); + } } finally { if (shouldClearForceTransportType) { // Always clear transport type lock when firmware update completes @@ -2371,6 +2585,7 @@ class ServiceFirmwareUpdate extends ServiceBase { deviceParams: { dbDevice: {} as any, }, + allowDuringFirmwareUpdate: true, skipDeviceCancel: true, hideCheckingDeviceLoading: true, debugMethodName: 'startUpdateWorkflowV2', @@ -2693,9 +2908,9 @@ class ServiceFirmwareUpdate extends ServiceBase { releaseResult, ) : undefined; - if (!plan && isPro2Device) { + if (isPro2Device && !plan) { throw new OneKeyLocalError( - 'Do not support update firmware for this device', + 'Firmware update plan is required for Protocol V2 updates', ); } const executor = plan?.executor ?? 'v3'; @@ -2715,22 +2930,16 @@ class ServiceFirmwareUpdate extends ServiceBase { isPro2Device, pro2TargetsToUpdate: releaseResult.pro2TargetsToUpdate, }; - if (executor === 'v4') { - if (!plan) { - throw new OneKeyLocalError( - 'Firmware update plan is required for the V4 executor', - ); - } - const { getFirmwareUpdateV4Targets } = await loadFirmwareUpdateRuntime(); - const targetsToUpdate = getFirmwareUpdateV4Targets(plan.targetsToUpdate); + if (plan?.executor === 'v4') { + const targetsToUpdate = ( + await loadFirmwareUpdateRuntime() + ).getFirmwareUpdateV4Targets(plan.targetsToUpdate); return this.createRunTaskWithRetry({ fn: async () => this.updatingFirmwareV4( { ...updateParams, - requirePreparedArtifacts: Boolean( - releaseResult.firmwareUpdatePlanDigest, - ), + requirePreparedArtifacts: true, targetsToUpdate, }, firmwareArtifacts, @@ -2786,10 +2995,28 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId: params.connectId, currentTransportType, }); - const forceUpdateResEvenIfSameVersion = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 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, @@ -2798,7 +3025,7 @@ class ServiceFirmwareUpdate extends ServiceBase { platform: platformEnv.symbol ?? 'web', firmwareType: params.firmwareType, targetsToUpdate: params.targetsToUpdate, - forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, + forcedUpdateRes: forceUpdateResEvenIfSameVersion, }), ); 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 index 74d539fc84f4..f35aa1a41598 100644 --- a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/ServiceHardwarePortfolioSync.wait.test.ts @@ -177,6 +177,7 @@ describe('ServiceHardwarePortfolioSync settled event debounce', () => { aggregateTokenMap: {}, deviceConnectId: connectId, totalFiat, + totalFiatCurrency: 'usd', totalTokenCount: 0, tokenMap: {}, tokens: [], @@ -240,6 +241,7 @@ describe('ServiceHardwarePortfolioSync settled event debounce', () => { aggregateTokenMap: {}, deviceConnectId: 'PRO2_A', totalFiat: '2', + totalFiatCurrency: 'usd', totalTokenCount: 0, tokenMap: {}, tokens: [], @@ -350,6 +352,7 @@ describe('ServiceHardwarePortfolioSync.syncSettledPortfolio', () => { deviceDbId: 'db-device-1', indexedAccountId: 'indexed-account-1', totalFiat: '0.00007276', + totalFiatCurrency: 'usd', totalTokenCount: 1, tokenMap: { eth: { diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts index 1822a130d634..d32c91710d93 100644 --- a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.test.ts @@ -102,7 +102,8 @@ describe('serviceHardwarePortfolioSyncUtils', () => { networkId: 'all--networks', ownerAccountId: 'evm--1', ownerNetworkId: 'all--networks', - totalFiat: '2500.555', + totalFiat: '357.2222', + totalFiatCurrency: 'usd', totalTokenCount: 8, tokenMap: { eth: buildFiat({ fiatValue: '100', price: 100 }), @@ -260,6 +261,7 @@ describe('serviceHardwarePortfolioSyncUtils', () => { ownerAccountId: 'evm--1', ownerNetworkId: 'all--networks', totalFiat: '100', + totalFiatCurrency: 'usd', totalTokenCount: 1, tokenMap: { eth: buildFiat({ fiatValue: '100', price: 100 }), @@ -303,6 +305,7 @@ describe('serviceHardwarePortfolioSyncUtils', () => { ownerAccountId: 'evm--1', ownerNetworkId: 'all--networks', totalFiat: '100', + totalFiatCurrency: 'usd', totalTokenCount: 2, tokenMap: { advertising: buildFiat({ fiatValue: '50' }), diff --git a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts index 310df25002ed..1247d9b1e373 100644 --- a/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts +++ b/packages/kit-bg/src/services/ServiceHardware/serviceHardwarePortfolioSync/serviceHardwarePortfolioSyncUtils.ts @@ -104,6 +104,7 @@ export function buildPortfolioSyncArtifacts({ currencyMap, displayCurrency, totalFiat: eventPayload.totalFiat, + totalFiatCurrency: eventPayload.totalFiatCurrency, totalTokenCount: eventPayload.totalTokenCount, timestamp, tokenMap: eventPayload.tokenMap, diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts index a163ad2955fb..c7ffc23e2bab 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.test.ts @@ -84,4 +84,27 @@ describe('HardwareProcessingManager OneKey operation lease', () => { '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 a06251265c66..e716306153bd 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/HardwareProcessingManager.ts @@ -1,4 +1,4 @@ -import { Semaphore } from 'async-mutex'; +import { E_ALREADY_LOCKED, Semaphore, tryAcquire } from 'async-mutex'; import { UserCancelFromOutside } from '@onekeyhq/shared/src/errors'; @@ -7,6 +7,15 @@ export type IOneKeyHardwareOperationLease = { readonly owner: symbol; }; +export type ITryRunExclusiveOneKeyOperationResult = + | { + acquired: true; + result: T; + } + | { + acquired: false; + }; + export class HardwareProcessingManager { private cancelCallbacks: Map void> = new Map(); @@ -14,6 +23,27 @@ export class HardwareProcessingManager { 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, @@ -27,20 +57,51 @@ export class HardwareProcessingManager { return operation(lease); } - return this.oneKeyOperationSemaphore.runExclusive(async () => { - const acquiredLease: IOneKeyHardwareOperationLease = Object.freeze({ + return this.oneKeyOperationSemaphore.runExclusive(() => + this.runWithNewOneKeyOperationLease({ deviceKey, - owner: Symbol('onekey-hardware-operation'), - }); - this.activeOneKeyOperationLease = acquiredLease; - try { - return await operation(acquiredLease); - } finally { - if (this.activeOneKeyOperationLease === acquiredLease) { - this.activeOneKeyOperationLease = undefined; - } + 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) { diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts index d0c13906b3c7..e136f633c134 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.sendUiResponse.test.ts @@ -1,3 +1,7 @@ +import { EHardwareVendor } from '@onekeyhq/shared/types/device'; + +import { firmwareUpdateWorkflowRunningAtom } from '../../states/jotai/atoms'; + import ServiceHardwareUI from './ServiceHardwareUI'; import type { UiResponseEvent } from '@onekeyfe/hd-core'; @@ -27,6 +31,32 @@ jest.mock('@onekeyhq/shared/src/eventBus/appEventBus', () => ({ }, })); +jest.mock('@onekeyhq/shared/src/locale/appLocale', () => ({ + appLocale: { + intl: { + formatMessage: jest.fn(() => 'Hardware is busy'), + }, + onLocaleChange: jest.fn(), + }, +})); + +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: { @@ -68,3 +98,184 @@ describe('ServiceHardwareUI.sendUiResponse', () => { 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); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts index 40b19b793ba5..1eb49d5faf03 100644 --- a/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts +++ b/packages/kit-bg/src/services/ServiceHardwareUI/ServiceHardwareUI.ts @@ -54,6 +54,7 @@ import type { 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 @@ -83,6 +84,8 @@ export type ICloseHardwareUiStateDialogParams = { 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. @@ -517,19 +520,46 @@ class ServiceHardwareUI extends ServiceBase { const isThirdPartyVendor = getVendorProfile( device?.vendor ?? EHardwareVendor.onekey, ).isThirdParty; + 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. - return this.runExclusiveOneKeyOperation( - (lease) => this.withHardwareProcessingInternal(() => fn(lease), params), - { - deviceKey: - device?.id || device?.deviceId || device?.uuid || device?.connectId, - lease: params.oneKeyOperationLease, - }, - ); + try { + return await this.runExclusiveOneKeyOperation( + (lease) => this.withHardwareProcessingInternal(() => fn(lease), params), + { + deviceKey: + device?.id || device?.deviceId || device?.uuid || device?.connectId, + lease: params.oneKeyOperationLease, + }, + ); + } finally { + if (tracksFirmwareUpdateExclusivity) { + this.firmwareUpdateExclusiveDepth = Math.max( + this.firmwareUpdateExclusiveDepth - 1, + 0, + ); + } + } } runExclusiveOneKeyOperation( @@ -549,6 +579,26 @@ class ServiceHardwareUI extends ServiceBase { }); } + 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, diff --git a/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts b/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts index ab38d4911c73..dd237a2df97f 100644 --- a/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts +++ b/packages/kit-bg/src/services/ServiceNFT.pro2.test.ts @@ -91,6 +91,62 @@ describe('ServiceNFT Pro2 upload routing', () => { expect(uploadPro2Nft).not.toHaveBeenCalled(); }); + it('treats an existing Pro2 NFT as an idempotent success', 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: { + imageHex: 'full-image', + thumbnailHex: 'thumbnail-image', + title: 'NFT #1', + subtitle: 'Collection', + }, + }), + ).resolves.toEqual({ + nftUpdated: true, + message: 'NFT already exists', + }); + }); + + 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: { + imageHex: 'full-image', + thumbnailHex: '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, diff --git a/packages/kit-bg/src/services/ServiceNFT.ts b/packages/kit-bg/src/services/ServiceNFT.ts index 7181c1c334d0..961865c8b95d 100644 --- a/packages/kit-bg/src/services/ServiceNFT.ts +++ b/packages/kit-bg/src/services/ServiceNFT.ts @@ -36,6 +36,32 @@ export type IPro2NftUploadParams = { timestampMs?: number; }; +const PRO2_NFT_ALREADY_EXISTS_MESSAGE = 'NFT already exists'; + +function isPro2NftAlreadyExistsError(error: unknown) { + if (!error || typeof error !== 'object') { + return false; + } + + const hardwareError = error as { + message?: unknown; + payload?: { + error?: unknown; + message?: unknown; + }; + }; + + return [ + hardwareError.message, + hardwareError.payload?.error, + hardwareError.payload?.message, + ].some( + (message) => + typeof message === 'string' && + message.includes(PRO2_NFT_ALREADY_EXISTS_MESSAGE), + ); +} + @backgroundClass() class ServiceNFT extends ServiceBase { constructor({ backgroundApi }: { backgroundApi: any }) { @@ -67,10 +93,21 @@ class ServiceNFT extends ServiceBase { 'Pro2 NFT upload parameters are required', ); } - return this.backgroundApi.serviceHardware.uploadPro2Nft({ - connectId: device.connectId ?? '', - ...pro2UploadParams, - }); + try { + return await this.backgroundApi.serviceHardware.uploadPro2Nft({ + connectId: device.connectId ?? '', + ...pro2UploadParams, + }); + } catch (error) { + // Treat duplicate uploads as idempotent because the target NFT already exists. + if (isPro2NftAlreadyExistsError(error)) { + return { + nftUpdated: true, + message: PRO2_NFT_ALREADY_EXISTS_MESSAGE, + }; + } + throw error; + } } if (!uploadResParams) { throw new OneKeyLocalError( 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 1ef0f1126c39..afcd61b1d2bd 100644 --- a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts +++ b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts @@ -229,6 +229,19 @@ export const { }, }); +// 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/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts index d7ca8709b863..71e90f20dcc1 100644 --- a/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.test.ts @@ -1,4 +1,4 @@ -import { EDeviceType } from '@onekeyfe/hd-shared'; +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; import { emptyMetaState } from './atoms'; import { @@ -13,14 +13,154 @@ import { } 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 }, - device: { id: 'old-device-1' }, + 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.each(firmwareTypeSwitchDeviceTypes)( + 'keeps a deprecated %s wallet manageable 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)).toBe( + walletWithDevice, + ); + }, + ); + + 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('keeps a Bitcoin-only wallet manageable 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)).toBe( + walletWithDevice, + ); + }); + + it('uses normalized firmwareType for legacy Protocol V1 device records', () => { + 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)).toBe( + walletWithDevice, + ); + }); + + it('treats legacy wallets without firmwareTypeAtCreated as universal', () => { + 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)).toBe( + walletWithDevice, + ); + }); }); describe('device details navigation state', () => { diff --git a/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts index d410ac12dab0..6f69b2b7480d 100644 --- a/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts +++ b/packages/kit/src/states/jotai/contexts/deviceDetails/deviceStateManagement.ts @@ -1,3 +1,5 @@ +import { EFirmwareType } from '@onekeyfe/hd-shared'; + import { hasDeviceStateIdentityMismatch, mergeDeviceStateEvent, @@ -114,15 +116,45 @@ export function resolveDeviceState({ export function resolveUsableWalletWithDevice( walletWithDevice?: IHwQrWalletWithDevice, ) { - if ( - walletWithDevice?.wallet?.deprecated || - walletWithDevice?.wallet?.isMocked - ) { + if (!isDeviceManagementWalletUsable(walletWithDevice)) { return undefined; } return walletWithDevice; } +export function isDeviceManagementWalletUsable( + walletWithDevice?: IHwQrWalletWithDevice, +) { + const wallet = walletWithDevice?.wallet; + const device = walletWithDevice?.device; + if (!wallet || wallet.isMocked) { + return false; + } + if (!wallet.deprecated) { + return true; + } + + const deviceType = + device?.deviceStateInfo?.identity.deviceType ?? device?.deviceType; + if (!deviceType || !deviceUtils.checkAllowChangeFirmwareType(deviceType)) { + return false; + } + + const currentFirmwareType = + device?.deviceStateInfo?.identity.firmwareType ?? + device?.featuresInfo?.$app_firmware_type ?? + device?.featuresInfo?.firmwareType; + if (!currentFirmwareType) { + return false; + } + + // Legacy wallets were created on Universal firmware. Switching firmware + // invalidates accounts, but the device must remain manageable to switch back. + const firmwareTypeAtCreated = + wallet.firmwareTypeAtCreated ?? EFirmwareType.Universal; + return currentFirmwareType !== firmwareTypeAtCreated; +} + export function resolveDeviceWithCurrentType< T extends { deviceType?: EDeviceType }, >(device: T, currentDeviceType?: EDeviceType): T { 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 2498aaac415c..a7e23e82702b 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.test.ts @@ -6,11 +6,11 @@ import { buildDeviceDetailsVisibility, canOpenDeviceManagementDetails, canShowTrezorBleBinding, + getFirmwareTypeChangeAvailability, getTrezorAutoLockOptionsMs, shouldShowDeviceInteractiveSections, syncRelevantDeviceStateEvent, } from './utils'; -import * as deviceDetailsUtils from './utils'; describe('DeviceDetailsModal utils', () => { it('allows Ledger rows to open device details', () => { @@ -64,16 +64,27 @@ describe('DeviceDetailsModal utils', () => { }); }); - it('shows the Pro2 firmware type switch as coming soon', () => { - const getAvailability = ( - deviceDetailsUtils as typeof deviceDetailsUtils & { - getFirmwareTypeChangeAvailability?: (deviceType: EDeviceType) => string; - } - ).getFirmwareTypeChangeAvailability; - - expect(getAvailability?.(EDeviceType.Pro)).toBe('enabled'); - expect(getAvailability?.(EDeviceType.Pro2)).toBe('comingSoon'); - expect(getAvailability?.(EDeviceType.Touch)).toBe('hidden'); + it.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', () => { diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts index fc33d3a447e6..dcaab3e97cbd 100644 --- a/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts +++ b/packages/kit/src/views/DeviceManagement/pages/DeviceDetailsModal/utils.ts @@ -83,7 +83,7 @@ export function getFirmwareTypeChangeAvailability( deviceType: EDeviceType | undefined, ): IFirmwareTypeChangeAvailability { if (isProtocolV2ProductType(deviceType)) { - return 'comingSoon'; + return 'hidden'; } if (deviceType && deviceUtils.checkAllowChangeFirmwareType(deviceType)) { return 'enabled'; diff --git a/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx b/packages/kit/src/views/DeviceManagement/pages/DeviceManagementListModal/index.tsx index a98484f45450..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 { @@ -310,7 +311,7 @@ function DeviceManagementV2ListWeb() { const devices: Array = 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/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 d9fef0f73095..4f87ab8294db 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'; @@ -40,19 +40,24 @@ import type { import { useFirmwareUpdateActions } from '../hooks/useFirmwareUpdateActions'; import { useFirmwareVersionValid } from '../hooks/useFirmwareVersionValid'; import { FirmwareUpdateTestIDs } from '../testIDs'; -import { isPro2SafeOSFirmwareUpdate } from '../utils'; +import { + 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 = '', + toVersion, active, }: { - fromVersion?: string; - toVersion?: string; + fromVersion?: string | null; + toVersion?: string | null; active: boolean; }) { const { versionValid, unknownMessage } = useFirmwareVersionValid(); @@ -63,10 +68,12 @@ function FirmwareVersionOnlyProgressText({ flexShrink: 1, numberOfLines: 1, } as const; - const fromVersionText = versionValid(fromVersion) + const fromVersionText = versionValid(fromVersion ?? '') ? fromVersion : unknownMessage; - const toVersionText = toVersion?.length > 0 ? toVersion : unknownMessage; + const toVersionText = versionValid(toVersion ?? '') + ? toVersion + : unknownMessage; return ( {fromVersionText} - - - {toVersionText} - + {toVersion ? ( + <> + + + {toVersionText} + + + ) : null} ); } @@ -116,10 +132,14 @@ function ChangeLogSection({ updateInfo, accordionValue, versionOnly, + fromVersion, + toVersion, }: { title: string; accordionValue: string; versionOnly?: boolean; + fromVersion?: string | null; + toVersion?: string | null; updateInfo: | IFirmwareUpdateInfo | IBleFirmwareUpdateInfo @@ -171,8 +191,8 @@ function ChangeLogSection({ {versionOnly ? ( ) : ( @@ -219,6 +239,48 @@ function ChangeLogSection({ ); } +function ProtocolV2VersionSection({ + item, +}: { + item: IProtocolV2FirmwareVersionDisplayItem; +}) { + const intl = useIntl(); + return ( + + + {getProtocolV2FirmwareVersionTitle({ target: item.target, intl })} + + {item.releaseIdentifierOnly ? ( + + {item.targetVersion ?? + intl.formatMessage({ + id: ETranslations.hardware_status_update_available, + })} + + ) : ( + + )} + + ); +} + export function FirmwareChangeLogContentView({ result, ...rest @@ -226,15 +288,50 @@ export function FirmwareChangeLogContentView({ result: ICheckAllFirmwareReleaseResult | undefined; } & IStackProps) { const intl = useIntl(); - const isPro2SafeOSUpdate = isPro2SafeOSFirmwareUpdate(result); - const defaultExpandedSections = useMemo(() => { - if (result?.updateInfos?.firmware?.hasUpgrade || isPro2SafeOSUpdate) { + const protocolV2VersionItems = + getProtocolV2FirmwareVersionDisplayItems(result); + const [safeOSItem, ...componentItems] = protocolV2VersionItems; + if (safeOSItem) { + const safeOSHasUpdate = Boolean(safeOSItem.targetVersion); + return ( + + {safeOSHasUpdate ? ( + + + + ) : ( + + )} + {componentItems.map((item) => ( + + ))} + + ); + } + + const defaultExpandedSections = (() => { + if (result?.updateInfos?.firmware?.hasUpgrade) { return 'firmware'; } - if (result?.updateInfos?.bootloader?.hasUpgrade) return 'bootloader'; + if (result?.updateInfos?.bootloader?.hasUpgrade) { + return 'bootloader'; + } if (result?.updateInfos?.ble?.hasUpgrade) return 'ble'; return undefined; - }, [isPro2SafeOSUpdate, result?.updateInfos]); + })(); return ( @@ -245,26 +342,21 @@ export function FirmwareChangeLogContentView({ defaultValue={defaultExpandedSections} collapsible > - {result?.updateInfos?.firmware?.hasUpgrade || isPro2SafeOSUpdate ? ( + {result?.updateInfos?.firmware?.hasUpgrade ? ( ) : null} - {result?.updateInfos?.bootloader?.hasUpgrade && !isPro2SafeOSUpdate ? ( + {result?.updateInfos?.bootloader?.hasUpgrade ? ( ) : null} - {result?.updateInfos?.ble?.hasUpgrade && !isPro2SafeOSUpdate ? ( + {result?.updateInfos?.ble?.hasUpgrade ? ( { - 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/FirmwareUpdateProgressBarV2.tsx b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx index cbe672b533b1..70285619c495 100644 --- a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx @@ -41,7 +41,11 @@ import { EHardwareUiStateAction } from '@onekeyhq/shared/types/hardwareUi'; import { FirmwareUpdatePromptWebUsbDevice } from '../components/FirmwareUpdatePromptWebUsbDevice'; import { useFirmwareVersionValid } from '../hooks/useFirmwareVersionValid'; -import { isPro2SafeOSFirmwareUpdate } from '../utils'; +import { + getProtocolV2FirmwareVersionDisplayItems, + getProtocolV2FirmwareVersionTitle, + isPro2SafeOSFirmwareUpdate, +} from '../utils'; import { PRO2_INSTALL_ESTIMATED_PROGRESS_MAX, @@ -58,6 +62,8 @@ interface IFirmwareUpdateVersionInfo { hasUpgrade: boolean; title: string; githubReleaseUrl?: string; + releaseIdentifierOnly?: boolean; + currentVersionOnly?: boolean; } interface IFirmwareUpdateVersions { @@ -89,6 +95,8 @@ function FirmwareUpdateVersionItem({ githubReleaseUrl, isDone, isVerified, + releaseIdentifierOnly, + currentVersionOnly, }: { title: string; fromVersion: string; @@ -97,10 +105,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} @@ -200,6 +250,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} @@ -504,17 +556,47 @@ export function FirmwareUpdateProgressBarV2({ const upgradeVersions = useMemo(() => { if (!result?.updateInfos) return []; + const protocolV2VersionItems = + getProtocolV2FirmwareVersionDisplayItems(result); + 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 isPro2SafeOSUpdate = isPro2SafeOSFirmwareUpdate(result); const firmwareInfo = result.updateInfos.firmware; + const bootloaderInfo = result.updateInfos.bootloader; + const bleInfo = result.updateInfos.ble; - if (firmwareInfo?.hasUpgrade || isPro2SafeOSUpdate) { + if (firmwareInfo?.hasUpgrade) { versions.push({ - type: isPro2SafeOSUpdate ? 'SafeOS' : 'Firmware', + type: 'Firmware', info: { - title: isPro2SafeOSUpdate - ? 'SafeOS' - : intl.formatMessage({ id: ETranslations.global_firmware }), + title: intl.formatMessage({ id: ETranslations.global_firmware }), fromVersion: firmwareInfo?.fromVersion ?? '', toVersion: firmwareInfo?.toVersion ?? '', verifyVersion: resultVerifyVersions?.finalFirmwareVersion, @@ -524,30 +606,30 @@ export function FirmwareUpdateProgressBarV2({ }); } - if (result.updateInfos.bootloader?.hasUpgrade && !isPro2SafeOSUpdate) { + 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 && !isPro2SafeOSUpdate) { + 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, }, }); } diff --git a/packages/kit/src/views/FirmwareUpdate/utils.test.ts b/packages/kit/src/views/FirmwareUpdate/utils.test.ts index a978f95e3e37..0b79b1f8581e 100644 --- a/packages/kit/src/views/FirmwareUpdate/utils.test.ts +++ b/packages/kit/src/views/FirmwareUpdate/utils.test.ts @@ -1,8 +1,15 @@ import { EDeviceType } from '@onekeyfe/hd-shared'; +import { NEO_DEVICE_TYPE } from '@onekeyhq/shared/src/utils/hardwareDeviceTypes'; import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; -import { isPro2SafeOSFirmwareUpdate } from './utils'; +import { + getFirmwareUpdateDeviceTitle, + getProtocolV2FirmwareVersionDisplayItems, + getProtocolV2ResourceReleaseId, + hasProtocolV2FirmwareUpdateTarget, + isPro2SafeOSFirmwareUpdate, +} from './utils'; function buildResult({ deviceType = EDeviceType.Pro2, @@ -60,3 +67,156 @@ describe('isPro2SafeOSFirmwareUpdate', () => { ).toBe(false); }); }); + +describe('getFirmwareUpdateDeviceTitle', () => { + it.each([ + [EDeviceType.Pro2, 'OneKey Pro 2'], + [NEO_DEVICE_TYPE, 'OneKey Neo'], + ])('协议 V2 设备 %s 使用稳定型号作为标题', (deviceType, expected) => { + expect( + getFirmwareUpdateDeviceTitle({ + deviceType, + deviceName: '用户自定义名称', + } as ICheckAllFirmwareReleaseResult), + ).toBe(expected); + }); + + it('旧设备继续沿用原有设备名称', () => { + expect( + getFirmwareUpdateDeviceTitle({ + deviceType: EDeviceType.Touch, + deviceName: '用户自定义名称', + } as ICheckAllFirmwareReleaseResult), + ).toBe('用户自定义名称'); + }); +}); + +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)).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).map( + (item) => item.target, + ), + ).toEqual(['safeos', 'app_v2']); + }); + + 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 e8afcb47380d..bd8f4b5ea22d 100644 --- a/packages/kit/src/views/FirmwareUpdate/utils.ts +++ b/packages/kit/src/views/FirmwareUpdate/utils.ts @@ -1,11 +1,27 @@ 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 } from '@onekeyhq/shared/types/device'; +import type { + ICheckAllFirmwareReleaseResult, + IPro2FirmwareUpdateTarget, +} from '@onekeyhq/shared/types/device'; import type { IntlShape } from 'react-intl'; +export function getFirmwareUpdateDeviceTitle( + result: ICheckAllFirmwareReleaseResult, +) { + if (result.deviceType && isProtocolV2ProductType(result.deviceType)) { + return ( + deviceUtils.getDefaultDeviceLabel(result.deviceType) || result.deviceName + ); + } + + return result.deviceName; +} + export function isPro2SafeOSFirmwareUpdate( result: ICheckAllFirmwareReleaseResult | undefined, ) { @@ -20,6 +36,94 @@ export function isPro2SafeOSFirmwareUpdate( ); } +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, +): 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, + }, + ]; + + 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/TokenListBlock/TokenListBlock.portfolioSync.test.ts b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts index 86dece158931..156cac7e1bd7 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.portfolioSync.test.ts @@ -22,6 +22,8 @@ describe('TokenListBlock portfolio sync producer', () => { expect(source).toContain( 'accountUtils.isHwWallet({ walletId: wallet.id })', ); + expect(source).toContain('assetStatusCurrency &&'); + expect(source).toContain('totalFiatCurrency: assetStatusCurrency'); expect(gateIndex).toBeGreaterThan(0); expect(gateIndex).toBeLessThan(buildIndex); expect(buildIndex).toBeLessThan(sendToBackgroundIndex); diff --git a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx index e30ec03930cd..cfab6570453f 100644 --- a/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx +++ b/packages/kit/src/views/Home/components/TokenListBlock/TokenListBlock.tsx @@ -1831,6 +1831,7 @@ function TokenListBlock({ }); if ( + assetStatusCurrency && isProtocolV2ProductType(device?.deviceType) && wallet && accountUtils.isHwWallet({ walletId: wallet.id }) @@ -1867,6 +1868,7 @@ function TokenListBlock({ ownerAccountId: allNetworksResult[0].ownerAccountId, ownerNetworkId: allNetworksResult[0].ownerNetworkId, totalFiat: snapshot.createAtNetworkWorth, + totalFiatCurrency: assetStatusCurrency, totalTokenCount: portfolioTokens.length, tokenMap: { ...snapshot.mergeTokenListMap, diff --git a/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx index e0ac69b6afb0..197cc95650de 100644 --- a/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx +++ b/packages/kit/src/views/Setting/pages/FirmwareUpdateDevSettings/PageFirmwareUpdatePro2DevSettings.tsx @@ -19,7 +19,10 @@ import { PRO2_FIRMWARE_UPDATE_TARGETS } from '@onekeyhq/shared/types/device'; import { FirmwareUpdateActions } from '../Tab/DevSettingsSection/FirmwareUpdateActions'; const PRO2_FIRMWARE_UPDATE_TARGET_OPTIONS = PRO2_FIRMWARE_UPDATE_TARGETS.map( - (value) => ({ value, label: value }), + (value) => ({ + value, + label: value, + }), ); const EMPTY_PRO2_FIRMWARE_UPDATE_TARGETS: IPro2FirmwareUpdateTarget[] = []; @@ -60,7 +63,10 @@ function Pro2FirmwareUpdateTargetRow({ const nextTargets = enabled ? Array.from(new Set([...targets, target.value])) : targets.filter((item) => item !== target.value); - await updateTargets({ nextTargets, nextOnceTargets: onceTargets }); + const nextOnceTargets = enabled + ? onceTargets.filter((item) => item !== target.value) + : onceTargets; + await updateTargets({ nextTargets, nextOnceTargets }); }, [onceTargets, target.value, targets, updateTargets], ); @@ -70,7 +76,10 @@ function Pro2FirmwareUpdateTargetRow({ const nextOnceTargets = enabled ? Array.from(new Set([...onceTargets, target.value])) : onceTargets.filter((item) => item !== target.value); - await updateTargets({ nextTargets: targets, nextOnceTargets }); + const nextTargets = enabled + ? targets.filter((item) => item !== target.value) + : targets; + await updateTargets({ nextTargets, nextOnceTargets }); }, [onceTargets, target.value, targets, updateTargets], ); @@ -126,7 +135,7 @@ function FirmwareUpdatePro2DevSettings() { {PRO2_FIRMWARE_UPDATE_TARGET_OPTIONS.map((target) => ( diff --git a/packages/shared/src/eventBus/appEventBus.ts b/packages/shared/src/eventBus/appEventBus.ts index bc03bb6e6463..aa4879ac4f4f 100644 --- a/packages/shared/src/eventBus/appEventBus.ts +++ b/packages/shared/src/eventBus/appEventBus.ts @@ -359,6 +359,7 @@ export interface IAppEventBusPayload { ownerAccountId?: string; ownerNetworkId?: string; totalFiat: string; + totalFiatCurrency: string; totalTokenCount: number; tokenMap: Record; tokens: IAccountToken[]; diff --git a/packages/shared/src/utils/avatarUtils.test.ts b/packages/shared/src/utils/avatarUtils.test.ts new file mode 100644 index 000000000000..cabd504e854a --- /dev/null +++ b/packages/shared/src/utils/avatarUtils.test.ts @@ -0,0 +1,15 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + +import { HwWalletAvatarImages } from './avatarUtils'; +import { NEO_DEVICE_TYPE } from './hardwareDeviceTypes'; + +describe('HwWalletAvatarImages', () => { + it.each([EDeviceType.Pro2, NEO_DEVICE_TYPE])( + 'uses the OneKey Pro artwork for %s', + (deviceType) => { + expect(HwWalletAvatarImages[deviceType]).toBe( + HwWalletAvatarImages[EDeviceType.Pro], + ); + }, + ); +}); diff --git a/packages/shared/src/utils/avatarUtils.ts b/packages/shared/src/utils/avatarUtils.ts index 1b90332f025d..0a9a0afbb584 100644 --- a/packages/shared/src/utils/avatarUtils.ts +++ b/packages/shared/src/utils/avatarUtils.ts @@ -38,7 +38,7 @@ export const HwWalletAvatarImages: Record< [EDeviceType.Mini]: require('../assets/wallet/avatar/Mini.png'), [EDeviceType.Touch]: require('../assets/wallet/avatar/Touch.png'), [EDeviceType.Pro]: require('../assets/wallet/avatar/ProBlack.png'), - [EDeviceType.Pro2]: require('../assets/wallet/avatar/Pro2Black.png'), + [EDeviceType.Pro2]: require('../assets/wallet/avatar/ProBlack.png'), [NEO_DEVICE_TYPE]: require('../assets/wallet/avatar/ProBlack.png'), [`${EDeviceType.Pro}Black`]: require('../assets/wallet/avatar/ProBlack.png'), [`${EDeviceType.Pro}White`]: require('../assets/wallet/avatar/ProWhite.png'), diff --git a/packages/shared/src/utils/portfolioPayload.test.ts b/packages/shared/src/utils/portfolioPayload.test.ts index 337a90f85e7b..2de4d83ff3df 100644 --- a/packages/shared/src/utils/portfolioPayload.test.ts +++ b/packages/shared/src/utils/portfolioPayload.test.ts @@ -4,10 +4,11 @@ yarn test packages/shared/src/utils/portfolioPayload.test.ts import { appLocale } from '../locale/appLocale'; import { - buildPortfolioPayload, buildPortfolioPayloadHash, + buildPortfolioPayload as buildPortfolioPayloadImpl, } from './portfolioPayload'; +import type { IBuildPortfolioPayloadParams } from './portfolioPayload'; import type { ICurrencyItem } from '../../types/currency'; import type { IAccountToken, ITokenFiat } from '../../types/token'; @@ -47,6 +48,17 @@ function buildFiat(params: Partial): ITokenFiat { }; } +function buildPortfolioPayload( + params: Omit & { + totalFiatCurrency?: string; + }, +) { + return buildPortfolioPayloadImpl({ + ...params, + totalFiatCurrency: params.totalFiatCurrency ?? params.displayCurrency.id, + }); +} + describe('buildPortfolioPayload', () => { afterEach(() => { appLocale.setLocale(defaultLocale, defaultMessages); @@ -297,7 +309,7 @@ describe('buildPortfolioPayload', () => { } }); - test('uses the UI token order and converts fiat values to the display currency', () => { + test('uses the UI token order and converts all fiat values to the display currency', () => { const lowValueFirst = buildToken({ $key: 'low', symbol: 'LOW', @@ -319,7 +331,8 @@ describe('buildPortfolioPayload', () => { aggregateTokenMap: {}, currencyMap, displayCurrency: { id: 'cny', symbol: '¥' }, - totalFiat: '707.004', + totalFiat: '101', + totalFiatCurrency: 'usd', totalTokenCount: 2, timestamp: 1_780_900_000, tokenMap: { @@ -377,11 +390,47 @@ describe('buildPortfolioPayload', () => { expect(payload.totalFiat).toBe('¥707.00'); expect(payload.otherTokens).toEqual({ count: 0, - fiat: '< ¥0.01', + fiat: '¥0.00', portfolioPercentage: 0, }); }); + test('rejects a total whose source currency cannot be converted', () => { + expect(() => + buildPortfolioPayload({ + account: { label: 'Account #1', addressMasked: '0x12...ab' }, + aggregateTokenMap: {}, + currencyMap, + displayCurrency: { id: 'eur', symbol: '€' }, + totalFiat: '100', + totalFiatCurrency: 'usd', + totalTokenCount: 0, + timestamp: 1_780_900_000, + tokenMap: {}, + tokens: [], + }), + ).toThrow( + /Unable to convert Portfolio total from usd to eur: missing-rate/u, + ); + }); + + test('requires the total source currency at the payload boundary', () => { + expect(() => + buildPortfolioPayloadImpl({ + account: { label: 'Account #1', addressMasked: '0x12...ab' }, + aggregateTokenMap: {}, + currencyMap, + displayCurrency: { id: 'usd', symbol: '$' }, + totalFiat: '100', + totalFiatCurrency: '', + totalTokenCount: 0, + timestamp: 1_780_900_000, + tokenMap: {}, + tokens: [], + }), + ).toThrow('Portfolio total source currency is required'); + }); + test('only marks iconName for allowlisted native and contract tokens', () => { const ethNative = buildToken({ $key: 'eth', diff --git a/packages/shared/src/utils/portfolioPayload.ts b/packages/shared/src/utils/portfolioPayload.ts index 2639c8c8a7a7..8014a8b1d92d 100644 --- a/packages/shared/src/utils/portfolioPayload.ts +++ b/packages/shared/src/utils/portfolioPayload.ts @@ -61,6 +61,7 @@ export type IBuildPortfolioPayloadParams = { currencyMap: Record; displayCurrency: IPortfolioDisplayCurrency; totalFiat: string; + totalFiatCurrency: string; totalTokenCount: number; timestamp: number; tokenMap: Record; @@ -390,6 +391,7 @@ export function buildPortfolioPayload({ currencyMap, displayCurrency, totalFiat: rawTotalFiat, + totalFiatCurrency, totalTokenCount, timestamp, tokenMap, @@ -449,7 +451,23 @@ export function buildPortfolioPayload({ }; }); - const totalFiatValue = toPortfolioAllocationValue(rawTotalFiat); + if (!totalFiatCurrency) { + throw new OneKeyLocalError('Portfolio total source currency is required'); + } + const convertedTotalFiat = convertFiatStrictToDisplayCurrency({ + currencyMap, + sourceCurrency: totalFiatCurrency, + targetCurrency: displayCurrency.id, + value: rawTotalFiat, + }); + if (convertedTotalFiat.value === null) { + throw new OneKeyLocalError( + `Unable to convert Portfolio total from ${totalFiatCurrency} to ${displayCurrency.id}: ${ + convertedTotalFiat.reason ?? 'unknown' + }`, + ); + } + const totalFiatValue = toPortfolioAllocationValue(convertedTotalFiat.value); const totalFiat = formatPortfolioTotalFiat(totalFiatValue, currencyPrefix); const topTokensFiatValue = tokenBuildResults.reduce( (total, result) => total.plus(result.allocationValue), diff --git a/packages/shared/types/device.pro2.test.ts b/packages/shared/types/device.pro2.test.ts index 05ee3ec9db12..b59525b72aa1 100644 --- a/packages/shared/types/device.pro2.test.ts +++ b/packages/shared/types/device.pro2.test.ts @@ -8,7 +8,6 @@ describe('Pro 2 firmware targets', () => { 'app_v2', 'coprocessor', 'resource', - 'boot_resources', 'se01', 'se02', 'se03', diff --git a/packages/shared/types/device.ts b/packages/shared/types/device.ts index 5e91683df8c3..34485a16a987 100644 --- a/packages/shared/types/device.ts +++ b/packages/shared/types/device.ts @@ -118,6 +118,25 @@ export type IBleFirmwareUpdateInfo = export type IBootloaderUpdateInfo = IFirmwareUpdateInfoBase; +export type IProtocolV2ResourceArchive = { + archiveSha256: string; + archiveSize: number; +}; + +export type IProtocolV2FirmwareComponentVersion = { + target: Exclude; + currentVersion: string | null; + targetVersion: string | null; +}; + +export type IProtocolV2FirmwareVersionInfo = { + safeOS: { + currentVersion: string | null; + targetVersion: string | null; + }; + components: IProtocolV2FirmwareComponentVersion[]; +}; + export type ICheckAllFirmwareReleaseResult = { hasUpgrade: boolean | undefined; features: IOneKeyDeviceFeatures | undefined; @@ -137,6 +156,8 @@ export type ICheckAllFirmwareReleaseResult = { }; totalPhase: IDeviceFirmwareType[]; pro2TargetsToUpdate?: IPro2FirmwareUpdateTarget[]; + pro2ResourceArchive?: IProtocolV2ResourceArchive; + protocolV2FirmwareVersionInfo?: IProtocolV2FirmwareVersionInfo; }; export const PRO2_FIRMWARE_UPDATE_TARGETS = [ @@ -145,7 +166,6 @@ export const PRO2_FIRMWARE_UPDATE_TARGETS = [ 'app_v2', 'coprocessor', 'resource', - 'boot_resources', 'se01', 'se02', 'se03', diff --git a/yarn.lock b/yarn.lock index 4e96076177f5..7954a7fb214d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8612,9 +8612,9 @@ __metadata: resolution: "@onekeyfe/cli@workspace:apps/cli" dependencies: "@napi-rs/keyring": "npm:^1.3.0" - "@onekeyfe/hd-common-connect-sdk": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-common-connect-sdk": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.99" "@onekeyhq/core": "workspace:*" "@onekeyhq/shared": "workspace:*" "@types/proper-lockfile": "npm:^4" @@ -8808,38 +8808,38 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/hd-ble-sdk@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-ble-sdk@npm:1.2.0-alpha.77" +"@onekeyfe/hd-ble-sdk@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-ble-sdk@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-react-native": "npm:1.2.0-alpha.77" - checksum: 10/1a2bb544d5730e9beae8dbcf92f5333ebade2a1b1e3204b1be071a31b41e211554ec54714ecdbdc8acc9b79b349f45eb3e493f574d9d2c819a1814352fd7cde2 + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-react-native": "npm:1.2.0-alpha.99" + checksum: 10/0699975822cfbb8a3035f8ba27bd81aebebb33e00d76ad28de5af6827f4ddcbb14a3b4c9e3b00fd847bd103ab4120980403c520c7111d6dc31286ebc4195969e languageName: node linkType: hard -"@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.77" +"@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-emulator": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-lowlevel": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.77" - checksum: 10/43d5bdc689f170cc3d1e54b5eac1a0068cd2f2b607710f146419b8d3f9aec3d6638d2fe681000cc83ba337c48d46d491d7265bdaf0c8009645d25d70c5460ae7 + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-emulator": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-lowlevel": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.99" + checksum: 10/e50488e6595dbde67962eb647dc6f475777ecde7bad845a42c47cc58700f65f4fcd7a5d5ca8d745493e5277dfdd1b4f4d99a674e4a32d3dad799e52ffcf3babe languageName: node linkType: hard -"@onekeyfe/hd-core@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-core@npm:1.2.0-alpha.77" +"@onekeyfe/hd-core@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-core@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" axios: "npm:1.15.2" bignumber.js: "npm:^9.0.2" bytebuffer: "npm:^5.0.1" @@ -8848,138 +8848,138 @@ __metadata: semver: "npm:^7.3.7" peerDependencies: "@noble/hashes": ^1.1.3 - checksum: 10/b95d99f1fe89c03ddcd0cab4480bf8ad0383e4a113f3f311a2b62f0c36c39c770b7e317e1dc75e96e56a94a7dd0fca611907780e4cf29d64ed98090338891f35 + checksum: 10/36cb3223eb3b308d3231e531efcf7128e9e68ffa00ba7a9fbcaeff4e84f2f9bdd8ff999d6698dc91d80a5f81c04ed6389e81bf71d168d20fd88d9198e1c7e0ce languageName: node linkType: hard -"@onekeyfe/hd-shared@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-shared@npm:1.2.0-alpha.77" - checksum: 10/59277ac86c5e8cfa1f0a98fbcf9db0e44ca64b1e7a9f07a77d7a92607c1f0b761b0afc054f317734b2ee4bee362b2eacbae09a3ac67549ebb83b82a550d1a39a +"@onekeyfe/hd-shared@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-shared@npm:1.2.0-alpha.99" + checksum: 10/36b22deb1a52ff39717402239c7a63f81200c84dfa3d215f35c68d72e5b1b77b2e1c2163408abdac88d2d0be5989fc0c56d0adb6c06116c58aee18b0ac5d1266 languageName: node linkType: hard -"@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" "@stoprocent/noble": "npm:2.3.16" p-retry: "npm:^4.6.2" peerDependencies: electron: ">=25.0.0" electron-log: ">=4.0.0" - checksum: 10/47443ee470fe4b819f3159f8403641092c819b0a9a5e2763ac164c6bf6ac59fb06331a5919d26b65600278cf81ef2509f981c138ba4c9c41a9d1093e9c674382 + checksum: 10/ee47a7bf3cb95c0e77939da4e3117a3510f3660af82616c8f73822f476bc6c7c0116a35d7a7fac8d7748e906d1ed58cb75ef019b9b2a2b9c022057be580df25f languageName: node linkType: hard -"@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" axios: "npm:1.15.2" secure-json-parse: "npm:^4.0.0" - checksum: 10/86cf081fdd030e5c3edfa9a1873a93d727d18a97850a3b387011c6c64878411f789c745c8d0b926657e907e5a3a6251ae95d60875f7bd061f0840fcd2696f3b4 + checksum: 10/35386da242cf21521ce52d7d527bc348bfa7a54af61de7c091eda2e92fd7f01a243a85973c5d0405e7a9019587a9f902b224f37986ff048098147b20ea8efa59 languageName: node linkType: hard -"@onekeyfe/hd-transport-http@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-http@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-http@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-http@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" axios: "npm:1.15.2" secure-json-parse: "npm:^4.0.0" - checksum: 10/c3bd1a352302112306d385c788bba3162174009f77f8c4fa32c5cd5476be63bb26086ddd7dab061f7006ee3dc576f42da14cba8920def0c359f1ab8f33f6ee0a + checksum: 10/237473dca1477d6e8a11a7140ccac2da89372f6dbaad0f0fbc1ff20cbc79d6e26a2f73ba9a0bd90175ce744a4eaf2a5c5381bc19142873088be16d554b8f33e7 languageName: node linkType: hard -"@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" - checksum: 10/b8376dd300a877da97c02237839b99f8b4049b861915606613cea7eef483deeafd1e8cebc3373198c54286f676cc2e9b83ad03eecba8cfc2fc4651da9805a3d5 + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" + checksum: 10/d9b30052f39342d6d6ddd7d2244c93b55bb0eaca1259bb3e9f51431b0b58467921fb2ee6f066df4a6101a247b117317dc9128c986b0c73a1ffa34a22bc6f6ba0 languageName: node linkType: hard -"@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" "@onekeyfe/react-native-ble-utils": "npm:^0.1.6" react-native-ble-plx: "npm:3.5.1" - checksum: 10/bac614c958c56fe4e7acd1df93c90ab16d66be21299d067bf92cefb23e7dd311b35219b95087ec0640d19c40b23090c02aea5ef7f9f3703034789761ae7efa15 + checksum: 10/e3a7d0427c6d81e77358295d3e57f3f085bf2dfc7da9d79ef29bc42b6dde44e1e370dbe8119aad31991c04f3c97202e837f53bf9fd3087a594d8cf91639631ac languageName: node linkType: hard -"@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" bytebuffer: "npm:^5.0.1" usb: "npm:^2.14.0" - checksum: 10/d08bb4a0464aa0b9300b19c4924d75a728ce668f4fb32fac35740e8fd9ff23fb656f586480f3d465b970580fa1c6839610e3650ba343110a21b0b769ed11d288 + checksum: 10/ff0713b109768563a3989f06f9e79a4a9ff937dd7694fe3e48aa31c5bf556d2f5cba32ee14205b54c1b3ef5678024b15b69b744c7805a6dbe45d2f89e86b80ff languageName: node linkType: hard -"@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" - checksum: 10/fb9674b8507faa65ae7a579c4e01c1da0884a15cc6a344dc436ca54994050928d892635b92d80a9a9c94af6888d26d88b361375d1d2b5d1766d23505014c849d + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" + checksum: 10/4bd39955f238330e28e32027b8806cb5654fef64d04ada856725f5d247c67a267cb259779586e3e625219be34559031cf076f57ecf9dde885c229c7572030efb languageName: node linkType: hard -"@onekeyfe/hd-transport@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-transport@npm:1.2.0-alpha.77" +"@onekeyfe/hd-transport@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-transport@npm:1.2.0-alpha.99" dependencies: bytebuffer: "npm:^5.0.1" long: "npm:^4.0.0" protobufjs: "npm:^6.11.2" - checksum: 10/4cd9c6b2e3a99a9917bba2ee5fa7895041a356833923b7d0edfdcfc0749246031c4401184d72726545b8e65584b07cdac390c4b2fa5bbdcdadebe858004fe552 + checksum: 10/9704884e9621c95b2a70bdad609b385e5207e6a0f9e04b44c429ab81d1924990d6f0b29f12c53713fbf330eb79b7fa4e9ee9c116954435976781a2322ac10c2d languageName: node linkType: hard -"@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.77" +"@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.99" dependencies: "@onekeyfe/cross-inpage-provider-core": "npm:2.2.67" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.77" - checksum: 10/8e00f1306c384264e4dda52741e771f5d3347ccd65360c3bc29af48a73de15a3685e41b2adfde2a3c2d5b5b3c1e11cdbe9999b611fb38fe2d3ac4f183d2296e4 + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.99" + checksum: 10/d42a3553482d8e466e5bbb001c2b12b668333c692af2babd812dad167051547452fd3b87f3bb8e4383faa828bcf4c0fa3ac7c8d5546a534b6a8ddf9248845b3a languageName: node linkType: hard -"@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.99" dependencies: "@noble/hashes": "npm:^1.7.1" - checksum: 10/cd05170199a8406a5fb55c6828ee190a6301de160237eda63a0aa81ccde0939a6f1bfe1a7939b8cb21d2e5897c0902aebdd0db6c98f82c7087406c06d33545f7 + checksum: 10/253ebba1c746bc5acc942b7760cc5b8be169b22b563b995f23de72c551bcfc6354046b9128d00c699d7ea82a6ef1c963527cbc613c0426ade2714b170f2c8314 languageName: node linkType: hard -"@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.99" dependencies: "@ledgerhq/context-module": "npm:^1.0.0" "@ledgerhq/device-management-kit": "npm:^1.1.0" @@ -8988,62 +8988,62 @@ __metadata: "@ledgerhq/device-signer-kit-solana": "npm:^1.7.0" "@ledgerhq/hw-app-trx": "npm:^6.34.1" "@ledgerhq/hw-transport": "npm:^6.34.1" - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" bitcoinjs-lib: "npm:@onekeyfe/bitcoinjs-lib@7.0.1" - checksum: 10/ebcb653809447c33f536edd15e7251ed2cc329959361bacca20ce8de19686f9d7961ffefffd18c9415fde836a77a2a2249c928e66df4b9660721201f47b0ebe8 + checksum: 10/7b817867a113ad8122e0a68eac7f18b52010c2011712ffd96ba3f24ea4fa04d114607f316f27875637c0af4cdbdd0329b18c393bb8015e48d5d583c9b9d0982a languageName: node linkType: hard -"@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.99" dependencies: "@ledgerhq/device-management-kit": "npm:^1.1.0" "@ledgerhq/device-signer-kit-bitcoin": "npm:^1.0.0" "@ledgerhq/device-signer-kit-ethereum": "npm:^1.9.0" "@ledgerhq/device-signer-kit-solana": "npm:^1.7.0" "@ledgerhq/device-transport-kit-react-native-ble": "npm:^1.0.0" - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.99" peerDependencies: react-native: "*" - checksum: 10/10ab209887dd42eef9727f7a46aba04c04ec4180ed2bb28101df44257f7b1001cee8482aa28598cd1ff1baf584b85321bd74d93ffc6702e1ba86ec684344ee77 + checksum: 10/4adad6fcc856734c42e9376fba78baff00f5a3ecde2ddfd3e17d53238259c9b31bd12ad3be88c694ac201d4949494c59b1cf81e706aaf4827a3c893eda8d540e languageName: node linkType: hard -"@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.99" dependencies: "@ledgerhq/device-management-kit": "npm:^1.1.0" "@ledgerhq/device-signer-kit-bitcoin": "npm:^1.0.0" "@ledgerhq/device-signer-kit-ethereum": "npm:^1.9.0" "@ledgerhq/device-signer-kit-solana": "npm:^1.7.0" "@ledgerhq/device-transport-kit-web-hid": "npm:^1.0.0" - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.77" - checksum: 10/744ff2856a57a6d183806f1ad3c879645b5eac6bcfa1883f3b1c1a1647918f538a77bc94a6f95d583e341a375d3518ccdd8294e347cb561877313a2dc51f35f9 + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.99" + checksum: 10/7249742123eea00284c03ac0c368b50ffcfc987c7e1dfec2c817453955f2b78937d286de3c15353f0e8d337b3e102efd2e0cd313ed917d489264195dcd49f0a1 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.99" tslib: "npm:^2.6.2" - checksum: 10/6c60741c95db3b06160632f2d76f127083bd96cd1f98e00c936f20bc3df24fb1e372c8f823aad55d8c30a4544036175471affd0d7e2844783a715f9e587e41dd + checksum: 10/57b45e5c6383ff94af165aafe0aa4d60e6ab77b9591f60e5ca79b78088acacaebe3ba2c9c9a493914bf6c838968f26c211ba9520ef21c2a490afb77ffde5725c languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.99" buffer: "npm:^6.0.3" peerDependencies: "@stoprocent/noble": "*" @@ -9053,48 +9053,48 @@ __metadata: optional: true electron: optional: true - checksum: 10/45a449563da974ac26dca70776bc88dabf2655b176ac9bb05d15fb6eef8e9cebcc656f6e565a37104557fa8e745befd86c99238040a735b55accefa1a1fa70fb + checksum: 10/255b86fb9efd86446d4f6f4b11ccb3d7a8e57fc67a7e964e17785594c4b8762c527ac801267eb97d304452c31d0310fec409f716e9b83e6a8cf38704e85036f8 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.99" buffer: "npm:^6.0.3" react-native-ble-plx: "npm:^3.5.1" - checksum: 10/c2b82b7dc30659621cd736731d18a112aa177a5e5d47f29e84ac3f0a0d239c7abfd9f840401589539f7452d4258cd5cfc85cdee89a1a1a63c101d5224dc734b6 + checksum: 10/a1b4cc6682dd208c84357ec217da335428a09f41194f1ce257acc434847ccedd377fe852c0658eac67c0cbabc66c1806cbd233b963edbd511c453cf293fdbe43 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.99" buffer: "npm:^6.0.3" - checksum: 10/2b12d613135bb3ce9c4a37467c3f43dd512817125cb07d7d4016f4589779bdc127d8edbcb606a5d7333ab1d2e47313d97c3abf00fd4029bbfad245aadbeeff22 + checksum: 10/6573e133478fb4d972e63a79e15fc87240facac3573bfc74a84e547cff14c2943d03842ac39307b39ee7311cbbdcb1adf93bfdaf714e2ede85192d94314a5065 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.77" +"@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.99" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.77" - checksum: 10/848b83393e84c32e31652cd7e15d63de0aad9758a108e4e08790b13d6b6186b1585a0d678d3c848d02f045d69b55b12e93b50b8dde4d9d1d3cbcf9944501f3c0 + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.99" + checksum: 10/141c4ba86d3746ff52842fd5318047ae5e986a5d242bd43fcbe68519c585675cf570082259a723feec9f635748dd5a91793807c25515469f2419d862a4224904 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.77": - version: 1.2.0-alpha.77 - resolution: "@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.77" - checksum: 10/621c892feae576d2db727feaa43301d77d82ee9cd8264aa9cb9e29c553ff580f9542e9871d0bd366082a9eebf0e3f9aa23252598910a3e170acc3c9353f8e120 +"@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.99": + version: 1.2.0-alpha.99 + resolution: "@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.99" + checksum: 10/b5e917d6ade8f0e24f84e8e7425a15f36317bcca1cf858ddd2d1d30cf0ca30b8ecaf9e1ac611e3b00732a8613ec2c727928a1868f1f9adfe3c990ae089be7d0d languageName: node linkType: hard @@ -9792,21 +9792,21 @@ __metadata: "@onekeyfe/cross-inpage-provider-injected": "npm:2.2.73" "@onekeyfe/cross-inpage-provider-types": "npm:2.2.73" "@onekeyfe/extension-bridge-hosted": "npm:2.2.73" - "@onekeyfe/hd-ble-sdk": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-common-connect-sdk": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-transport-electron": "npm:1.2.0-alpha.77" - "@onekeyfe/hd-web-sdk": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-ledger-connector-ble": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-ledger-connector-webhid": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector-electron-ble": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector-rn-ble": "npm:1.2.0-alpha.77" - "@onekeyfe/hwk-trezor-connector-webusb": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-ble-sdk": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-common-connect-sdk": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-transport-electron": "npm:1.2.0-alpha.99" + "@onekeyfe/hd-web-sdk": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-ledger-connector-ble": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-ledger-connector-webhid": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector-electron-ble": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector-rn-ble": "npm:1.2.0-alpha.99" + "@onekeyfe/hwk-trezor-connector-webusb": "npm:1.2.0-alpha.99" "@onekeyfe/onekey-cross-webview": "npm:2.2.73" "@open-wc/webpack-import-meta-loader": "npm:^0.4.7" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.11"