diff --git a/apps/cli/package.json b/apps/cli/package.json index 0e57c77e0d0d..5e890f0b9f5b 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.69", - "@onekeyfe/hd-core": "1.2.0-alpha.69", - "@onekeyfe/hd-transport-usb": "1.2.0-alpha.69", + "@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", "proper-lockfile": "^4.1.2" } } diff --git a/apps/desktop/app/app.ts b/apps/desktop/app/app.ts index b7a4534d8829..63f1c69abdbe 100644 --- a/apps/desktop/app/app.ts +++ b/apps/desktop/app/app.ts @@ -57,6 +57,10 @@ import { import { ipcMessageKeys } from './config'; import { ElectronTranslations, i18nText, initLocale } from './i18n'; import { scheduleCrashDumpCleanup } from './libs/crashDumpCleanup'; +import { + DESKTOP_API_ALLOWED_MODULES, + isDesktopApiMethodAllowed, +} from './libs/desktopApiModuleAllowlist'; import { applyDesktopNetworkThrottleToKnownSessions, applyDesktopNetworkThrottleToWebContents, @@ -1132,23 +1136,7 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { // New invoke-based handler for contextIsolation-compatible API calls ipcMain.removeHandler('DESKTOP_API_CALL'); - const allowedModules = new Set([ - 'system', - 'security', - 'storage', - 'webview', - 'notification', - 'dev', - 'inAppPurchase', - 'bluetooth', - 'appUpdate', - 'bundleUpdate', - 'cloudKit', - 'keychain', - 'sniRequest', - 'oauthLocalServer', - 'appleAuth', - ]); + const allowedModules = new Set(DESKTOP_API_ALLOWED_MODULES); ipcMain.handle( 'DESKTOP_API_CALL', async ( @@ -1176,14 +1164,7 @@ async function createMainWindow(opts?: { isSoftRestart?: boolean }) { `DESKTOP_API_CALL: unknown module "${module}"`, ); } - // Block inherited prototype methods and private methods - if ( - typeof method !== 'string' || - method.startsWith('_') || - ['constructor', 'toString', 'valueOf', 'hasOwnProperty'].includes( - method, - ) - ) { + if (!isDesktopApiMethodAllowed(module, method)) { throw new OneKeyLocalError( `DESKTOP_API_CALL: disallowed method "${method}"`, ); diff --git a/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts b/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts new file mode 100644 index 000000000000..44c2ec0a3dbb --- /dev/null +++ b/apps/desktop/app/libs/desktopApiModuleAllowlist.test.ts @@ -0,0 +1,50 @@ +import { + DESKTOP_API_ALLOWED_MODULES, + isDesktopApiMethodAllowed, + isDesktopApiModuleAllowed, +} from './desktopApiModuleAllowlist'; + +describe('desktop API module allowlist', () => { + it('allows the firmware artifact module required by desktop upgrades', () => { + expect(DESKTOP_API_ALLOWED_MODULES).toContain('firmwareArtifact'); + expect(isDesktopApiModuleAllowed('firmwareArtifact')).toBe(true); + }); + + it('rejects modules outside the explicit allowlist', () => { + expect(isDesktopApiModuleAllowed('__proto__')).toBe(false); + }); + + it.each([ + 'getCapabilities', + 'download', + 'cancelDownloads', + 'materialize', + 'open', + 'read', + 'close', + 'createLease', + 'retain', + 'releaseLease', + 'sweepOrphans', + ])('allows firmwareArtifact.%s', (method) => { + expect(isDesktopApiMethodAllowed('firmwareArtifact', method)).toBe(true); + }); + + it.each([ + 'validateDownloadInput', + 'downloadLocked', + 'streamResponseToFile', + 'writeResponseBody', + 'promoteArtifact', + 'resolveArtifactPath', + '__proto__', + 'constructor', + ])('rejects firmwareArtifact.%s', (method) => { + expect(isDesktopApiMethodAllowed('firmwareArtifact', method)).toBe(false); + }); + + it('keeps the legacy method policy for existing modules', () => { + expect(isDesktopApiMethodAllowed('system', 'getSystemInfo')).toBe(true); + expect(isDesktopApiMethodAllowed('system', '_privateMethod')).toBe(false); + }); +}); diff --git a/apps/desktop/app/libs/desktopApiModuleAllowlist.ts b/apps/desktop/app/libs/desktopApiModuleAllowlist.ts new file mode 100644 index 000000000000..08ba5a4c433d --- /dev/null +++ b/apps/desktop/app/libs/desktopApiModuleAllowlist.ts @@ -0,0 +1,68 @@ +export const DESKTOP_API_ALLOWED_MODULES = Object.freeze([ + 'system', + 'security', + 'storage', + 'webview', + 'notification', + 'dev', + 'inAppPurchase', + 'bluetooth', + 'appUpdate', + 'bundleUpdate', + 'cloudKit', + 'keychain', + 'sniRequest', + 'oauthLocalServer', + 'appleAuth', + 'firmwareArtifact', +] as const); + +const DESKTOP_API_ALLOWED_METHODS_BY_MODULE: Readonly< + Partial< + Record<(typeof DESKTOP_API_ALLOWED_MODULES)[number], readonly string[]> + > +> = Object.freeze({ + firmwareArtifact: Object.freeze([ + 'getCapabilities', + 'download', + 'cancelDownloads', + 'materialize', + 'open', + 'read', + 'close', + 'createLease', + 'retain', + 'releaseLease', + 'sweepOrphans', + ]), +}); + +const DESKTOP_API_DISALLOWED_METHODS = new Set([ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', +]); + +export const isDesktopApiModuleAllowed = (module: string): boolean => + DESKTOP_API_ALLOWED_MODULES.includes( + module as (typeof DESKTOP_API_ALLOWED_MODULES)[number], + ); + +export const isDesktopApiMethodAllowed = ( + module: string, + method: unknown, +): boolean => { + if ( + typeof method !== 'string' || + method.startsWith('_') || + DESKTOP_API_DISALLOWED_METHODS.has(method) + ) { + return false; + } + const allowedMethods = + DESKTOP_API_ALLOWED_METHODS_BY_MODULE[ + module as (typeof DESKTOP_API_ALLOWED_MODULES)[number] + ]; + return allowedMethods ? allowedMethods.includes(method) : true; +}; diff --git a/apps/mobile/ios/AppDelegate.swift b/apps/mobile/ios/AppDelegate.swift index 305f38918b96..2fe91de5c2a9 100644 --- a/apps/mobile/ios/AppDelegate.swift +++ b/apps/mobile/ios/AppDelegate.swift @@ -249,16 +249,8 @@ public class AppDelegate: ExpoAppDelegate { } // Background URLSession events (concurrent/background downloads). - // When the app is relaunched in the background to finish a background - // download, hand the completion handler to the downloader via a notification. - // We post rather than call directly because the Nitro module's C++ umbrella - // header can't be imported into this Swift AppDelegate (see the - // NSClassFromString bridges above). If the downloader instance isn't live yet - // the events are processed on the next foreground launch instead — the - // download itself still completed in the background. - // // Posted under a generic name (RangeDownloaderBackgroundEvents) so any number - // of channels (bundle / apk / chart) route through one notification; the + // of channels route through one notification; the // shared range-downloader filters by its own session identifier prefix (and // still recognizes the legacy identifier prefix for in-flight downloads that // span an app update). diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index ea43923a9d97..5008045727c4 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -4006,7 +4006,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.78): + - ReactNativeRangeDownloader (3.0.81-alpha.11): - boost - DoubleConversion - fast_float @@ -4036,6 +4036,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeNativeLogger - SocketRocket + - SSZipArchive (= 2.5.5) - Yoga - ReactNativeSplashScreen (3.0.78): - boost @@ -4710,7 +4711,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SniConnect (3.0.78): + - SniConnect (3.0.81-alpha.9): - boost - DoubleConversion - EMASCurl (= 1.5.5) @@ -5564,7 +5565,7 @@ SPEC CHECKSUMS: ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd ReactNativePerfMemory: 7ef4df212ac2a19e5125deb9f2067ee110235e48 ReactNativePerfStats: d1368e3a14b5387dea7cc87bca9a566810636f5e - ReactNativeRangeDownloader: fb71689f6c2ccf99ad63b59411a84ee1581bc7ac + ReactNativeRangeDownloader: 93e90ff445d0404cb97a6eea7a617b4b4b209c12 ReactNativeSplashScreen: 0bc82cdce113b2f60366b3d0f2a495ff86e13022 ReactNativeZipArchive: bb4a2b338281c0166bee97142bf59ef9cd124c62 RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 @@ -5588,7 +5589,7 @@ SPEC CHECKSUMS: SegmentSlider: e3507345e9bf6a48fd301181765d056a70420512 Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 Skeleton: e04f3e3d91865cdb03ef256a095d128688acf3fb - SniConnect: 4093cf48264f2b78081f8cfa8482d7a6453803c5 + SniConnect: eb28a03028065e205739894cb10be4c80b8cabd5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ae32ea0dc9ee..84ca23b899e5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -71,11 +71,11 @@ "@onekeyfe/react-native-perf-memory": "3.0.78", "@onekeyfe/react-native-perf-stats": "3.0.78", "@onekeyfe/react-native-perp-depth-bar": "3.0.78", - "@onekeyfe/react-native-range-downloader": "3.0.78", + "@onekeyfe/react-native-range-downloader": "3.0.81-alpha.11", "@onekeyfe/react-native-scroll-guard": "3.0.78", "@onekeyfe/react-native-segment-slider": "3.0.78", "@onekeyfe/react-native-skeleton": "3.0.78", - "@onekeyfe/react-native-sni-connect": "3.0.78", + "@onekeyfe/react-native-sni-connect": "3.0.81-alpha.9", "@onekeyfe/react-native-splash-screen": "3.0.78", "@onekeyfe/react-native-split-bundle-loader": "3.0.78", "@onekeyfe/react-native-tab-view": "3.0.78", diff --git a/development/perf-ci/thresholds/web.cold.json b/development/perf-ci/thresholds/web.cold.json index aef2b2cfdead..3c8e1443bcfb 100644 --- a/development/perf-ci/thresholds/web.cold.json +++ b/development/perf-ci/thresholds/web.cold.json @@ -50,7 +50,7 @@ "swap": { "resourceCount": 220, "scriptCount": 169, - "jsDecodedBytes": 17458790, + "jsDecodedBytes": 17475174, "longTaskTotalMs": 1200 }, "defi": { diff --git a/package.json b/package.json index ed653d7bb21c..7f263f8f0431 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.69", - "@onekeyfe/hd-common-connect-sdk": "1.2.0-alpha.69", - "@onekeyfe/hd-core": "1.2.0-alpha.69", - "@onekeyfe/hd-shared": "1.2.0-alpha.69", - "@onekeyfe/hd-transport": "1.2.0-alpha.69", - "@onekeyfe/hd-transport-electron": "1.2.0-alpha.69", - "@onekeyfe/hd-web-sdk": "1.2.0-alpha.69", - "@onekeyfe/hwk-adapter-core": "1.2.0-alpha.69", - "@onekeyfe/hwk-ledger-adapter": "1.2.0-alpha.69", - "@onekeyfe/hwk-ledger-connector-ble": "1.2.0-alpha.69", - "@onekeyfe/hwk-ledger-connector-webhid": "1.2.0-alpha.69", - "@onekeyfe/hwk-trezor-adapter": "1.2.0-alpha.69", - "@onekeyfe/hwk-trezor-connector-electron-ble": "1.2.0-alpha.69", - "@onekeyfe/hwk-trezor-connector-rn-ble": "1.2.0-alpha.69", - "@onekeyfe/hwk-trezor-connector-webusb": "1.2.0-alpha.69", + "@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/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.69", - "@onekeyfe/hd-shared": "1.2.0-alpha.69", - "@onekeyfe/hd-transport": "1.2.0-alpha.69", - "@onekeyfe/hd-transport-http": "1.2.0-alpha.69", - "@onekeyfe/hd-transport-web-device": "1.2.0-alpha.69", + "@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", "promise": "^8.3.0", "metro": "0.83.2", "metro-babel-transformer": "0.83.2", diff --git a/packages/kit-bg/package.json b/packages/kit-bg/package.json index 3e32dae4441e..f416164dc167 100644 --- a/packages/kit-bg/package.json +++ b/packages/kit-bg/package.json @@ -9,7 +9,11 @@ "idb": "^7.1.1", "ipaddr.js": "^2.3.0", "jpeg-js": "^0.4.4", - "miscreant": "^0.3.2" + "miscreant": "^0.3.2", + "yauzl": "2.10.0" + }, + "devDependencies": { + "@types/yauzl": "2.10.3" }, "scripts": { "keyless:mock-server:build": "tsc -p tsconfig.keyless-mock-server.json", diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts new file mode 100644 index 000000000000..e0ab68e74729 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.test.ts @@ -0,0 +1,206 @@ +import { createHash } from 'node:crypto'; +import { rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import DesktopApiFirmwareArtifact, { + isFirmwareArtifactUrlAllowed, +} from './DesktopApiFirmwareArtifact'; + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp/onekey-firmware-artifact-jest'), + }, + session: { defaultSession: { fetch: jest.fn() } }, +})); + +describe('DesktopApiFirmwareArtifact URL admission', () => { + afterEach(async () => { + await rm('/tmp/onekey-firmware-artifact-jest', { + recursive: true, + force: true, + }); + }); + + it('accepts only exact reviewed artifact hosts', () => { + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://web.onekey-asset.com/firmware.bin'), + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL( + 'https://pub-d5c080673b4e4e9dae7e03680340378d.r2.dev/firmware.bin', + ), + ), + ).toBe(true); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://web.onekey-asset.com.evil.test/firmware.bin'), + ), + ).toBe(false); + expect( + isFirmwareArtifactUrlAllowed( + new URL('https://common.onekey-asset.com:8443/firmware.bin'), + ), + ).toBe(false); + }); + + it('probes its root and keeps the minimum lease lifecycle in memory', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + expect(adapter.getCapabilities()).toMatchObject({ + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ['domain'], + maxReadBytes: 256 * 1024, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000001'; + const lease = await adapter.createLease(transactionId); + await adapter.releaseLease({ + leaseRef: lease.leaseRef, + disposition: 'safeCancelled', + }); + }); + + it('rejects cancelled transactions until their lease is released', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000002'; + const lease = await adapter.createLease(transactionId); + const input = { + taskId: 'firmware', + transactionId, + leaseRef: lease.leaseRef, + artifactId: 'firmware', + url: 'https://web.onekey-asset.com/firmware.bin', + route: { routeType: 'domain' } as const, + expectedSize: 1, + expectedSha256: 'a'.repeat(64), + maxBytes: 1, + overallDeadlineSeconds: 1, + }; + await adapter.cancelDownloads(transactionId); + await expect(adapter.download(input)).rejects.toThrow('ARTIFACT_CANCELLED'); + await adapter.releaseLease({ + leaseRef: lease.leaseRef, + disposition: 'safeCancelled', + }); + const nextLease = await adapter.createLease(transactionId); + await expect( + adapter.download({ ...input, leaseRef: nextLease.leaseRef }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + }); + + it('uses distinct partial files for the same artifact across transactions', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const artifact = Buffer.from('firmware artifact'); + const expectedSha256 = createHash('sha256').update(artifact).digest('hex'); + const partialPaths: string[] = []; + const adapterWithStream = adapter as unknown as { + streamResponseToFile( + input: Parameters[0], + partialPath: string, + resumeOffset: number, + ): Promise; + }; + jest + .spyOn(adapterWithStream, 'streamResponseToFile') + .mockImplementation(async (_input, partialPath) => { + partialPaths.push(partialPath); + await writeFile(partialPath, artifact); + }); + const transactionIds = [ + 'fwtx:00000000-0000-4000-8000-000000000003', + 'fwtx:00000000-0000-4000-8000-000000000004', + ]; + const leases = await Promise.all( + transactionIds.map((transactionId) => adapter.createLease(transactionId)), + ); + const baseInput = { + taskId: `fw-${expectedSha256.slice(0, 24)}`, + artifactId: 'firmware', + url: 'https://web.onekey-asset.com/firmware.bin', + route: { routeType: 'domain' } as const, + expectedSize: artifact.byteLength, + expectedSha256, + maxBytes: artifact.byteLength, + overallDeadlineSeconds: 30, + }; + + await expect( + Promise.all( + transactionIds.map((transactionId, index) => + adapter.download({ + ...baseInput, + transactionId, + leaseRef: leases[index].leaseRef, + }), + ), + ), + ).resolves.toHaveLength(2); + expect(partialPaths).toHaveLength(2); + expect(new Set(partialPaths).size).toBe(2); + expect( + partialPaths.every((value) => path.extname(value) === '.partial'), + ).toBe(true); + }); + + it('restarts an unverified partial instead of mixing responses across attempts', async () => { + const adapter = new DesktopApiFirmwareArtifact({ + desktopApi: {} as never, + }); + const transactionId = 'fwtx:00000000-0000-4000-8000-000000000005'; + const taskId = 'firmware-without-integrity'; + const url = 'https://web.onekey-asset.com/firmware.bin'; + const downloadToken = createHash('sha256').update(url).digest('hex'); + const transactionToken = createHash('sha256') + .update(transactionId) + .digest('hex') + .slice(0, 16); + const partialPath = path.join( + '/tmp/onekey-firmware-artifact-jest', + `${downloadToken}.${taskId}.${transactionToken}.partial`, + ); + await writeFile(partialPath, Buffer.from('stale partial')); + const artifact = Buffer.from('complete firmware artifact'); + const actualSha256 = createHash('sha256').update(artifact).digest('hex'); + const resumeOffsets: number[] = []; + const adapterWithStream = adapter as unknown as { + streamResponseToFile( + input: Parameters[0], + targetPath: string, + resumeOffset: number, + ): Promise; + }; + jest + .spyOn(adapterWithStream, 'streamResponseToFile') + .mockImplementation(async (_input, targetPath, resumeOffset) => { + resumeOffsets.push(resumeOffset); + await writeFile(targetPath, artifact); + }); + const { leaseRef } = await adapter.createLease(transactionId); + + await expect( + adapter.download({ + taskId, + transactionId, + leaseRef, + artifactId: 'firmware', + url, + route: { routeType: 'domain' }, + maxBytes: 512 * 1024 * 1024, + overallDeadlineSeconds: 30, + }), + ).resolves.toEqual({ + artifactRef: `fw:${actualSha256}`, + size: artifact.byteLength, + sha256: actualSha256, + expectedSha256Verified: false, + }); + expect(resumeOffsets).toEqual([0]); + }); +}); diff --git a/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts new file mode 100644 index 000000000000..5991d967c525 --- /dev/null +++ b/packages/kit-bg/src/desktopApis/DesktopApiFirmwareArtifact.ts @@ -0,0 +1,1159 @@ +// cspell:ignore fwlease + +import { createHash, randomUUID } from 'node:crypto'; +import { + accessSync, + createReadStream, + createWriteStream, + constants as fsConstants, + mkdirSync, +} from 'node:fs'; +import { + type FileHandle, + mkdir, + open, + readdir, + rename, + rm, + stat, +} from 'node:fs/promises'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { app, session } from 'electron'; +import yauzl from 'yauzl'; + +import { FirmwareArtifactDesktopError } from './FirmwareArtifactDesktopError'; + +import type { IDesktopApi } from './instance/IDesktopApi'; +import type { + IFirmwareArtifactAdapter, + IFirmwareArtifactReceipt, +} from '../services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import type { Entry, ZipFile } from 'yauzl'; + +const MAX_READ_BYTES = 256 * 1024; +const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024; +const MAX_ARCHIVE_ENTRIES = 4096; +const MAX_ARCHIVE_ENTRY_BYTES = 128 * 1024 * 1024; +const UNIX_FILE_TYPE_MASK = 61_440; +const UNIX_REGULAR_FILE_TYPE = 32_768; +const ARTIFACT_REF_PATTERN = /^fw:[a-f0-9]{64}$/u; +const SHA256_PATTERN = /^[a-fA-F0-9]{64}$/u; +const TASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,100}$/u; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]{1,160}$/u; +const LEASE_REF_PATTERN = /^fwlease:[a-f0-9-]{36}$/u; +const NESTED_ARCHIVE_PATTERN = /\.(?:zip|7z|rar|tar|gz|tgz)$/iu; +const FINAL_ARTIFACT_GRACE_MS = 24 * 60 * 60 * 1000; +const PARTIAL_ARTIFACT_GRACE_MS = 7 * 24 * 60 * 60 * 1000; +const FIRMWARE_ARTIFACT_HOSTNAMES = new Set([ + 'common.onekey-asset.com', + 'pub-d5c080673b4e4e9dae7e03680340378d.r2.dev', + 'web.onekey-asset.com', +]); + +type IDownloadInput = Parameters[0]; +type IMaterializeInput = Parameters[0]; +type IArchiveRequirement = { + entryName: string; + expectedSize: number; + expectedSha256?: string; +}; + +type IStagedEntry = { + entryName: string; + size: number; + sha256: string; + filePath: string; +}; + +type IFirmwareArtifactLease = { + transactionId: string; + artifactRefs: Set; +}; + +const hashFile = async (filePath: string): Promise => { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) { + digest.update(chunk); + } + return digest.digest('hex'); +}; + +const assertSafeInteger = (value: number, label: string): void => { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + `${label} must be a positive safe integer`, + ); + } +}; + +export const isFirmwareArtifactUrlAllowed = (url: URL): boolean => + url.protocol === 'https:' && + url.port === '' && + !url.username && + !url.password && + !url.hash && + FIRMWARE_ARTIFACT_HOSTNAMES.has(url.hostname.toLowerCase()); + +const validatePortableEntryName = ( + name: string, + canonicalNames: Set, +): void => { + const normalized = name.normalize('NFC'); + const folded = normalized.toLowerCase(); + const parts = name.split('/'); + if ( + !name || + name.length > 512 || + name !== normalized || + name.startsWith('/') || + name.startsWith('\\') || + name.includes('\\') || + name.includes(':') || + Array.from(name).some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || code === 0x7f; + }) || + parts.some( + (part) => + !part || + part === '.' || + part === '..' || + part.endsWith('.') || + part.endsWith(' '), + ) || + NESTED_ARCHIVE_PATTERN.test(name) || + canonicalNames.has(folded) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry name is not portable', + ); + } + canonicalNames.add(folded); +}; + +const openZip = (filePath: string, options: yauzl.Options): Promise => + new Promise((resolve, reject) => { + yauzl.open(filePath, options, (error, zipFile) => { + if (error || !zipFile) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive cannot be opened', + { cause: error }, + ), + ); + } else { + resolve(zipFile); + } + }); + }); + +const openZipEntry = (zipFile: ZipFile, entry: Entry): Promise => + new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry cannot be opened', + { cause: error }, + ), + ); + } else { + resolve(stream); + } + }); + }); + +class DesktopApiFirmwareArtifact implements IFirmwareArtifactAdapter { + private readonly downloads = new Map< + string, + Promise + >(); + + private readonly cancelledTransactions = new Set(); + + private readonly cancellationCallbacks = new Map void>>(); + + private readonly readers = new Map< + string, + { handle: FileHandle; size: number; filePath: string } + >(); + + private readonly promotions = new Map>(); + + private readonly rootPath: string; + + private readonly leases = new Map(); + + constructor(_params: { desktopApi: IDesktopApi }) { + this.rootPath = path.join(app.getPath('userData'), 'firmware-artifacts'); + mkdirSync(this.rootPath, { recursive: true }); + accessSync(this.rootPath, fsConstants.R_OK | fsConstants.W_OK); + } + + getCapabilities() { + return { + firmwareArtifactProtocolVersion: 4, + supportedRouteTypes: ['domain'], + supportsArchiveMaterialization: true, + maxReadBytes: MAX_READ_BYTES, + }; + } + + async download(input: IDownloadInput): Promise { + this.validateDownloadInput(input); + this.assertNotCancelled(input.transactionId); + if (input.expectedSha256) { + await this.retainExpected({ + leaseRef: input.leaseRef, + transactionId: input.transactionId, + artifactRef: `fw:${input.expectedSha256.toLowerCase()}`, + }); + } else { + await this.assertLeaseTransaction(input.leaseRef, input.transactionId); + } + const downloadToken = + input.expectedSha256?.toLowerCase() ?? + createHash('sha256').update(input.url).digest('hex'); + const key = `${downloadToken}:${input.taskId}:${input.transactionId}`; + const existing = this.downloads.get(key); + if (existing) return existing; + const task = this.downloadLocked(input, downloadToken) + .then(async (receipt) => { + await this.retainExpected({ + leaseRef: input.leaseRef, + transactionId: input.transactionId, + artifactRef: receipt.artifactRef, + }); + return receipt; + }) + .finally(() => { + this.downloads.delete(key); + }); + this.downloads.set(key, task); + return task; + } + + async cancelDownloads(transactionId: string): Promise { + if (!IDENTIFIER_PATTERN.test(transactionId)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware transactionId is invalid', + ); + } + this.cancelledTransactions.add(transactionId); + for (const cancel of this.cancellationCallbacks.get(transactionId) ?? []) { + cancel(); + } + } + + async materialize( + input: IMaterializeInput, + ): Promise< + readonly { entryName: string; receipt: IFirmwareArtifactReceipt }[] + > { + await this.assertLease(input.leaseRef); + const archivePath = await this.resolveArtifactPath( + input.archiveArtifactRef, + ); + const expectedEntries = input.expectedEntries + ? this.validateExpectedEntries(input.expectedEntries) + : undefined; + const requirements = await this.validateArchive( + archivePath, + expectedEntries, + ); + const scratchPath = path.join(this.rootPath, `archive-${randomUUID()}`); + await mkdir(scratchPath, { recursive: false }); + try { + const staged = await this.extractArchive( + archivePath, + scratchPath, + requirements, + ); + const result = []; + for (const entry of staged) { + const destination = this.artifactPath(entry.sha256); + if ( + !(await this.isStoredArtifactValid( + destination, + entry.size, + entry.sha256, + )) + ) { + await this.promoteArtifact({ + sourcePath: entry.filePath, + size: entry.size, + sha256: entry.sha256, + }); + } + const receipt = { + artifactRef: `fw:${entry.sha256}`, + size: entry.size, + sha256: entry.sha256, + expectedSha256Verified: input.expectedEntries !== undefined, + }; + await this.retainExpected({ + leaseRef: input.leaseRef, + artifactRef: receipt.artifactRef, + }); + result.push({ + entryName: entry.entryName, + receipt, + }); + } + return result; + } finally { + await rm(scratchPath, { recursive: true, force: true }); + } + } + + async open(artifactRef: string) { + const filePath = await this.resolveArtifactPath(artifactRef); + const fileStat = await stat(filePath); + const readerId = randomUUID(); + this.readers.set(readerId, { + handle: await open(filePath, 'r'), + size: fileStat.size, + filePath, + }); + return { readerId, size: fileStat.size }; + } + + async read({ + readerId, + offset, + length, + }: { + readerId: string; + offset: number; + length: number; + }): Promise { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length <= 0 || + length > MAX_READ_BYTES + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact read is invalid', + ); + } + const reader = this.readers.get(readerId); + if (!reader || offset + length > reader.size) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact read is out of bounds', + ); + } + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await reader.handle.read(buffer, 0, length, offset); + if (bytesRead !== length) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_READER_INVALID', + 'Firmware artifact returned a short read', + ); + } + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + } + + async close(readerId: string): Promise { + const reader = this.readers.get(readerId); + this.readers.delete(readerId); + await reader?.handle.close(); + } + + async createLease(transactionId: string): Promise<{ leaseRef: string }> { + if (!IDENTIFIER_PATTERN.test(transactionId)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware transactionId is invalid', + ); + } + if (this.leases.size >= 32) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Too many firmware artifact leases', + ); + } + const leaseRef = `fwlease:${randomUUID()}`; + this.leases.set(leaseRef, { + transactionId, + artifactRefs: new Set(), + }); + return { leaseRef }; + } + + async retain({ + leaseRef, + artifactRef, + }: { + leaseRef: string; + artifactRef: string; + }): Promise { + await this.resolveArtifactPath(artifactRef); + await this.retainExpected({ leaseRef, artifactRef }); + } + + async releaseLease({ + leaseRef, + disposition, + }: Parameters[0]): Promise { + if ( + disposition !== 'completed' && + disposition !== 'safeCancelled' && + disposition !== 'safeAbandoned' + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware lease disposition is invalid', + ); + } + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + this.leases.delete(leaseRef); + const { transactionId } = lease; + this.cancelledTransactions.delete(transactionId); + } + + async sweepOrphans(): Promise<{ + deletedFiles: number; + deletedBytes: number; + }> { + const retained = new Set( + [...this.leases.values()] + .flatMap((lease) => [...lease.artifactRefs]) + .map((artifactRef) => artifactRef.slice(3)), + ); + const active = new Set( + [...this.downloads.keys()].map((key) => key.slice(0, 64)), + ); + const openPaths = new Set( + [...this.readers.values()].map((reader) => reader.filePath), + ); + const files = await readdir(this.rootPath, { withFileTypes: true }).catch( + () => [], + ); + const now = Date.now(); + let deletedFiles = 0; + let deletedBytes = 0; + for (const entry of files) { + if (entry.isFile()) { + const sha256 = entry.name.slice(0, 64); + if ( + /^[a-f0-9]{64}$/u.test(sha256) && + !retained.has(sha256) && + !active.has(sha256) + ) { + const filePath = path.join(this.rootPath, entry.name); + if (!openPaths.has(filePath)) { + let grace: number | undefined; + if (entry.name.endsWith('.bin')) { + grace = FINAL_ARTIFACT_GRACE_MS; + } else if (entry.name.endsWith('.partial')) { + grace = PARTIAL_ARTIFACT_GRACE_MS; + } + if (grace) { + const fileStat = await stat(filePath).catch(() => undefined); + if (fileStat && now - fileStat.mtimeMs >= grace) { + await rm(filePath, { force: true }); + deletedFiles += 1; + deletedBytes += fileStat.size; + } + } + } + } + } + } + return { deletedFiles, deletedBytes }; + } + + private validateDownloadInput(input: IDownloadInput): void { + const url = new URL(input.url); + if (!isFirmwareArtifactUrlAllowed(url)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware URL is outside the reviewed artifact host allowlist', + ); + } + if (input.expectedSize !== undefined) { + assertSafeInteger(input.expectedSize, 'expectedSize'); + } + assertSafeInteger(input.maxBytes, 'maxBytes'); + if ( + input.maxBytes > MAX_ARTIFACT_BYTES || + (input.expectedSize !== undefined && + input.expectedSize > input.maxBytes) || + (input.expectedSha256 !== undefined && + !SHA256_PATTERN.test(input.expectedSha256)) || + !TASK_ID_PATTERN.test(input.taskId) || + !IDENTIFIER_PATTERN.test(input.transactionId) || + !IDENTIFIER_PATTERN.test(input.artifactId) || + !LEASE_REF_PATTERN.test(input.leaseRef) || + !Number.isFinite(input.overallDeadlineSeconds) || + input.overallDeadlineSeconds <= 0 + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifact constraints are invalid', + ); + } + if (input.route.routeType !== 'domain') { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware route constraints are invalid', + ); + } + } + + private async downloadLocked( + input: IDownloadInput, + downloadToken: string, + ): Promise { + this.assertNotCancelled(input.transactionId); + await mkdir(this.rootPath, { recursive: true }); + const expectedSha256 = input.expectedSha256?.toLowerCase(); + if (expectedSha256) { + const finalPath = this.artifactPath(expectedSha256); + if ( + await this.isStoredArtifactValid( + finalPath, + input.expectedSize, + expectedSha256, + input.maxBytes, + ) + ) { + const fileStat = await stat(finalPath); + return { + artifactRef: `fw:${expectedSha256}`, + size: fileStat.size, + sha256: expectedSha256, + expectedSha256Verified: true, + }; + } + } + + const partialPath = path.join( + this.rootPath, + `${downloadToken}.${input.taskId}.${createHash('sha256') + .update(input.transactionId) + .digest('hex') + .slice(0, 16)}.partial`, + ); + let partialSize = await stat(partialPath) + .then((value) => value.size) + .catch(() => 0); + if (!expectedSha256 && partialSize > 0) { + await rm(partialPath, { force: true }); + partialSize = 0; + } else if (partialSize > input.maxBytes) { + await rm(partialPath, { force: true }); + partialSize = 0; + } else if ( + input.expectedSize !== undefined && + partialSize === input.expectedSize && + (await this.isStoredArtifactValid( + partialPath, + input.expectedSize, + expectedSha256, + input.maxBytes, + )) + ) { + const actualSha256 = await hashFile(partialPath); + await this.promoteArtifact({ + sourcePath: partialPath, + size: input.expectedSize, + sha256: actualSha256, + }); + return { + artifactRef: `fw:${actualSha256}`, + size: input.expectedSize, + sha256: actualSha256, + expectedSha256Verified: expectedSha256 !== undefined, + }; + } else if ( + input.expectedSize !== undefined && + partialSize === input.expectedSize + ) { + await rm(partialPath, { force: true }); + partialSize = 0; + } + await this.streamResponseToFile(input, partialPath, partialSize); + this.assertNotCancelled(input.transactionId); + const completedStat = await stat(partialPath); + const actualSha256 = await hashFile(partialPath); + if ( + completedStat.size <= 0 || + completedStat.size > input.maxBytes || + (input.expectedSize !== undefined && + completedStat.size !== input.expectedSize) || + (expectedSha256 !== undefined && actualSha256 !== expectedSha256) + ) { + await rm(partialPath, { force: true }); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INTEGRITY_FAILED', + 'Firmware artifact size or SHA-256 is invalid', + ); + } + await this.promoteArtifact({ + sourcePath: partialPath, + size: completedStat.size, + sha256: actualSha256, + }); + return { + artifactRef: `fw:${actualSha256}`, + size: completedStat.size, + sha256: actualSha256, + expectedSha256Verified: expectedSha256 !== undefined, + }; + } + + private async streamResponseToFile( + input: IDownloadInput, + partialPath: string, + resumeOffset: number, + ): Promise { + const headers: Record = { + 'Accept-Encoding': 'identity', + ...(resumeOffset > 0 ? { Range: `bytes=${resumeOffset}-` } : {}), + }; + const timeout = Math.min( + Math.ceil(input.overallDeadlineSeconds * 1000), + 30 * 60 * 1000, + ); + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), timeout); + const unregister = this.registerCancellation(input.transactionId, () => + abortController.abort(), + ); + try { + const response = await session.defaultSession.fetch(input.url, { + method: 'GET', + headers, + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + signal: abortController.signal, + }); + if (!response.body) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response body is missing', + ); + } + await this.writeResponseBody({ + input, + partialPath, + resumeOffset, + statusCode: response.status, + contentRange: response.headers.get('content-range') ?? undefined, + contentEncoding: response.headers.get('content-encoding') ?? undefined, + body: Readable.fromWeb(response.body as unknown as NodeReadableStream), + }); + } catch (error) { + if (error instanceof FirmwareArtifactDesktopError) { + throw error; + } + if (this.cancelledTransactions.has(input.transactionId)) { + throw this.createCancelledError(); + } + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_NETWORK_FAILED', + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + } finally { + clearTimeout(timeoutId); + unregister(); + } + } + + private assertNotCancelled(transactionId: string): void { + if (this.cancelledTransactions.has(transactionId)) { + throw this.createCancelledError(); + } + } + + private createCancelledError(): FirmwareArtifactDesktopError { + return new FirmwareArtifactDesktopError( + 'ARTIFACT_CANCELLED', + 'Firmware artifact download was cancelled', + ); + } + + private registerCancellation( + transactionId: string, + cancel: () => void, + ): () => void { + const callbacks = + this.cancellationCallbacks.get(transactionId) ?? new Set<() => void>(); + callbacks.add(cancel); + this.cancellationCallbacks.set(transactionId, callbacks); + if (this.cancelledTransactions.has(transactionId)) cancel(); + return () => { + callbacks.delete(cancel); + if (!callbacks.size) this.cancellationCallbacks.delete(transactionId); + }; + } + + private async writeResponseBody({ + input, + partialPath, + resumeOffset, + statusCode, + contentRange, + contentEncoding, + body, + }: { + input: IDownloadInput; + partialPath: string; + resumeOffset: number; + statusCode: number; + contentRange: string | undefined; + contentEncoding: string | undefined; + body: Readable; + }): Promise { + if (statusCode !== 200 && statusCode !== 206) { + body.destroy(); + throw new FirmwareArtifactDesktopError( + `ARTIFACT_HTTP_${statusCode}`, + 'Firmware request failed', + ); + } + const append = resumeOffset > 0 && statusCode === 206; + if ( + statusCode === 206 && + !this.isValidContentRange( + contentRange, + append ? resumeOffset : 0, + input.expectedSize, + input.maxBytes, + ) + ) { + body.destroy(); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware Content-Range is invalid', + ); + } + if (contentEncoding && contentEncoding !== 'identity') { + body.destroy(); + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response content encoding is invalid', + ); + } + let written = append ? resumeOffset : 0; + const limiter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + written += chunk.byteLength; + if (written > input.maxBytes) { + callback( + new FirmwareArtifactDesktopError( + 'ARTIFACT_PROTOCOL_INVALID', + 'Firmware response exceeds maxBytes', + ), + ); + } else { + callback(null, chunk); + } + }, + }); + await pipeline( + body, + limiter, + createWriteStream(partialPath, { + flags: append ? 'a' : 'w', + }), + ); + } + + private isValidContentRange( + value: string | undefined, + expectedStart: number, + expectedTotal: number | undefined, + maxBytes: number, + ): boolean { + const match = /^bytes ([0-9]+)-([0-9]+)\/([0-9]+)$/iu.exec(value ?? ''); + if (!match) return false; + const start = Number(match[1]); + const end = Number(match[2]); + const total = Number(match[3]); + return ( + Number.isSafeInteger(start) && + Number.isSafeInteger(end) && + Number.isSafeInteger(total) && + start === expectedStart && + end >= start && + end < total && + (expectedTotal !== undefined + ? total === expectedTotal + : total > 0 && total <= maxBytes) + ); + } + + private validateExpectedEntries( + entries: NonNullable, + ): Map { + if (!entries.length || entries.length > MAX_ARCHIVE_ENTRIES) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive expected entry count is invalid', + ); + } + const result = new Map(); + const canonicalNames = new Set(); + let totalSize = 0; + for (const entry of entries) { + validatePortableEntryName(entry.entryName, canonicalNames); + assertSafeInteger(entry.expectedSize, 'archive entry size'); + totalSize += entry.expectedSize; + if ( + entry.expectedSize > MAX_ARCHIVE_ENTRY_BYTES || + totalSize > MAX_ARTIFACT_BYTES || + !SHA256_PATTERN.test(entry.expectedSha256) || + result.has(entry.entryName) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive expected entry is invalid', + ); + } + result.set(entry.entryName, entry); + } + return result; + } + + private async validateArchive( + filePath: string, + expectedRequirements: Map | undefined, + ): Promise> { + const zipFile = await openZip(filePath, { + autoClose: true, + lazyEntries: false, + strictFileNames: true, + validateEntrySizes: true, + }); + return new Promise>((resolve, reject) => { + const names = new Set(); + const canonicalNames = new Set(); + const requirements = new Map(); + let totalSize = 0; + zipFile.on('entry', (entry) => { + try { + const entryName = String(entry.fileName); + const expectedRequirement = expectedRequirements?.get(entryName); + const expectedSize = + expectedRequirement?.expectedSize ?? entry.uncompressedSize; + const hostSystem = entry.versionMadeBy >> 8; + const fileType = + (entry.externalFileAttributes >>> 16) & UNIX_FILE_TYPE_MASK; + const isRegular = + hostSystem === 3 || hostSystem === 19 + ? fileType === 0 || fileType === UNIX_REGULAR_FILE_TYPE + : (entry.externalFileAttributes & 0x10) === 0; + validatePortableEntryName(entryName, canonicalNames); + totalSize += entry.uncompressedSize; + if ( + (expectedRequirements && !expectedRequirement) || + names.has(entryName) || + entryName.endsWith('/') || + entry.uncompressedSize !== expectedSize || + entry.uncompressedSize <= 0 || + entry.uncompressedSize > MAX_ARCHIVE_ENTRY_BYTES || + totalSize > MAX_ARTIFACT_BYTES || + entry.compressedSize < 0 || + entry.uncompressedSize > Math.max(entry.compressedSize, 1) * 1000 || + (entry.generalPurposeBitFlag & 1) !== 0 || + (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) || + !isRegular + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry metadata does not match the manifest', + ); + } + names.add(entryName); + requirements.set(entryName, { + entryName, + expectedSize, + ...(expectedRequirement?.expectedSha256 + ? { expectedSha256: expectedRequirement.expectedSha256 } + : {}), + }); + } catch (error) { + zipFile.close(); + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry metadata is invalid', + { cause: error }, + ), + ); + } + }); + zipFile.once('error', reject); + zipFile.once('end', () => { + if (expectedRequirements && names.size !== expectedRequirements.size) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive has missing or extra entries', + ), + ); + } else { + resolve(requirements); + } + }); + }); + } + + private async extractArchive( + archivePath: string, + scratchPath: string, + requirements: Map, + ): Promise { + const zipFile = await openZip(archivePath, { + autoClose: false, + lazyEntries: true, + strictFileNames: true, + validateEntrySizes: true, + }); + return new Promise((resolve, reject) => { + const staged: IStagedEntry[] = []; + let settled = false; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + zipFile.close(); + reject(error); + }; + zipFile.on('error', fail); + zipFile.on('end', () => { + if (settled) return; + settled = true; + zipFile.close(); + if (staged.length !== requirements.size) { + reject( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive extraction is incomplete', + ), + ); + } else { + resolve(staged); + } + }); + zipFile.on('entry', (entry) => { + void (async () => { + const requirement = requirements.get(entry.fileName); + if (!requirement) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive contains an unexpected entry', + ); + } + const filePath = path.join(scratchPath, `${staged.length}.entry`); + const digest = createHash('sha256'); + let size = 0; + const verifier = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.byteLength; + if (size > requirement.expectedSize) { + callback( + new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry exceeds its expected size', + ), + ); + } else { + digest.update(chunk); + callback(null, chunk); + } + }, + }); + await pipeline( + await openZipEntry(zipFile, entry), + verifier, + createWriteStream(filePath, { flags: 'wx' }), + ); + const expectedSha256 = requirement.expectedSha256?.toLowerCase(); + const sha256 = digest.digest('hex'); + if ( + size !== requirement.expectedSize || + (expectedSha256 !== undefined && sha256 !== expectedSha256) + ) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_ARCHIVE_INVALID', + 'Firmware archive entry integrity is invalid', + ); + } + staged.push({ + entryName: entry.fileName, + size, + sha256, + filePath, + }); + zipFile.readEntry(); + })().catch(fail); + }); + zipFile.readEntry(); + }); + } + + private artifactPath(sha256: string): string { + return path.join(this.rootPath, `${sha256}.bin`); + } + + private async assertLease(leaseRef: string): Promise { + if (!this.leases.has(this.validateLeaseRef(leaseRef))) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + } + + private async assertLeaseTransaction( + leaseRef: string, + transactionId: string, + ): Promise { + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease || lease.transactionId !== transactionId) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_MISMATCH', + 'Firmware artifact lease transaction does not match', + ); + } + } + + private async promoteArtifact({ + sourcePath, + size, + sha256, + }: { + sourcePath: string; + size: number; + sha256: string; + }): Promise { + const previous = this.promotions.get(sha256) ?? Promise.resolve(); + const promotion = previous + .catch(() => undefined) + .then(async () => { + const destination = this.artifactPath(sha256); + if (await this.isStoredArtifactValid(destination, size, sha256)) { + await rm(sourcePath, { force: true }); + return; + } + await rm(destination, { force: true }); + await rename(sourcePath, destination); + }); + this.promotions.set(sha256, promotion); + try { + await promotion; + } finally { + if (this.promotions.get(sha256) === promotion) { + this.promotions.delete(sha256); + } + } + } + + private async retainExpected({ + leaseRef, + transactionId, + artifactRef, + }: { + leaseRef: string; + transactionId?: string; + artifactRef: string; + }): Promise { + if (!ARTIFACT_REF_PATTERN.test(artifactRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifactRef is invalid', + ); + } + const lease = this.leases.get(this.validateLeaseRef(leaseRef)); + if (!lease) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_UNAVAILABLE', + 'Firmware artifact lease is unavailable', + ); + } + if (transactionId && lease.transactionId !== transactionId) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_LEASE_MISMATCH', + 'Firmware artifact lease transaction does not match', + ); + } + lease.artifactRefs.add(artifactRef); + } + + private validateLeaseRef(leaseRef: string): string { + if (!LEASE_REF_PATTERN.test(leaseRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware leaseRef is invalid', + ); + } + return leaseRef; + } + + private async resolveArtifactPath(artifactRef: string): Promise { + if (!ARTIFACT_REF_PATTERN.test(artifactRef)) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INVALID_INPUT', + 'Firmware artifactRef is invalid', + ); + } + const sha256 = artifactRef.slice(3); + const filePath = this.artifactPath(sha256); + if ((await hashFile(filePath).catch(() => '')) !== sha256) { + throw new FirmwareArtifactDesktopError( + 'ARTIFACT_INTEGRITY_FAILED', + 'Firmware artifact is missing or corrupt', + ); + } + return filePath; + } + + private async isStoredArtifactValid( + filePath: string, + expectedSize: number | undefined, + expectedSha256: string | undefined, + maxBytes = MAX_ARTIFACT_BYTES, + ): Promise { + try { + const fileStat = await stat(filePath); + return ( + fileStat.isFile() && + fileStat.size > 0 && + fileStat.size <= maxBytes && + (expectedSize === undefined || fileStat.size === expectedSize) && + (expectedSha256 === undefined || + (await hashFile(filePath)) === expectedSha256) + ); + } catch { + return false; + } + } +} + +export default DesktopApiFirmwareArtifact; diff --git a/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts b/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts new file mode 100644 index 000000000000..cac1f13422bd --- /dev/null +++ b/packages/kit-bg/src/desktopApis/FirmwareArtifactDesktopError.ts @@ -0,0 +1,8 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +export class FirmwareArtifactDesktopError extends OneKeyLocalError { + constructor(code: string, message: string, options?: ErrorOptions) { + super(`${code}: ${message}`); + if (options?.cause !== undefined) this.cause = options.cause; + } +} diff --git a/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts b/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts index 5b90c1d5a6a3..929436b6d17f 100644 --- a/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts +++ b/packages/kit-bg/src/desktopApis/instance/IDesktopApi.ts @@ -4,6 +4,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -21,6 +22,7 @@ export interface IDesktopApi { webview: DesktopApiWebview; notification: DesktopApiNotification; dev: DesktopApiDev; + firmwareArtifact: DesktopApiFirmwareArtifact; inAppPurchase: DesktopApiInAppPurchase; bluetooth: DesktopApiBluetooth; appUpdate: DesktopApiAppUpdate; diff --git a/packages/kit-bg/src/desktopApis/instance/desktopApi.ts b/packages/kit-bg/src/desktopApis/instance/desktopApi.ts index 6c06bc60909c..5e2f798cda38 100644 --- a/packages/kit-bg/src/desktopApis/instance/desktopApi.ts +++ b/packages/kit-bg/src/desktopApis/instance/desktopApi.ts @@ -16,6 +16,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -115,6 +116,14 @@ class DesktopApi implements IDesktopApi { ); } + get firmwareArtifact(): DesktopApiFirmwareArtifact { + return this.getOrCreateModule('firmwareArtifact', () => + this.createModule( + require('../DesktopApiFirmwareArtifact') as typeof import('../DesktopApiFirmwareArtifact'), + ), + ); + } + get bluetooth(): DesktopApiBluetooth { return this.getOrCreateModule('bluetooth', () => this.createModule( diff --git a/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts b/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts index 12f1bfe97d95..573a0ac12f13 100644 --- a/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts +++ b/packages/kit-bg/src/desktopApis/instance/desktopApiProxy.ts @@ -12,6 +12,7 @@ import type DesktopApiBluetooth from '../DesktopApiBluetooth'; import type DesktopApiBundleUpdate from '../DesktopApiBundleUpdate'; import type DesktopApiCloudKit from '../DesktopApiCloudKit'; import type DesktopApiDev from '../DesktopApiDev'; +import type DesktopApiFirmwareArtifact from '../DesktopApiFirmwareArtifact'; import type DesktopApiInAppPurchase from '../DesktopApiInAppPurchase'; import type DesktopApiKeychain from '../DesktopApiKeychain'; import type DesktopApiNotification from '../DesktopApiNotification'; @@ -73,6 +74,9 @@ export class DesktopApiProxy extends RemoteApiProxyBase implements IDesktopApi { dev: DesktopApiDev = this._createProxyModule('dev'); + firmwareArtifact: DesktopApiFirmwareArtifact = + this._createProxyModule('firmwareArtifact'); + inAppPurchase: DesktopApiInAppPurchase = this._createProxyModule('inAppPurchase'); diff --git a/packages/kit-bg/src/services/ServiceBootstrap.ts b/packages/kit-bg/src/services/ServiceBootstrap.ts index 23fb56be9298..e1e58c9e67e1 100644 --- a/packages/kit-bg/src/services/ServiceBootstrap.ts +++ b/packages/kit-bg/src/services/ServiceBootstrap.ts @@ -19,6 +19,18 @@ class ServiceBootstrap extends ServiceBase { public async init() { await this.initCritical(); + if (platformEnv.isNative || platformEnv.isDesktop) { + void import('./ServiceFirmwareUpdate/FirmwareUpdateRuntime') + .then(({ firmwareArtifactAdapter }) => + firmwareArtifactAdapter.sweepOrphans(), + ) + .catch(() => { + defaultLogger.app.bootstrap.initCriticalStep( + 'firmwareArtifactOrphanSweep (FAILED)', + 0, + ); + }); + } if (platformEnv.isWeb || platformEnv.isDesktop) { setTimeout(() => { void this.initDeferred(); diff --git a/packages/kit-bg/src/services/ServiceDevSetting.ts b/packages/kit-bg/src/services/ServiceDevSetting.ts index 0f65c28f0ac6..9630b71f6f22 100644 --- a/packages/kit-bg/src/services/ServiceDevSetting.ts +++ b/packages/kit-bg/src/services/ServiceDevSetting.ts @@ -316,6 +316,17 @@ class ServiceDevSetting extends ServiceBase { return fwDev[key]; } + @backgroundMethod() + public async getFirmwareUpdateDevSettingsSnapshot(): Promise< + IFirmwareUpdateDevSettings | undefined + > { + const dev = await devSettingsPersistAtom.get(); + if (!dev.enabled) { + return undefined; + } + return firmwareUpdateDevSettingsPersistAtom.get(); + } + @backgroundMethod() public async updateFirmwareUpdateDevSettings( values: Partial, diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts new file mode 100644 index 000000000000..9b329bdf977b --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.desktop.ts @@ -0,0 +1,31 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const getDesktopAdapter = (): IFirmwareArtifactAdapter => { + const adapter = ( + globalThis.desktopApiProxy as unknown as { + firmwareArtifact?: IFirmwareArtifactAdapter; + } + ).firmwareArtifact; + if (!adapter) { + throw new OneKeyLocalError('Desktop firmware artifact API is unavailable'); + } + return adapter; +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities: () => getDesktopAdapter().getCapabilities(), + download: (input) => getDesktopAdapter().download(input), + cancelDownloads: (transactionId) => + getDesktopAdapter().cancelDownloads(transactionId), + materialize: (input) => getDesktopAdapter().materialize(input), + open: (artifactRef) => getDesktopAdapter().open(artifactRef), + read: (input) => getDesktopAdapter().read(input), + close: (readerId) => getDesktopAdapter().close(readerId), + createLease: (transactionId) => + getDesktopAdapter().createLease(transactionId), + retain: (input) => getDesktopAdapter().retain(input), + releaseLease: (input) => getDesktopAdapter().releaseLease(input), + sweepOrphans: () => getDesktopAdapter().sweepOrphans(), +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts new file mode 100644 index 000000000000..66a315e9c28d --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.native.ts @@ -0,0 +1,94 @@ +import { ReactNativeRangeDownloader } from '@onekeyfe/react-native-range-downloader'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const assertBackgroundRuntime = () => { + if ( + !platformEnv.isJest && + (!platformEnv.isNative || platformEnv.isNativeMainThread) + ) { + throw new OneKeyLocalError( + 'Firmware artifacts are only available in the native background runtime', + ); + } +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities() { + assertBackgroundRuntime(); + const capabilities = + ReactNativeRangeDownloader.getFirmwareArtifactCapabilities(); + return { + ...capabilities, + supportedRouteTypes: ['domain'], + }; + }, + async download(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.downloadFirmwareArtifact({ + taskId: input.taskId, + transactionId: input.transactionId, + leaseRef: input.leaseRef, + artifactId: input.artifactId, + url: input.url, + routeType: 'domain', + resolvedIp: undefined, + ...(input.expectedSize !== undefined + ? { expectedSize: input.expectedSize } + : {}), + ...(input.expectedSha256 ? { expectedSha256: input.expectedSha256 } : {}), + maxBytes: input.maxBytes, + overallDeadlineSeconds: input.overallDeadlineSeconds, + }); + }, + cancelDownloads(transactionId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.cancelFirmwareArtifactDownloads({ + transactionId, + }); + }, + async materialize(input) { + assertBackgroundRuntime(); + const result = await ReactNativeRangeDownloader.materializeFirmwareArchive({ + leaseRef: input.leaseRef, + archiveArtifactRef: input.archiveArtifactRef, + ...(input.expectedEntries + ? { expectedEntries: [...input.expectedEntries] } + : {}), + }); + return result.artifacts; + }, + open(artifactRef) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.openFirmwareArtifact({ artifactRef }); + }, + read(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.readFirmwareArtifact(input); + }, + close(readerId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.closeFirmwareArtifact({ readerId }); + }, + createLease(transactionId) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.createFirmwareArtifactLease({ + transactionId, + }); + }, + retain(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.retainFirmwareArtifact(input); + }, + releaseLease(input) { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.releaseFirmwareArtifactLease(input); + }, + sweepOrphans() { + assertBackgroundRuntime(); + return ReactNativeRangeDownloader.sweepFirmwareArtifactOrphans(); + }, +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts new file mode 100644 index 000000000000..8594d3683c21 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.ts @@ -0,0 +1,23 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import type { IFirmwareArtifactAdapter } from './FirmwareArtifactAdapter.types'; + +const unavailable = (): never => { + throw new OneKeyLocalError( + 'External firmware artifacts are not used on this platform', + ); +}; + +export const firmwareArtifactAdapter: IFirmwareArtifactAdapter = { + getCapabilities: unavailable, + download: unavailable, + cancelDownloads: unavailable, + materialize: unavailable, + open: unavailable, + read: unavailable, + close: unavailable, + createLease: unavailable, + retain: unavailable, + releaseLease: unavailable, + sweepOrphans: unavailable, +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts new file mode 100644 index 000000000000..31d0c3fe50d5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactAdapter.types.ts @@ -0,0 +1,72 @@ +export type IFirmwareArchiveExpectedEntry = { + artifactId: string; + entryName: string; + expectedSize: number; + expectedSha256: string; +}; + +export type IFirmwareArtifactRoute = { + routeType: 'domain'; +}; + +export type IFirmwareArtifactReceipt = { + artifactRef: string; + size: number; + sha256: string; + expectedSha256Verified: boolean; +}; + +export type IFirmwareArtifactLeaseDisposition = + | 'completed' + | 'safeCancelled' + | 'safeAbandoned'; + +export type IFirmwareMaterializedArtifact = { + entryName: string; + receipt: IFirmwareArtifactReceipt; +}; + +export type IFirmwareArtifactCapabilities = { + firmwareArtifactProtocolVersion: number; + supportedRouteTypes: string[]; + supportsArchiveMaterialization: boolean; + maxReadBytes: number; +}; + +export interface IFirmwareArtifactAdapter { + getCapabilities(): + | IFirmwareArtifactCapabilities + | Promise; + download(input: { + taskId: string; + transactionId: string; + leaseRef: string; + artifactId: string; + url: string; + route: IFirmwareArtifactRoute; + expectedSize?: number; + expectedSha256?: string; + maxBytes: number; + overallDeadlineSeconds: number; + }): Promise; + cancelDownloads(transactionId: string): Promise; + materialize(input: { + leaseRef: string; + archiveArtifactRef: string; + expectedEntries?: readonly IFirmwareArchiveExpectedEntry[]; + }): Promise; + open(artifactRef: string): Promise<{ readerId: string; size: number }>; + read(input: { + readerId: string; + offset: number; + length: number; + }): Promise; + close(readerId: string): Promise; + createLease(transactionId: string): Promise<{ leaseRef: string }>; + retain(input: { leaseRef: string; artifactRef: string }): Promise; + releaseLease(input: { + leaseRef: string; + disposition: IFirmwareArtifactLeaseDisposition; + }): Promise; + sweepOrphans(): Promise<{ deletedFiles: number; deletedBytes: number }>; +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts new file mode 100644 index 000000000000..7387e4cd43fa --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactPreflight.ts @@ -0,0 +1,681 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { IFirmwareArtifactReceipt } from './FirmwareArtifactAdapter.types'; +import type { + CoreApi, + FirmwareArtifactReader, + FirmwareArtifactReference, + FirmwareUpdateCapabilities, + FirmwareUpdatePlan, + FirmwareUpdatePlanTarget, + FirmwareUpdatePreparedPlan, + FirmwareUpdateV4Target, +} from '@onekeyfe/hd-core'; + +const MAX_READ_BYTES = 256 * 1024; +const MAX_BRIDGE_BINARY_BYTES = 4 * 1024 * 1024; +const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024; +const TOTAL_DEADLINE_MS = 30 * 60 * 1000; +const NATIVE_ARTIFACT_STAGE_TIMEOUT_MS = 15_000; +const activeArtifactDownloadCounts = new Map(); +const FIRMWARE_CAPABILITY_KEYS = [ + 'planSchemaVersion', + 'preparedPlanSchemaVersion', + 'hostBindingProtocolVersion', + 'manifestModes', + 'supportsArtifactReader', +] as const; + +export type IFirmwareDownloadArtifact = { + url: string; + role: FirmwareUpdatePlan['artifacts'][number]['role']; + logicalName?: string; + expectedSize?: number; + expectedSha256?: string; + container: FirmwareUpdatePlan['artifacts'][number]['container']; +}; + +export const withFirmwareArtifactStageTimeout = async ( + stage: string, + operation: Promise, +): Promise => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new OneKeyLocalError(`ARTIFACT_${stage}_TIMEOUT`)); + }, NATIVE_ARTIFACT_STAGE_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +}; + +export const isExternalFirmwareCapabilityReady = ( + value: unknown, +): value is FirmwareUpdateCapabilities => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length !== FIRMWARE_CAPABILITY_KEYS.length || + FIRMWARE_CAPABILITY_KEYS.some( + (key) => !Object.prototype.hasOwnProperty.call(value, key), + ) + ) { + return false; + } + const capabilities = value as Record; + if ( + capabilities.planSchemaVersion !== 2 || + capabilities.preparedPlanSchemaVersion !== 2 || + capabilities.hostBindingProtocolVersion !== 2 || + capabilities.supportsArtifactReader !== true || + !Array.isArray(capabilities.manifestModes) || + capabilities.manifestModes.length !== 2 + ) { + return false; + } + const modes = new Set(capabilities.manifestModes); + return ( + modes.size === 2 && modes.has('external-only') && modes.has('sdk-managed') + ); +}; + +export type IFirmwareArtifactReference = FirmwareArtifactReference; +type IFirmwareArtifactReader = FirmwareArtifactReader; + +export type IPreparedFirmwareArtifacts = { + transactionId: string; + leaseRef: string; + plan: FirmwareUpdatePlan; + preparedPlan: FirmwareUpdatePreparedPlan; + artifactsById: Readonly>; + selected: { + firmware?: IFirmwareArtifactReference; + ble?: IFirmwareArtifactReference; + bootloader?: IFirmwareArtifactReference; + resourceEntries?: { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + componentArtifacts: Partial< + Record< + Exclude, + IFirmwareArtifactReference + > + >; + resourceBundleArtifacts: { + name: string; + artifact: IFirmwareArtifactReference; + }[]; + }; + artifactReader: IFirmwareArtifactReader; +}; + +type IBridgeBinaryTarget = Exclude; + +export type IBridgeFirmwareBinaries = { + transactionId: string; + executor: FirmwareUpdatePlan['executor']; + planDigest: string; + targetBinaries: Partial>; +}; + +const assertReceipt = ( + receipt: IFirmwareArtifactReceipt, + artifact: IFirmwareDownloadArtifact, +): IFirmwareArtifactReference => { + if ( + !Number.isSafeInteger(receipt.size) || + receipt.size <= 0 || + receipt.size > (artifact.expectedSize ?? MAX_ARTIFACT_BYTES) || + !/^[a-f0-9]{64}$/iu.test(receipt.sha256) || + receipt.artifactRef !== `fw:${receipt.sha256.toLowerCase()}` || + receipt.expectedSha256Verified !== + (artifact.expectedSha256 !== undefined) || + (artifact.expectedSize !== undefined && + receipt.size !== artifact.expectedSize) || + (artifact.expectedSha256 !== undefined && + receipt.sha256.toLowerCase() !== artifact.expectedSha256.toLowerCase()) + ) { + throw new OneKeyLocalError( + 'Firmware artifact receipt does not match the update plan', + ); + } + return receipt; +}; + +const trackArtifactDownload = async ( + transactionId: string, + operation: () => Promise, +): Promise => { + activeArtifactDownloadCounts.set( + transactionId, + (activeArtifactDownloadCounts.get(transactionId) ?? 0) + 1, + ); + try { + return await operation(); + } finally { + const remaining = + (activeArtifactDownloadCounts.get(transactionId) ?? 1) - 1; + if (remaining > 0) { + activeArtifactDownloadCounts.set(transactionId, remaining); + } else { + activeArtifactDownloadCounts.delete(transactionId); + } + } +}; + +export const downloadFirmwareArtifact = async ({ + artifact, + artifactId, + taskId, + transactionId, + leaseRef, + deadlineAt, +}: { + artifact: IFirmwareDownloadArtifact; + artifactId: string; + taskId: string; + transactionId: string; + leaseRef: string; + deadlineAt: number; +}): Promise => + trackArtifactDownload(transactionId, async () => { + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + throw new OneKeyLocalError( + 'Firmware artifact preparation exceeded its deadline', + ); + } + const startedAt = Date.now(); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-start', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + }); + try { + const receipt = await firmwareArtifactAdapter.download({ + taskId, + transactionId, + leaseRef, + artifactId, + url: artifact.url, + route: { routeType: 'domain' }, + ...(artifact.expectedSize !== undefined + ? { expectedSize: artifact.expectedSize } + : {}), + ...(artifact.expectedSha256 + ? { expectedSha256: artifact.expectedSha256 } + : {}), + maxBytes: artifact.expectedSize ?? MAX_ARTIFACT_BYTES, + overallDeadlineSeconds: remainingMs / 1000, + }); + const verifiedReceipt = assertReceipt(receipt, artifact); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-complete', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + durationMs: Date.now() - startedAt, + }); + return verifiedReceipt; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-download-failed', + artifactId, + artifactRole: artifact.role, + expectedBytes: artifact.expectedSize, + durationMs: Date.now() - startedAt, + errorCode: + errorMessage.match(/ARTIFACT_[A-Z0-9_]+/)?.[0] ?? + (error instanceof Error ? error.name : 'UnknownError'), + }); + throw error; + } + }); + +export const cancelFirmwareArtifactPreparations = async (): Promise => { + await Promise.all( + [...activeArtifactDownloadCounts.keys()].map((transactionId) => + firmwareArtifactAdapter.cancelDownloads(transactionId), + ), + ); +}; + +const createArtifactReader = (): IFirmwareArtifactReader => { + const sizesByReaderId = new Map(); + return { + async open({ artifactRef }) { + const openOperation = firmwareArtifactAdapter.open(artifactRef); + let opened: Awaited; + try { + opened = await withFirmwareArtifactStageTimeout( + 'READER_OPEN', + openOperation, + ); + } catch (error) { + void openOperation + .then(({ readerId }) => firmwareArtifactAdapter.close(readerId)) + .catch(() => undefined); + throw error; + } + sizesByReaderId.set(opened.readerId, opened.size); + return opened; + }, + async read({ readerId, offset, length }) { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length <= 0 || + length > MAX_READ_BYTES + ) { + throw new OneKeyLocalError('Invalid firmware artifact reader request'); + } + const size = sizesByReaderId.get(readerId); + if (size === undefined || offset + length > size) { + throw new OneKeyLocalError('Firmware artifact reader is out of bounds'); + } + const data = await withFirmwareArtifactStageTimeout( + 'READER_READ', + firmwareArtifactAdapter.read({ + readerId, + offset, + length, + }), + ); + return { + data, + bytesRead: data.byteLength, + eof: offset + data.byteLength === size, + }; + }, + async close({ readerId }) { + sizesByReaderId.delete(readerId); + await withFirmwareArtifactStageTimeout( + 'READER_CLOSE', + firmwareArtifactAdapter.close(readerId), + ); + }, + }; +}; + +export const resolveFirmwarePlanArtifact = ( + planArtifact: FirmwareUpdatePlan['artifacts'][number], +): IFirmwareDownloadArtifact => { + if (planArtifact.container !== 'raw' && planArtifact.container !== 'zip') { + throw new OneKeyLocalError( + 'Firmware update plan contains an unsupported artifact container', + ); + } + const expectedSize = + Number.isSafeInteger(planArtifact.expectedSize) && + (planArtifact.expectedSize ?? 0) > 0 + ? planArtifact.expectedSize + : undefined; + const expectedSha256 = + typeof planArtifact.expectedSha256 === 'string' && + /^[a-f0-9]{64}$/iu.test(planArtifact.expectedSha256) + ? planArtifact.expectedSha256.toLowerCase() + : undefined; + return { + url: planArtifact.url, + role: planArtifact.role, + ...(planArtifact.logicalName + ? { logicalName: planArtifact.logicalName } + : {}), + ...(expectedSize !== undefined ? { expectedSize } : {}), + ...(expectedSha256 ? { expectedSha256 } : {}), + container: planArtifact.container, + }; +}; + +const readFirmwareArtifact = async ( + artifact: IFirmwareArtifactReference, +): Promise => { + if ( + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 || + artifact.size > MAX_BRIDGE_BINARY_BYTES + ) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary exceeds the in-memory limit', + ); + } + const opened = await firmwareArtifactAdapter.open(artifact.artifactRef); + try { + if (opened.size !== artifact.size) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary size is invalid', + ); + } + const result = new Uint8Array(artifact.size); + let offset = 0; + while (offset < artifact.size) { + const length = Math.min(MAX_READ_BYTES, artifact.size - offset); + const chunk = await firmwareArtifactAdapter.read({ + readerId: opened.readerId, + offset, + length, + }); + if (chunk.byteLength !== length) { + throw new OneKeyLocalError( + 'Desktop Bridge firmware binary read is incomplete', + ); + } + result.set(new Uint8Array(chunk), offset); + offset += length; + } + return result.buffer; + } finally { + await firmwareArtifactAdapter.close(opened.readerId); + } +}; + +const getBridgeBinaryPlanArtifacts = ( + plan: FirmwareUpdatePlan, +): FirmwareUpdatePlan['artifacts'] => { + if (plan.executor === 'v4') { + const components = plan.artifacts.filter( + (artifact) => artifact.role === 'component', + ); + return components.length > 0 && + components.every((artifact) => artifact.container === 'raw') + ? components + : []; + } + return plan.artifacts.filter( + (artifact) => + (artifact.role === 'firmware' || + artifact.role === 'ble' || + artifact.role === 'bootloader') && + artifact.container === 'raw', + ); +}; + +export async function prepareBridgeFirmwareBinaries( + plan: FirmwareUpdatePlan, + requestedTransactionId?: string, +): Promise { + if (!(await isFirmwareArtifactCapabilityReady())) { + throw new OneKeyLocalError( + 'Installed firmware artifact module is incompatible', + ); + } + const planArtifacts = getBridgeBinaryPlanArtifacts(plan); + if (!planArtifacts.length) return undefined; + + const transactionId = + requestedTransactionId ?? + `bridge:${plan.planDigest.slice(0, 32)}:${Date.now()}`; + const { leaseRef } = await firmwareArtifactAdapter.createLease(transactionId); + let completed = false; + try { + const targetBinaries: IBridgeFirmwareBinaries['targetBinaries'] = {}; + const deadlineAt = Date.now() + TOTAL_DEADLINE_MS; + for (const [index, planArtifact] of planArtifacts.entries()) { + const artifact = resolveFirmwarePlanArtifact(planArtifact); + const receipt = await downloadFirmwareArtifact({ + artifact, + artifactId: planArtifact.artifactId, + taskId: `fw-${plan.planDigest.slice(0, 24)}-${index}`, + transactionId, + 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; + return { + transactionId, + executor: plan.executor, + planDigest: plan.planDigest, + targetBinaries, + }; + } finally { + await firmwareArtifactAdapter.releaseLease({ + leaseRef, + disposition: completed ? 'completed' : 'safeCancelled', + }); + } +} + +export const getBridgeFirmwareV3BinaryParams = ( + binaries: IBridgeFirmwareBinaries | undefined, +) => ({ + ...(binaries?.targetBinaries.firmware + ? { firmwareBinary: binaries.targetBinaries.firmware } + : {}), + ...(binaries?.targetBinaries.ble + ? { bleBinary: binaries.targetBinaries.ble } + : {}), + ...(binaries?.targetBinaries.bootloader + ? { bootloaderBinary: binaries.targetBinaries.bootloader } + : {}), +}); + +export const getBridgeFirmwareV4BinaryParams = ( + binaries: IBridgeFirmwareBinaries | undefined, +) => ({ + ...(binaries?.targetBinaries.boot + ? { bootloaderBinary: binaries.targetBinaries.boot } + : {}), + ...(binaries?.targetBinaries.app_v1 + ? { applicationP1Binary: binaries.targetBinaries.app_v1 } + : {}), + ...(binaries?.targetBinaries.app_v2 + ? { applicationP2Binary: binaries.targetBinaries.app_v2 } + : {}), + ...(binaries?.targetBinaries.coprocessor + ? { coprocessorBinary: binaries.targetBinaries.coprocessor } + : {}), + ...(binaries?.targetBinaries.se01 + ? { se01Binary: binaries.targetBinaries.se01 } + : {}), + ...(binaries?.targetBinaries.se02 + ? { se02Binary: binaries.targetBinaries.se02 } + : {}), + ...(binaries?.targetBinaries.se03 + ? { se03Binary: binaries.targetBinaries.se03 } + : {}), + ...(binaries?.targetBinaries.se04 + ? { se04Binary: binaries.targetBinaries.se04 } + : {}), +}); + +export async function prepareFirmwareArtifacts( + plan: FirmwareUpdatePlan, + { + transactionId, + leaseRef: existingLeaseRef, + preparePlan, + }: { + transactionId: string; + leaseRef?: string; + preparePlan: CoreApi['prepareFirmwareUpdatePlan']; + }, +): Promise { + if (!(await isFirmwareArtifactCapabilityReady())) { + throw new OneKeyLocalError( + 'Installed firmware artifact module is incompatible', + ); + } + + const leaseRef = + existingLeaseRef ?? + (await firmwareArtifactAdapter.createLease(transactionId)).leaseRef; + const deadlineAt = Date.now() + TOTAL_DEADLINE_MS; + const artifactsById: Record = {}; + const resourceEntriesByArtifactId: Record< + string, + { + entryName: string; + artifact: IFirmwareArtifactReference; + }[] + > = {}; + + let cancellationRequested = false; + let preparationFailure: { reason: unknown } | undefined; + await Promise.allSettled( + plan.artifacts.map(async (planArtifact, index) => { + try { + const artifact = resolveFirmwarePlanArtifact(planArtifact); + const receipt = await downloadFirmwareArtifact({ + artifact, + artifactId: planArtifact.artifactId, + taskId: `fw-${plan.planDigest.slice(0, 24)}-${index}`, + transactionId, + leaseRef, + deadlineAt, + }); + artifactsById[planArtifact.artifactId] = receipt; + if (planArtifact.container === 'zip') { + const entries = await firmwareArtifactAdapter.materialize({ + leaseRef, + archiveArtifactRef: receipt.artifactRef, + }); + resourceEntriesByArtifactId[planArtifact.artifactId] = entries.map( + (entry) => ({ + entryName: entry.entryName, + artifact: entry.receipt, + }), + ); + } + } catch (error) { + if (!cancellationRequested) { + cancellationRequested = true; + preparationFailure = { reason: error }; + await firmwareArtifactAdapter + .cancelDownloads(transactionId) + .catch(() => undefined); + } + throw error; + } + }), + ); + if (preparationFailure) { + throw preparationFailure.reason; + } + + const componentArtifacts: IPreparedFirmwareArtifacts['selected']['componentArtifacts'] = + {}; + const resourceBundleArtifacts: IPreparedFirmwareArtifacts['selected']['resourceBundleArtifacts'] = + []; + for (const planArtifact of plan.artifacts) { + const artifact = artifactsById[planArtifact.artifactId]; + if (!artifact) { + throw new OneKeyLocalError('Firmware artifact preparation is incomplete'); + } + if (planArtifact.role === 'component') { + const target = planArtifact.target; + if ( + target === 'firmware' || + target === 'ble' || + target === 'bootloader' || + target === 'resource' + ) { + throw new OneKeyLocalError('Firmware component target is invalid'); + } + componentArtifacts[target] = artifact; + } + if (planArtifact.role === 'resourceBundle') { + if (!planArtifact.logicalName) { + throw new OneKeyLocalError( + 'Firmware resource bundle has no logical name', + ); + } + resourceBundleArtifacts.push({ + name: planArtifact.logicalName, + artifact, + }); + } + } + const preparedPlan = preparePlan({ + plan, + leaseRef, + artifacts: plan.artifacts.map((planArtifact) => { + const artifact = artifactsById[planArtifact.artifactId]; + if (!artifact) { + throw new OneKeyLocalError( + 'Firmware artifact preparation is incomplete', + ); + } + const materializedEntries = + resourceEntriesByArtifactId[planArtifact.artifactId]; + return { + artifactId: planArtifact.artifactId, + artifact, + ...(materializedEntries?.length ? { materializedEntries } : {}), + }; + }), + }); + + return { + transactionId, + leaseRef, + plan, + preparedPlan, + artifactsById, + selected: { + firmware: artifactsById.firmware, + ble: artifactsById.ble, + bootloader: artifactsById.bootloader, + resourceEntries: resourceEntriesByArtifactId.resource, + 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 new file mode 100644 index 000000000000..9c6e66a29bdf --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.test.ts @@ -0,0 +1,347 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +import { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, +} from './FirmwareArtifactSelfTest'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +import type { IPreparedFirmwareArtifacts } from './FirmwareArtifactPreflight'; +import type { + FirmwarePreparedArtifactController, + IFirmwarePreparedArtifactReleaseResult, +} from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePreparedPlan, + RemoteConfigResponse, +} from '@onekeyfe/hd-core'; + +jest.mock('./FirmwareManifestProvider', () => ({ + getFirmwareManifestSnapshot: jest.fn(), +})); + +const mockedGetFirmwareManifestSnapshot = jest.mocked( + getFirmwareManifestSnapshot, +); +const mockFirmwareSha256 = '1'.repeat(64); +const mockResourceSha256 = '2'.repeat(64); +const mockRemoteConfig = { + pro: { + firmware: [], + ble: [], + 'firmware-v8': [ + { + required: false, + version: [4, 21, 0], + url: 'https://firmware.example/pro.bin', + fingerprint: mockFirmwareSha256, + expectedSize: 1024 * 1024, + resource: 'https://firmware.example/pro-resources.zip', + resourceFingerprint: mockResourceSha256, + resourceExpectedSize: 512 * 1024, + fullResource: 'https://firmware.example/pro-full-resources.zip', + fullResourceFingerprint: '4'.repeat(64), + fullResourceExpectedSize: 2 * 1024 * 1024, + changelog: { 'en-US': '', 'zh-CN': '' }, + }, + ], + }, +} as unknown as RemoteConfigResponse; + +const createSdk = () => { + const prepareFirmwareUpdatePlan = jest.fn( + ({ plan }: Parameters[0]) => + ({ + preparedPlanDigest: 'd'.repeat(64), + planDigest: plan.planDigest, + }) as FirmwareUpdatePreparedPlan, + ); + const firmwareUpdateV3 = jest.fn(async () => ({ + success: false as const, + payload: { code: 'DeviceNotFound', error: 'Device not found' }, + })); + const sdk = { + getFirmwareUpdateCapabilities: () => ({ + planSchemaVersion: 2 as const, + preparedPlanSchemaVersion: 2 as const, + hostBindingProtocolVersion: 2 as const, + manifestModes: ['external-only', 'sdk-managed'] as const, + supportsArtifactReader: true as const, + }), + prepareFirmwareUpdatePlan, + validateFirmwareUpdatePreparedPlan: (plan: FirmwareUpdatePreparedPlan) => + plan, + registerFirmwareUpdateHostBinding: jest.fn(() => 7), + unregisterFirmwareUpdateHostBinding: jest.fn(() => true), + firmwareUpdateV3, + } as unknown as CoreApi; + return { sdk, firmwareUpdateV3, prepareFirmwareUpdatePlan }; +}; + +const createController = async ({ + scenario, + read, + getReleaseResult, +}: { + scenario: 'pro-firmware' | 'pro-resource'; + read?: IPreparedFirmwareArtifacts['artifactReader']['read']; + getReleaseResult?: ( + transactionId: string, + ) => IFirmwarePreparedArtifactReleaseResult; +}) => { + const { artifact, artifactId } = + await getFirmwareArtifactSelfTestArtifact(scenario); + const expectedSize = artifact.expectedSize; + const expectedSha256 = artifact.expectedSha256; + if (expectedSize === undefined || expectedSha256 === undefined) { + throw new OneKeyLocalError('Test fixture integrity metadata is missing'); + } + const open = jest.fn(async () => ({ + readerId: 'reader-1', + size: expectedSize, + })); + const readImpl: IPreparedFirmwareArtifacts['artifactReader']['read'] = + read ?? + (async ({ offset, length }) => ({ + data: new ArrayBuffer(length), + bytesRead: length, + eof: offset + length === artifact.expectedSize, + })); + const readMock = jest.fn(readImpl); + const close = jest.fn(async () => undefined); + const dispositions: ('completed' | 'safeCancelled')[] = []; + const sweepOrphanedArtifacts = jest.fn(async () => ({ + deletedFiles: 2, + deletedBytes: 4096, + })); + + const withPreparedPlanArtifacts: FirmwarePreparedArtifactController['withPreparedPlanArtifacts'] = + async ( + { + plan, + sdk, + transactionId = 'fwtx:test', + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }, + execute: (prepared: IPreparedFirmwareArtifacts) => Promise, + onReleased?: (result: IFirmwarePreparedArtifactReleaseResult) => void, + ): Promise => { + // cspell:disable-next-line + const leaseRef = `fwlease:${transactionId}`; + const receipt = { + artifactRef: `fw:${expectedSha256}`, + size: expectedSize, + sha256: expectedSha256, + }; + const preparedPlan = sdk.prepareFirmwareUpdatePlan({ + plan, + leaseRef, + artifacts: [ + { + artifactId, + artifact: receipt, + }, + ], + }); + const prepared = { + transactionId, + leaseRef, + plan, + preparedPlan, + artifactsById: { [artifactId]: receipt }, + selected: { + ...(artifactId === 'firmware' ? { firmware: receipt } : {}), + componentArtifacts: {}, + resourceBundleArtifacts: [], + }, + artifactReader: { + open, + read: readMock, + close, + }, + } as IPreparedFirmwareArtifacts; + + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(prepared); + disposition = 'completed'; + return result; + } finally { + dispositions.push(disposition); + onReleased?.( + getReleaseResult?.(transactionId) ?? { + hostBindingReleased: true, + leaseReleased: true, + }, + ); + } + }; + + const controller = { + withPreparedPlanArtifacts: jest.fn(withPreparedPlanArtifacts), + getExecutionArtifacts: jest.fn( + (preparedArtifacts: IPreparedFirmwareArtifacts) => ({ + preparedArtifacts, + hostBindingGeneration: 7, + }), + ), + sweepOrphanedArtifacts, + } as unknown as Pick< + FirmwarePreparedArtifactController, + | 'getExecutionArtifacts' + | 'sweepOrphanedArtifacts' + | 'withPreparedPlanArtifacts' + >; + return { + artifact, + close, + controller, + dispositions, + open, + read: readMock, + sweepOrphanedArtifacts, + }; +}; + +describe('FirmwareArtifactSelfTest', () => { + beforeEach(() => { + mockedGetFirmwareManifestSnapshot.mockResolvedValue(mockRemoteConfig); + }); + + it('runs the production firmware handoff and 50 cached preflight cycles', async () => { + const fixture = await createController({ scenario: 'pro-firmware' }); + const sdkFixture = createSdk(); + const progress = jest.fn(); + + const result = await executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-firmware', + sdk: sdkFixture.sdk, + controller: fixture.controller, + onProgress: progress, + }); + + expect(result.bytesRead).toBe(fixture.artifact.expectedSize); + expect(result.chunkCount).toBeGreaterThan(1); + expect(result.preflightCompletedIterations).toBe(50); + expect(result).toEqual( + expect.objectContaining({ + preparedPlanValidated: true, + sdkHandoffValidated: true, + cleanupValidated: true, + failureCleanupValidated: true, + sdkBoundaryCode: 'DeviceNotFound', + deletedFiles: 2, + deletedBytes: 4096, + }), + ); + expect(fixture.controller.withPreparedPlanArtifacts).toHaveBeenCalledTimes( + 52, + ); + expect(fixture.open).toHaveBeenCalledTimes(51); + expect(fixture.close).toHaveBeenCalledTimes(51); + expect(fixture.dispositions).toEqual([ + ...Array.from({ length: 51 }, () => 'completed' as const), + 'safeCancelled', + ]); + expect(sdkFixture.firmwareUpdateV3).toHaveBeenCalledTimes(1); + expect(sdkFixture.firmwareUpdateV3).toHaveBeenCalledWith( + '__firmware_sdk_self_test_no_device__', + expect.objectContaining({ + preparedPlan: expect.any(Object), + platform: expect.any(String), + firmwareVersion: [4, 21, 0], + hostBindingGeneration: 7, + artifacts: expect.objectContaining({ + firmware: expect.objectContaining({ + size: fixture.artifact.expectedSize, + }), + }), + }), + ); + expect(progress).toHaveBeenLastCalledWith( + expect.objectContaining({ phase: 'sweeping', progress: 99 }), + ); + }); + + it('does not require archive metadata that is absent from config.json', async () => { + const result = await getFirmwareArtifactSelfTestArtifact('pro-resource'); + + expect(result).toEqual( + expect.objectContaining({ + artifactId: 'resource', + artifact: expect.objectContaining({ + container: 'zip', + url: 'https://firmware.example/pro-resources.zip', + }), + }), + ); + expect(result.artifact).not.toHaveProperty('expectedEntries'); + }); + + it('closes the production reader and releases with safeCancelled on failure', async () => { + const fixture = await createController({ + scenario: 'pro-firmware', + read: async ({ length }) => ({ + data: new ArrayBuffer(length - 1), + bytesRead: length - 1, + eof: false, + }), + }); + const { sdk } = createSdk(); + + await expect( + executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-reader', + sdk, + controller: fixture.controller, + onProgress: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_READER_CHUNK_INVALID'); + + expect(fixture.close).toHaveBeenCalledWith({ readerId: 'reader-1' }); + expect(fixture.dispositions).toEqual(['safeCancelled']); + expect(fixture.sweepOrphanedArtifacts).not.toHaveBeenCalled(); + }); + + it('fails if the controlled production cleanup does not release the lease', async () => { + const fixture = await createController({ + scenario: 'pro-firmware', + getReleaseResult: (transactionId) => ({ + hostBindingReleased: true, + leaseReleased: !transactionId.endsWith(':failure-cleanup'), + }), + }); + const { sdk } = createSdk(); + + await expect( + executeFirmwareArtifactSelfTest({ + scenario: 'pro-firmware', + transactionId: 'fwtx:test-cleanup', + sdk, + controller: fixture.controller, + onProgress: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_FAILURE_CLEANUP_FAILED'); + }); + + it('exposes only stable error codes', () => { + expect( + getFirmwareArtifactSelfTestErrorCode( + new OneKeyLocalError('ARTIFACT_HTTP_503: unavailable'), + ), + ).toBe('ARTIFACT_HTTP_503'); + expect( + getFirmwareArtifactSelfTestErrorCode( + new Error('https://secret.example/path failed'), + ), + ).toBe('SELF_TEST_FAILED'); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts new file mode 100644 index 000000000000..2af52133bb1a --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest.ts @@ -0,0 +1,590 @@ +import { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; + +import appCrypto from '@onekeyhq/shared/src/appCrypto'; +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; +import stringUtils from '@onekeyhq/shared/src/utils/stringUtils'; + +import { + isExternalFirmwareCapabilityReady, + resolveFirmwarePlanArtifact, +} from './FirmwareArtifactPreflight'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; +import { executePreparedFirmwareUpdateV3 } from './FirmwarePreparedExecution'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { + IFirmwareDownloadArtifact, + IPreparedFirmwareArtifacts, +} from './FirmwareArtifactPreflight'; +import type { + FirmwarePreparedArtifactController, + IFirmwarePreparedArtifactReleaseResult, +} from './FirmwarePreparedArtifactController'; +import type { CoreApi, FirmwareUpdatePlan } from '@onekeyfe/hd-core'; + +export type IFirmwareArtifactSelfTestScenario = + | 'pro-firmware' + | 'pro-resource' + | 'pro-full-resource'; + +export type IFirmwareArtifactSelfTestPhase = + | 'starting' + | 'preflight' + | 'reading' + | 'sdk-handoff' + | 'device-boundary' + | 'cache-stress' + | 'failure-cleanup' + | 'sweeping' + | 'completed' + | 'failed' + | 'cancelled'; + +export type IFirmwareArtifactSelfTestStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export type IFirmwareArtifactSelfTestDescriptor = { + scenario: IFirmwareArtifactSelfTestScenario; + label: string; + version: string; + role: IFirmwareDownloadArtifact['role']; + container: IFirmwareDownloadArtifact['container']; + expectedSize?: number; +}; + +export type IFirmwareArtifactSelfTestProgress = { + phase: Exclude< + IFirmwareArtifactSelfTestPhase, + 'starting' | 'completed' | 'failed' | 'cancelled' + >; + progress: number; + bytesRead?: number; + chunkCount?: number; + materializedEntryCount?: number; + preflightCompletedIterations?: number; +}; + +export type IFirmwareArtifactSelfTestResult = { + bytesRead: number; + chunkCount: number; + materializedEntryCount: number; + preflightCompletedIterations: number; + preparedPlanValidated: boolean; + sdkHandoffValidated: boolean; + cleanupValidated: boolean; + failureCleanupValidated: boolean; + sdkBoundaryCode: string; + deletedFiles: number; + deletedBytes: number; +}; + +export type IFirmwareArtifactSelfTestState = { + runId: string; + descriptor: IFirmwareArtifactSelfTestDescriptor; + platform: 'ios' | 'android' | 'desktop'; + status: IFirmwareArtifactSelfTestStatus; + phase: IFirmwareArtifactSelfTestPhase; + progress: number; + startedAt: number; + updatedAt: number; + completedAt?: number; + bytesRead: number; + chunkCount: number; + materializedEntryCount: number; + preflightCompletedIterations: number; + preparedPlanValidated: boolean; + sdkHandoffValidated: boolean; + cleanupValidated: boolean; + failureCleanupValidated: boolean; + sdkBoundaryCode?: string; + deletedFiles: number; + deletedBytes: number; + errorCode?: string; +}; + +type IFirmwareArtifactSelfTestHost = Pick< + FirmwarePreparedArtifactController, + | 'getExecutionArtifacts' + | 'sweepOrphanedArtifacts' + | 'withPreparedPlanArtifacts' +>; + +const READER_CHUNK_BYTES = 256 * 1024; +const PREFLIGHT_STRESS_READ_BYTES = 4 * 1024; +const PREFLIGHT_STRESS_ITERATIONS = 50; +const SDK_SELF_TEST_CONNECT_ID = '__firmware_sdk_self_test_no_device__'; +const CONTROLLED_FAILURE_CODE = 'EXPECTED_FAILURE_CLEANUP_PROBE'; + +type IFirmwareArtifactSdkFailure = { + code?: string | number; + error: string; +}; + +const getProRelease = async () => { + const config = await getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }); + const release = config.pro['firmware-v8']?.[0]; + if (!release) { + throw new OneKeyLocalError('Remote Pro firmware is unavailable'); + } + return release; +}; + +export const getFirmwareArtifactSelfTestArtifact = async ( + scenario: IFirmwareArtifactSelfTestScenario, +): Promise<{ + descriptor: IFirmwareArtifactSelfTestDescriptor; + artifact: IFirmwareDownloadArtifact; + artifactId: 'firmware' | 'resource'; +}> => { + const release = await getProRelease(); + let url: string | undefined; + let expectedSize: number | undefined; + let expectedSha256: string | undefined; + let label: string; + let artifactId: 'firmware' | 'resource'; + if (scenario === 'pro-firmware') { + url = release.url; + expectedSize = release.expectedSize; + expectedSha256 = release.fingerprint; + label = 'Pro firmware'; + artifactId = 'firmware'; + } else if (scenario === 'pro-resource') { + url = release.resource; + expectedSize = release.resourceExpectedSize; + expectedSha256 = release.resourceFingerprint; + label = 'Pro incremental resource'; + artifactId = 'resource'; + } else { + url = release.fullResource; + expectedSize = release.fullResourceExpectedSize; + expectedSha256 = release.fullResourceFingerprint; + label = 'Pro full resource'; + artifactId = 'resource'; + } + if (!url) { + throw new OneKeyLocalError(`${label} URL is unavailable`); + } + const artifact = resolveFirmwarePlanArtifact({ + artifactId, + role: artifactId, + target: artifactId, + url, + container: artifactId === 'resource' ? 'zip' : 'raw', + expectedSize, + expectedSha256, + }); + const version = release.version.join('.'); + return { + artifact, + artifactId, + descriptor: { + scenario, + label, + version, + role: artifact.role, + container: artifact.container, + expectedSize: artifact.expectedSize, + }, + }; +}; + +export const getFirmwareArtifactSelfTestPlatform = (): + | 'ios' + | 'android' + | 'desktop' => { + if (platformEnv.isNativeIOS) return 'ios'; + if (platformEnv.isNativeAndroid) return 'android'; + if (platformEnv.isDesktop) return 'desktop'; + throw new OneKeyLocalError( + 'Firmware artifact self-test requires iOS, Android, or Desktop', + ); +}; + +export const getFirmwareArtifactSelfTestErrorCode = ( + error: unknown, +): string => { + const message = error instanceof Error ? error.message : String(error); + return /\b(ARTIFACT_[A-Z0-9_]+)\b/u.exec(message)?.[1] ?? 'SELF_TEST_FAILED'; +}; + +const getSdkPlanPlatform = (): FirmwareUpdatePlan['platform'] => + platformEnv.isDesktop ? 'desktop' : 'native'; + +const digestFirmwareSelfTestPlan = async ( + plan: Omit, +): Promise => + bufferUtils.bytesToHex( + await appCrypto.hash.sha256( + bufferUtils.toBuffer(stringUtils.stableStringify(plan), 'utf8'), + ), + ); + +const buildFirmwareSelfTestPlan = async ({ + artifact, + artifactId, + scenario, + version, +}: { + artifact: IFirmwareDownloadArtifact; + artifactId: 'firmware' | 'resource'; + scenario: IFirmwareArtifactSelfTestScenario; + version: string; +}): Promise => { + const target = scenario === 'pro-firmware' ? 'firmware' : 'resource'; + const planWithoutDigest = { + schemaVersion: 2, + executor: 'v3', + deviceIdentity: 'firmware-self-test-pro', + deviceModel: String(EDeviceType.Pro), + firmwareType: EFirmwareType.Universal, + platform: getSdkPlanPlatform(), + artifacts: [ + { + artifactId, + role: target, + target, + url: artifact.url, + container: artifact.container, + ...(artifact.logicalName ? { logicalName: artifact.logicalName } : {}), + ...(artifact.expectedSize !== undefined + ? { expectedSize: artifact.expectedSize } + : {}), + ...(artifact.expectedSha256 + ? { expectedSha256: artifact.expectedSha256.toLowerCase() } + : {}), + targetVersion: version, + }, + ], + targetsToUpdate: [target], + } as unknown as Omit; + return { + ...planWithoutDigest, + planDigest: await digestFirmwareSelfTestPlan(planWithoutDigest), + }; +}; + +const readPreparedArtifact = async ({ + prepared, + artifactId, + expectedSize, + maxBytes, + onProgress, +}: { + prepared: IPreparedFirmwareArtifacts; + artifactId: 'firmware' | 'resource'; + expectedSize?: number; + maxBytes?: number; + onProgress?: ( + bytesRead: number, + chunkCount: number, + totalBytes: number, + ) => void; +}): Promise<{ bytesRead: number; chunkCount: number }> => { + const artifact = prepared.artifactsById[artifactId]; + if (!artifact) { + throw new OneKeyLocalError('ARTIFACT_PREPARED_REFERENCE_MISSING'); + } + const reader = await prepared.artifactReader.open({ + artifactRef: artifact.artifactRef, + }); + if ( + reader.size !== artifact.size || + (expectedSize !== undefined && artifact.size !== expectedSize) + ) { + await prepared.artifactReader.close({ readerId: reader.readerId }); + throw new OneKeyLocalError('ARTIFACT_READER_SIZE_MISMATCH'); + } + + const totalBytes = Math.min(maxBytes ?? reader.size, reader.size); + let bytesRead = 0; + let chunkCount = 0; + try { + while (bytesRead < totalBytes) { + const length = Math.min(READER_CHUNK_BYTES, totalBytes - bytesRead); + const chunk = await prepared.artifactReader.read({ + readerId: reader.readerId, + offset: bytesRead, + length, + }); + if ( + chunk.bytesRead !== length || + chunk.data.byteLength !== length || + chunk.eof !== (bytesRead + length === reader.size) + ) { + throw new OneKeyLocalError('ARTIFACT_READER_CHUNK_INVALID'); + } + bytesRead += chunk.bytesRead; + chunkCount += 1; + onProgress?.(bytesRead, chunkCount, reader.size); + } + } finally { + await prepared.artifactReader.close({ readerId: reader.readerId }); + } + return { bytesRead, chunkCount }; +}; + +const getSdkFailure = async ( + operation: () => ReturnType, +): Promise => { + let result: Awaited>; + try { + result = await operation(); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + if (result.success) { + throw new OneKeyLocalError( + 'SDK self-test unexpectedly reached device execution', + ); + } + return { + code: result.payload.code, + error: result.payload.error, + }; +}; + +const executeSdkDeviceBoundary = async ({ + controller, + sdk, + prepared, +}: { + controller: IFirmwareArtifactSelfTestHost; + sdk: CoreApi; + prepared: IPreparedFirmwareArtifacts; +}): Promise => { + const executionArtifacts = controller.getExecutionArtifacts( + prepared, + 'firmwareUpdateV3', + ); + const targetVersion = prepared.plan.artifacts[0]?.targetVersion + ?.split('.') + .map((value) => Number.parseInt(value, 10)); + const failure = await getSdkFailure(() => + executePreparedFirmwareUpdateV3({ + sdk, + connectId: SDK_SELF_TEST_CONNECT_ID, + ...executionArtifacts, + platform: prepared.plan.platform, + firmwareType: prepared.plan.firmwareType, + bleVersion: undefined, + firmwareVersion: + prepared.plan.targetsToUpdate.includes('firmware') && + targetVersion?.every(Number.isSafeInteger) + ? targetVersion + : undefined, + bootloaderVersion: undefined, + }), + ); + if ( + /prepared plan|artifact binding|host binding|artifact reader/iu.test( + failure.error, + ) + ) { + throw new OneKeyLocalError( + `ARTIFACT_SDK_HANDOFF_REJECTED: ${failure.error}`, + ); + } + const boundaryCode = String(failure.code ?? 'DEVICE_BOUNDARY_REACHED'); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'device-boundary', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + boundaryCode, + }); + return boundaryCode; +}; + +const assertCleanup = ( + cleanup: IFirmwarePreparedArtifactReleaseResult | undefined, + code: string, +): void => { + if (!cleanup?.hostBindingReleased || !cleanup.leaseReleased) { + throw new OneKeyLocalError(`ARTIFACT_${code}_FAILED`); + } +}; + +export const executeFirmwareArtifactSelfTest = async ({ + scenario, + transactionId, + sdk, + controller, + onProgress, +}: { + scenario: IFirmwareArtifactSelfTestScenario; + transactionId: string; + sdk: CoreApi; + controller: IFirmwareArtifactSelfTestHost; + onProgress: (progress: IFirmwareArtifactSelfTestProgress) => void; +}): Promise => { + if (!isExternalFirmwareCapabilityReady(sdk.getFirmwareUpdateCapabilities())) { + throw new OneKeyLocalError('ARTIFACT_SDK_CAPABILITY_MISMATCH'); + } + const { artifact, artifactId, descriptor } = + await getFirmwareArtifactSelfTestArtifact(scenario); + const plan = await buildFirmwareSelfTestPlan({ + artifact, + artifactId, + scenario, + version: descriptor.version, + }); + + let mainCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + onProgress({ phase: 'preflight', progress: 5 }); + const mainResult = await controller.withPreparedPlanArtifacts( + { plan, sdk, transactionId }, + async (prepared) => { + onProgress({ phase: 'reading', progress: 65 }); + const readerResult = await readPreparedArtifact({ + prepared, + artifactId, + expectedSize: artifact.expectedSize, + onProgress: (bytesRead, chunkCount, totalBytes) => { + onProgress({ + phase: 'reading', + progress: 65 + Math.floor((bytesRead / totalBytes) * 20), + bytesRead, + chunkCount, + }); + }, + }); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'reader-complete', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + readerBytes: readerResult.bytesRead, + readerChunks: readerResult.chunkCount, + }); + onProgress({ + phase: 'sdk-handoff', + progress: 88, + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + }); + const sdkBoundaryCode = await executeSdkDeviceBoundary({ + controller, + sdk, + prepared, + }); + onProgress({ + phase: 'device-boundary', + progress: 90, + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + }); + return { + ...readerResult, + materializedEntryCount: prepared.selected.resourceEntries?.length ?? 0, + sdkBoundaryCode, + }; + }, + (cleanup) => { + mainCleanup = cleanup; + }, + ); + assertCleanup(mainCleanup, 'MAIN_CLEANUP'); + + let preflightCompletedIterations = 0; + if (scenario === 'pro-firmware') { + for (let index = 0; index < PREFLIGHT_STRESS_ITERATIONS; index += 1) { + let iterationCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + await controller.withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId: `${transactionId}:stress:${index + 1}`, + }, + async (prepared) => { + await readPreparedArtifact({ + prepared, + artifactId, + expectedSize: artifact.expectedSize, + maxBytes: PREFLIGHT_STRESS_READ_BYTES, + }); + }, + (cleanup) => { + iterationCleanup = cleanup; + }, + ); + assertCleanup(iterationCleanup, 'CACHE_STRESS_CLEANUP'); + preflightCompletedIterations += 1; + onProgress({ + phase: 'cache-stress', + progress: + 91 + + Math.floor( + (preflightCompletedIterations / PREFLIGHT_STRESS_ITERATIONS) * 5, + ), + preflightCompletedIterations, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + }); + } + } + + let failureCleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + try { + await controller.withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId: `${transactionId}:failure-cleanup`, + }, + async () => { + throw new OneKeyLocalError(CONTROLLED_FAILURE_CODE); + }, + (cleanup) => { + failureCleanup = cleanup; + }, + ); + throw new OneKeyLocalError('ARTIFACT_FAILURE_CLEANUP_NOT_TRIGGERED'); + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== CONTROLLED_FAILURE_CODE + ) { + throw error; + } + } + assertCleanup(failureCleanup, 'FAILURE_CLEANUP'); + onProgress({ + phase: 'failure-cleanup', + progress: 97, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + preflightCompletedIterations, + }); + + onProgress({ + phase: 'sweeping', + progress: 99, + bytesRead: mainResult.bytesRead, + chunkCount: mainResult.chunkCount, + materializedEntryCount: mainResult.materializedEntryCount, + preflightCompletedIterations, + }); + const sweep = await controller.sweepOrphanedArtifacts(); + return { + ...mainResult, + preflightCompletedIterations, + preparedPlanValidated: true, + sdkHandoffValidated: true, + cleanupValidated: true, + failureCleanupValidated: true, + deletedFiles: sweep.deletedFiles, + deletedBytes: sweep.deletedBytes, + }; +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts new file mode 100644 index 000000000000..14c60d15f710 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTestController.ts @@ -0,0 +1,267 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import { importHardwareSDK } from '@onekeyhq/shared/src/hardware/sdk-loader'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; + +import { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, + getFirmwareArtifactSelfTestPlatform, +} from './FirmwareArtifactSelfTest'; +import { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +import type { + IFirmwareArtifactSelfTestPhase, + IFirmwareArtifactSelfTestProgress, + IFirmwareArtifactSelfTestResult, + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from './FirmwareArtifactSelfTest'; +import type { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; +import type { CoreApi } from '@onekeyfe/hd-core'; + +type IFirmwareArtifactSelfTestControllerDependencies = { + getHardwareTransportType: () => Promise; + getSDKInstance: () => Promise; +}; + +export class FirmwareArtifactSelfTestController { + private state?: IFirmwareArtifactSelfTestState; + + constructor( + private readonly dependencies: IFirmwareArtifactSelfTestControllerDependencies, + private readonly preparedArtifacts: FirmwarePreparedArtifactController, + ) {} + + private updateState({ + phase, + progress, + bytesRead, + chunkCount, + materializedEntryCount, + preflightCompletedIterations, + }: IFirmwareArtifactSelfTestProgress): void { + const current = this.state; + if (!current || current.status !== 'running') return; + const next = { + ...current, + phase, + progress, + updatedAt: Date.now(), + bytesRead: bytesRead ?? current.bytesRead, + chunkCount: chunkCount ?? current.chunkCount, + materializedEntryCount: + materializedEntryCount ?? current.materializedEntryCount, + preflightCompletedIterations: + preflightCompletedIterations ?? current.preflightCompletedIterations, + }; + this.state = next; + const enteredNewPhase = phase !== current.phase; + const enteredNewProgressBucket = + Math.floor(progress / 5) !== Math.floor(current.progress / 5); + if (enteredNewPhase || enteredNewProgressBucket) { + this.log({ state: next, outcome: 'progress' }); + } + } + + private log({ + state, + outcome, + }: { + state: IFirmwareArtifactSelfTestState; + outcome: 'started' | 'progress' | 'success' | 'failure' | 'cancelled'; + }): void { + defaultLogger.update.firmware.firmwareArtifactSelfTest({ + runId: state.runId, + runtime: 'bg', + platform: state.platform, + scenario: state.descriptor.scenario, + phase: state.phase, + outcome, + durationMs: Date.now() - state.startedAt, + bytes: state.bytesRead || undefined, + chunkCount: state.chunkCount || undefined, + materializedEntryCount: state.materializedEntryCount || undefined, + preflightCompletedIterations: + state.preflightCompletedIterations || undefined, + preparedPlanValidated: state.preparedPlanValidated || undefined, + sdkHandoffValidated: state.sdkHandoffValidated || undefined, + cleanupValidated: state.cleanupValidated || undefined, + failureCleanupValidated: state.failureCleanupValidated || undefined, + sdkBoundaryCode: state.sdkBoundaryCode, + errorCode: state.errorCode, + }); + } + + private async getSdk(): Promise<{ + sdk: CoreApi; + disposeAfterTest: boolean; + }> { + if (!platformEnv.isDesktop) { + return { + sdk: await this.dependencies.getSDKInstance(), + disposeAfterTest: false, + }; + } + const transportType = await this.dependencies.getHardwareTransportType(); + if (isDirectFirmwareHostBindingTransport(transportType)) { + return { + sdk: await this.dependencies.getSDKInstance(), + disposeAfterTest: false, + }; + } + const sdk = await importHardwareSDK({ + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + const initialized = await sdk.init({ + debug: false, + env: 'desktop-webusb', + fetchConfig: false, + firmwareManifestMode: 'external-only', + preloadedConfig: await getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }), + }); + if (!initialized) { + throw new OneKeyLocalError( + 'Firmware SDK self-test direct instance failed to initialize', + ); + } + return { sdk, disposeAfterTest: true }; + } + + private finish({ + phase, + status, + result, + errorCode, + }: { + phase: Extract< + IFirmwareArtifactSelfTestPhase, + 'completed' | 'failed' | 'cancelled' + >; + status: Extract< + IFirmwareArtifactSelfTestState['status'], + 'completed' | 'failed' | 'cancelled' + >; + result?: IFirmwareArtifactSelfTestResult; + errorCode?: string; + }): void { + const current = this.state; + if (!current) return; + const completedAt = Date.now(); + const next = { + ...current, + phase, + status, + progress: status === 'completed' ? 100 : current.progress, + updatedAt: completedAt, + completedAt, + bytesRead: result?.bytesRead ?? current.bytesRead, + chunkCount: result?.chunkCount ?? current.chunkCount, + materializedEntryCount: + result?.materializedEntryCount ?? current.materializedEntryCount, + preflightCompletedIterations: + result?.preflightCompletedIterations ?? + current.preflightCompletedIterations, + preparedPlanValidated: + result?.preparedPlanValidated ?? current.preparedPlanValidated, + sdkHandoffValidated: + result?.sdkHandoffValidated ?? current.sdkHandoffValidated, + cleanupValidated: result?.cleanupValidated ?? current.cleanupValidated, + failureCleanupValidated: + result?.failureCleanupValidated ?? current.failureCleanupValidated, + sdkBoundaryCode: result?.sdkBoundaryCode ?? current.sdkBoundaryCode, + deletedFiles: result?.deletedFiles ?? current.deletedFiles, + deletedBytes: result?.deletedBytes ?? current.deletedBytes, + errorCode, + }; + this.state = next; + let outcome: 'success' | 'failure' | 'cancelled' = 'failure'; + if (status === 'completed') { + outcome = 'success'; + } else if (status === 'cancelled') { + outcome = 'cancelled'; + } + this.log({ state: next, outcome }); + } + + async start( + scenario: IFirmwareArtifactSelfTestScenario, + ): Promise { + if (this.state?.status === 'running') { + throw new OneKeyLocalError( + 'Another firmware artifact self-test is already running', + ); + } + const runId = generateUUID(); + // cspell:disable-next-line + const transactionId = `fwtx:${runId}`; + const now = Date.now(); + const state: IFirmwareArtifactSelfTestState = { + runId, + descriptor: (await getFirmwareArtifactSelfTestArtifact(scenario)) + .descriptor, + platform: getFirmwareArtifactSelfTestPlatform(), + status: 'running', + phase: 'starting', + progress: 0, + startedAt: now, + updatedAt: now, + bytesRead: 0, + chunkCount: 0, + materializedEntryCount: 0, + preflightCompletedIterations: 0, + preparedPlanValidated: false, + sdkHandoffValidated: false, + cleanupValidated: false, + failureCleanupValidated: false, + deletedFiles: 0, + deletedBytes: 0, + }; + this.state = state; + this.log({ state, outcome: 'started' }); + void this.getSdk() + .then(async ({ sdk, disposeAfterTest }) => { + try { + return await executeFirmwareArtifactSelfTest({ + scenario, + transactionId, + sdk, + controller: this.preparedArtifacts, + onProgress: (next) => this.updateState(next), + }); + } finally { + if (disposeAfterTest) { + await sdk.dispose(); + } + } + }) + .then((result) => { + this.finish({ + phase: 'completed', + status: 'completed', + result, + }); + }) + .catch((error) => { + const errorCode = getFirmwareArtifactSelfTestErrorCode(error); + const cancelled = errorCode === 'ARTIFACT_CANCELLED'; + this.finish({ + phase: cancelled ? 'cancelled' : 'failed', + status: cancelled ? 'cancelled' : 'failed', + errorCode, + }); + }); + return state; + } + + getState(): IFirmwareArtifactSelfTestState | undefined { + return this.state; + } +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts new file mode 100644 index 000000000000..633846a79d23 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.test.ts @@ -0,0 +1,139 @@ +import { fetchFirmwareConfig } from '@onekeyhq/shared/src/hardware/firmwareConfigProvider'; + +import { + FIRMWARE_MANIFEST_CACHE_TTL_MS, + clearFirmwareManifestSnapshotCache, + getFirmwareManifestSnapshot, +} from './FirmwareManifestProvider'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +jest.mock('@onekeyhq/shared/src/hardware/firmwareConfigProvider', () => ({ + fetchFirmwareConfig: jest.fn(), +})); + +jest.mock('@onekeyhq/shared/src/logger/logger', () => ({ + defaultLogger: { + ipTable: { + request: { + info: jest.fn(), + }, + }, + }, +})); + +const mockedFetchFirmwareConfig = fetchFirmwareConfig as jest.Mock; +const remoteConfig = { + bridge: { version: 'remote' }, +} as unknown as RemoteConfigResponse; +describe('FirmwareManifestProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearFirmwareManifestSnapshotCache(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the App-fetched remote snapshot', async () => { + mockedFetchFirmwareConfig.mockResolvedValue(remoteConfig); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + }); + + it.each([null, new Error('transport failed')])( + 'fails closed without a remote or cached snapshot for %s', + async (remoteResult) => { + if (remoteResult instanceof Error) { + mockedFetchFirmwareConfig.mockRejectedValue(remoteResult); + } else { + mockedFetchFirmwareConfig.mockResolvedValue(remoteResult); + } + + await expect( + getFirmwareManifestSnapshot({ preRelease: true }), + ).rejects.toThrow('Firmware manifest is unavailable'); + }, + ); + + it('accepts the existing config shape without optional integrity fields', async () => { + const config = { + ...remoteConfig, + pro: { + 'firmware-v8': [ + { + required: false, + version: [4, 21, 0], + url: 'https://firmware.example/pro.bin', + fingerprint: '', + changelog: {}, + }, + ], + }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig.mockResolvedValue(config); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(config); + }); + + it('reuses a snapshot only within the fixed refresh interval', async () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + const refreshedConfig = { + bridge: { version: 'refreshed' }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockResolvedValueOnce(refreshedConfig); + + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(remoteConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(1000 + FIRMWARE_MANIFEST_CACHE_TTL_MS); + await expect( + getFirmwareManifestSnapshot({ preRelease: false }), + ).resolves.toBe(refreshedConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(2); + }); + + it('forces a network refresh before an explicit firmware check', async () => { + const refreshedConfig = { + bridge: { version: 'refreshed' }, + } as unknown as RemoteConfigResponse; + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockResolvedValueOnce(refreshedConfig); + + await getFirmwareManifestSnapshot({ preRelease: false }); + await expect( + getFirmwareManifestSnapshot({ + preRelease: false, + forceRefresh: true, + }), + ).resolves.toBe(refreshedConfig); + expect(mockedFetchFirmwareConfig).toHaveBeenCalledTimes(2); + }); + + it('keeps the last known-good snapshot when a refresh fails', async () => { + mockedFetchFirmwareConfig + .mockResolvedValueOnce(remoteConfig) + .mockRejectedValueOnce(new Error('transport failed')); + + await getFirmwareManifestSnapshot({ preRelease: true }); + await expect( + getFirmwareManifestSnapshot({ + preRelease: true, + forceRefresh: true, + }), + ).resolves.toBe(remoteConfig); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts new file mode 100644 index 000000000000..dab64e28fcc5 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareManifestProvider.ts @@ -0,0 +1,78 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { fetchFirmwareConfig } from '@onekeyhq/shared/src/hardware/firmwareConfigProvider'; +import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +export const FIRMWARE_MANIFEST_CACHE_TTL_MS = 3 * 60 * 60 * 1000; + +type IFirmwareManifestCacheEntry = { + config: RemoteConfigResponse; + expiresAt: number; +}; + +const firmwareManifestCache = new Map(); +const firmwareManifestRefreshes = new Map< + boolean, + Promise +>(); + +export function clearFirmwareManifestSnapshotCache(): void { + firmwareManifestCache.clear(); + firmwareManifestRefreshes.clear(); +} + +async function refreshFirmwareManifestSnapshot({ + preRelease, +}: { + preRelease: boolean; +}): Promise { + const cached = firmwareManifestCache.get(preRelease); + try { + const remoteConfig = await fetchFirmwareConfig({ preRelease }); + if (remoteConfig) { + firmwareManifestCache.set(preRelease, { + config: remoteConfig, + expiresAt: Date.now() + FIRMWARE_MANIFEST_CACHE_TTL_MS, + }); + return remoteConfig; + } + } catch { + // Keep using the last known-good remote snapshot below. + } + + if (cached) { + defaultLogger.ipTable.request.info({ + info: '[FirmwareManifest] config_fetch route=cache outcome=stale_fallback', + }); + return cached.config; + } + + throw new OneKeyLocalError('Firmware manifest is unavailable'); +} + +export async function getFirmwareManifestSnapshot({ + preRelease, + forceRefresh = false, +}: { + preRelease: boolean; + forceRefresh?: boolean; +}): Promise { + const cached = firmwareManifestCache.get(preRelease); + if (!forceRefresh && cached && cached.expiresAt > Date.now()) { + return cached.config; + } + + const pendingRefresh = firmwareManifestRefreshes.get(preRelease); + if (pendingRefresh) { + return pendingRefresh; + } + + const refresh = refreshFirmwareManifestSnapshot({ preRelease }).finally( + () => { + firmwareManifestRefreshes.delete(preRelease); + }, + ); + firmwareManifestRefreshes.set(preRelease, refresh); + return refresh; +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts new file mode 100644 index 000000000000..df4c346e8a94 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedArtifactController.ts @@ -0,0 +1,618 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReady, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + withFirmwareArtifactStageTimeout, +} from './FirmwareArtifactPreflight'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; + +import type { + IBridgeFirmwareBinaries, + IPreparedFirmwareArtifacts, +} from './FirmwareArtifactPreflight'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePlanForceTarget, +} from '@onekeyfe/hd-core'; + +export type IFirmwareWorkflowArtifacts = + | IPreparedFirmwareArtifacts + | IBridgeFirmwareBinaries; + +export type IFirmwareExecutionArtifacts = { + preparedArtifacts?: IPreparedFirmwareArtifacts; + bridgeBinaries?: IBridgeFirmwareBinaries; + hostBindingGeneration?: number; +}; + +export type IFirmwarePreparedArtifactReleaseResult = { + hostBindingReleased: boolean; + leaseReleased: boolean; +}; + +type IFirmwareHostBinding = { + sdk: CoreApi; + generation: number; +}; + +type IFirmwarePreparedArtifactControllerDependencies = { + getHardwareTransportType: () => Promise; + getSDKInstance: (connectId: string | undefined) => Promise; +}; + +const getPreparedArtifactTraceSummary = ( + prepared: IPreparedFirmwareArtifacts, +) => { + const artifactReferences = Object.values(prepared.artifactsById ?? {}); + const resourceEntries = prepared.selected.resourceEntries ?? []; + return { + count: artifactReferences.length, + bytes: artifactReferences.reduce( + (total, artifact) => total + artifact.size, + 0, + ), + firmwareBytes: prepared.selected.firmware?.size, + bleBytes: prepared.selected.ble?.size, + bootloaderBytes: prepared.selected.bootloader?.size, + resourceCount: + resourceEntries.length + 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, + ), + integrityVerified: + artifactReferences.length > 0 && + artifactReferences.every( + (artifact) => + artifact.size > 0 && + typeof artifact.sha256 === 'string' && + artifact.sha256.length > 0, + ), + }; +}; + +const getBridgeArtifactTraceSummary = (bridge: IBridgeFirmwareBinaries) => { + const binaries = Object.values(bridge.targetBinaries).filter( + (binary): binary is ArrayBuffer => binary !== undefined, + ); + const firmware = bridge.targetBinaries.firmware; + const ble = bridge.targetBinaries.ble; + const bootloader = bridge.targetBinaries.bootloader; + return { + count: binaries.length, + bytes: binaries.reduce((total, binary) => total + binary.byteLength, 0), + firmwareBytes: firmware?.byteLength, + bleBytes: ble?.byteLength, + bootloaderBytes: bootloader?.byteLength, + resourceCount: 0, + resourceBytes: 0, + integrityVerified: binaries.length > 0, + }; +}; + +const assertFirmwareUpdatePlanCoverage = ({ + plan, + expectedTargets, +}: { + plan: FirmwareUpdatePlan; + expectedTargets: readonly FirmwareUpdatePlanForceTarget[]; +}): void => { + if ( + !Array.isArray(plan.artifacts) || + plan.artifacts.length === 0 || + !Array.isArray(plan.targetsToUpdate) || + plan.targetsToUpdate.length === 0 + ) { + throw new OneKeyLocalError( + 'Firmware update plan has no executable artifacts', + ); + } + + const artifactTargets = new Set( + plan.artifacts.map((artifact) => artifact.target), + ); + const planTargets = new Set(plan.targetsToUpdate); + if ( + [...artifactTargets].some((target) => !planTargets.has(target)) || + [...planTargets].some((target) => !artifactTargets.has(target)) + ) { + throw new OneKeyLocalError( + 'Firmware update plan target coverage is incomplete', + ); + } + + const coversExpectedTarget = ( + expectedTarget: FirmwareUpdatePlanForceTarget, + ) => { + switch (expectedTarget) { + case 'firmware': + return plan.artifacts.some( + (artifact) => + artifact.role === 'firmware' || + (artifact.role === 'component' && artifact.target !== 'resource'), + ); + case 'ble': + return plan.artifacts.some((artifact) => artifact.role === 'ble'); + case 'bootloader': + return plan.artifacts.some( + (artifact) => + artifact.role === 'bootloader' || + (artifact.role === 'component' && artifact.target === 'boot'), + ); + case 'resource': + return plan.artifacts.some( + (artifact) => + artifact.role === 'resource' || artifact.role === 'resourceBundle', + ); + default: + return false; + } + }; + + if ( + [...new Set(expectedTargets)].some( + (expectedTarget) => !coversExpectedTarget(expectedTarget), + ) + ) { + throw new OneKeyLocalError( + 'Firmware update plan does not cover every selected target', + ); + } +}; + +export class FirmwarePreparedArtifactController { + private plans = new Map(); + + private hostBindings = new Map(); + + constructor( + private readonly dependencies: IFirmwarePreparedArtifactControllerDependencies, + ) {} + + private async getExternalSdk( + connectId: string | undefined, + ): Promise { + if (!(await isFirmwareArtifactCapabilityReady())) { + return undefined; + } + if (platformEnv.isDesktop) { + const transportType = await this.dependencies.getHardwareTransportType(); + if (!isDirectFirmwareHostBindingTransport(transportType)) { + return undefined; + } + } + const sdk = await this.dependencies.getSDKInstance(connectId); + try { + return isExternalFirmwareCapabilityReady( + sdk.getFirmwareUpdateCapabilities?.(), + ) && + typeof sdk.prepareFirmwareUpdatePlan === 'function' && + typeof sdk.validateFirmwareUpdatePreparedPlan === 'function' && + typeof sdk.registerFirmwareUpdateHostBinding === 'function' && + typeof sdk.unregisterFirmwareUpdateHostBinding === 'function' + ? sdk + : undefined; + } catch { + return undefined; + } + } + + async cachePlanIfPreparedSupported({ + plan, + connectId, + transportType, + expectedTargets = [], + }: { + plan: FirmwareUpdatePlan; + connectId: string | undefined; + transportType: EHardwareTransportType; + expectedTargets?: readonly FirmwareUpdatePlanForceTarget[]; + }): Promise { + assertFirmwareUpdatePlanCoverage({ plan, expectedTargets }); + this.plans.set(plan.planDigest, plan); + if (this.plans.size > 16) { + const oldestDigest = this.plans.keys().next().value; + if (oldestDigest) { + this.plans.delete(oldestDigest); + } + } + const externalSdk = await this.getExternalSdk(connectId); + const bridgeCapabilityReady = + platformEnv.isDesktop && + transportType === EHardwareTransportType.Bridge && + (await isFirmwareArtifactCapabilityReady()); + if (!bridgeCapabilityReady && !externalSdk) { + return false; + } + return true; + } + + async cachePlanDigestIfPreparedSupported({ + hasUpgrade, + plan, + connectId, + transportType, + expectedTargets = [], + }: { + hasUpgrade: boolean | undefined; + plan: FirmwareUpdatePlan | undefined; + connectId: string | undefined; + transportType: EHardwareTransportType; + expectedTargets?: readonly FirmwareUpdatePlanForceTarget[]; + }): Promise { + return hasUpgrade && + plan && + (await this.cachePlanIfPreparedSupported({ + plan, + connectId, + transportType, + expectedTargets, + })) + ? plan.planDigest + : undefined; + } + + getPlan(releaseResult: ICheckAllFirmwareReleaseResult): FirmwareUpdatePlan { + const planDigest = releaseResult.firmwareUpdatePlanDigest; + const plan = planDigest ? this.plans.get(planDigest) : undefined; + if (!plan) { + throw new OneKeyLocalError( + 'Firmware update plan is unavailable; check for updates again', + ); + } + if ( + plan.deviceIdentity !== (releaseResult.deviceUUID || 'unavailable') || + plan.deviceModel !== String(releaseResult.deviceType) || + plan.platform !== (platformEnv.symbol ?? 'web') + ) { + throw new OneKeyLocalError( + 'Firmware update plan does not match the selected device', + ); + } + return plan; + } + + private bindHost(prepared: IPreparedFirmwareArtifacts, sdk: CoreApi): void { + const existing = this.hostBindings.get(prepared.transactionId); + if (existing) { + existing.sdk.unregisterFirmwareUpdateHostBinding(existing.generation); + } + const generation = ( + sdk.registerFirmwareUpdateHostBinding as unknown as (binding: { + artifactReader: IPreparedFirmwareArtifacts['artifactReader']; + }) => number + )({ + artifactReader: prepared.artifactReader, + }); + if (!Number.isSafeInteger(generation) || generation <= 0) { + throw new OneKeyLocalError( + 'Firmware SDK returned an invalid host binding generation', + ); + } + this.hostBindings.set(prepared.transactionId, { sdk, generation }); + } + + private releaseHost(transactionId: string): boolean { + const binding = this.hostBindings.get(transactionId); + if (!binding) return false; + try { + return binding.sdk.unregisterFirmwareUpdateHostBinding( + binding.generation, + ); + } finally { + this.hostBindings.delete(transactionId); + } + } + + getExecutionBindingParams(preparedArtifacts: IPreparedFirmwareArtifacts): { + hostBindingGeneration: number; + } { + const binding = this.hostBindings.get(preparedArtifacts.transactionId); + if (!binding) { + throw new OneKeyLocalError('Firmware host binding is unavailable'); + } + return { + hostBindingGeneration: binding.generation, + }; + } + + getExecutionArtifacts( + artifacts: IFirmwareWorkflowArtifacts | undefined, + sdkMethod?: string, + ): IFirmwareExecutionArtifacts { + const prepared = + artifacts && 'preparedPlan' in artifacts ? artifacts : undefined; + const bridge = + artifacts && 'targetBinaries' in artifacts ? artifacts : undefined; + const executionArtifacts = { + preparedArtifacts: prepared, + bridgeBinaries: bridge, + hostBindingGeneration: prepared + ? this.getExecutionBindingParams(prepared).hostBindingGeneration + : undefined, + }; + if (prepared) { + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'sdk-handoff', + executor: prepared.plan.executor, + sdkMethod, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + hostBindingProvided: Boolean(executionArtifacts.hostBindingGeneration), + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + } else if (bridge) { + firmwareUpdateTrace({ + transactionId: bridge.transactionId, + stage: 'sdk-handoff', + executor: bridge.executor, + sdkMethod, + inputMode: 'bridge-binary', + preparedPlanProvided: false, + hostBindingProvided: false, + artifacts: getBridgeArtifactTraceSummary(bridge), + }); + } + return executionArtifacts; + } + + async preparePlanArtifacts({ + plan, + sdk, + // cspell:disable-next-line + transactionId = `fwtx:${generateUUID().toLowerCase()}`, + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }): Promise { + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-start', + executor: plan.executor, + inputMode: 'artifact-reader', + expectedArtifactCount: plan.artifacts.length, + }); + const leaseOperation = firmwareArtifactAdapter.createLease(transactionId); + let leaseRef: string; + try { + ({ leaseRef } = await withFirmwareArtifactStageTimeout( + 'LEASE_CREATE', + leaseOperation, + )); + } catch (error) { + void leaseOperation + .then(({ leaseRef: lateLeaseRef }) => + firmwareArtifactAdapter.releaseLease({ + leaseRef: lateLeaseRef, + disposition: 'safeCancelled', + }), + ) + .catch(() => undefined); + throw error; + } + firmwareUpdateTrace({ + transactionId, + stage: 'lease-created', + executor: plan.executor, + inputMode: 'artifact-reader', + }); + try { + const prepared = await prepareFirmwareArtifacts(plan, { + transactionId, + leaseRef, + preparePlan: sdk.prepareFirmwareUpdatePlan, + }); + const validatedPreparedPlan = sdk.validateFirmwareUpdatePreparedPlan( + prepared.preparedPlan, + ); + if ( + validatedPreparedPlan.preparedPlanDigest !== + prepared.preparedPlan.preparedPlanDigest + ) { + throw new OneKeyLocalError( + 'Firmware prepared plan validation returned a different digest', + ); + } + firmwareUpdateTrace({ + transactionId, + stage: 'artifact-ready', + executor: plan.executor, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + this.bindHost(prepared, sdk); + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-complete', + executor: plan.executor, + inputMode: 'artifact-reader', + preparedPlanProvided: true, + hostBindingProvided: true, + artifacts: getPreparedArtifactTraceSummary(prepared), + }); + return prepared; + } catch (error) { + this.releaseHost(transactionId); + await firmwareArtifactAdapter + .cancelDownloads(transactionId) + .catch(() => undefined); + await firmwareArtifactAdapter + .releaseLease({ leaseRef, disposition: 'safeCancelled' }) + .catch(() => undefined); + throw error; + } + } + + async withPreparedPlanArtifacts( + { + plan, + sdk, + transactionId, + }: { + plan: FirmwareUpdatePlan; + sdk: CoreApi; + transactionId?: string; + }, + execute: (artifacts: IPreparedFirmwareArtifacts) => Promise, + onReleased?: (result: IFirmwarePreparedArtifactReleaseResult) => void, + ): Promise { + const prepared = await this.preparePlanArtifacts({ + plan, + sdk, + transactionId, + }); + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(prepared); + disposition = 'completed'; + return result; + } finally { + onReleased?.(await this.releasePreparedArtifacts(prepared, disposition)); + } + } + + private async prepareExternal( + releaseResult: ICheckAllFirmwareReleaseResult, + ): Promise { + if (!platformEnv.isNative && !platformEnv.isDesktop) { + return undefined; + } + if (!releaseResult.firmwareUpdatePlanDigest) { + return undefined; + } + const plan = this.getPlan(releaseResult); + const sdk = await this.getExternalSdk(releaseResult.updatingConnectId); + if (!sdk) { + throw new OneKeyLocalError( + 'Firmware external SDK capability is unavailable', + ); + } + return this.preparePlanArtifacts({ plan, sdk }); + } + + async prepareWorkflowArtifacts( + releaseResult: ICheckAllFirmwareReleaseResult, + ): Promise { + if (!releaseResult.firmwareUpdatePlanDigest) return undefined; + if (platformEnv.isDesktop) { + const transportType = await this.dependencies.getHardwareTransportType(); + if (transportType === EHardwareTransportType.Bridge) { + const plan = this.getPlan(releaseResult); + const transactionId = `bridge:${generateUUID().toLowerCase()}`; + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-start', + executor: plan.executor, + inputMode: 'bridge-binary', + expectedArtifactCount: plan.artifacts.length, + }); + const prepared = await prepareBridgeFirmwareBinaries( + plan, + transactionId, + ); + if (prepared) { + firmwareUpdateTrace({ + transactionId, + stage: 'preflight-complete', + executor: plan.executor, + inputMode: 'bridge-binary', + preparedPlanProvided: false, + hostBindingProvided: false, + artifacts: getBridgeArtifactTraceSummary(prepared), + }); + } + return prepared; + } + if (!isDirectFirmwareHostBindingTransport(transportType)) { + throw new OneKeyLocalError( + 'Firmware prepared transport is unavailable', + ); + } + } + const prepared = await this.prepareExternal(releaseResult); + if (!prepared) { + throw new OneKeyLocalError('Firmware artifacts are not prepared'); + } + return prepared; + } + + async withWorkflowArtifacts( + releaseResult: ICheckAllFirmwareReleaseResult, + execute: (artifacts: IFirmwareWorkflowArtifacts | undefined) => Promise, + ): Promise { + const artifacts = await this.prepareWorkflowArtifacts(releaseResult); + const prepared = + artifacts && 'preparedPlan' in artifacts ? artifacts : undefined; + let disposition: 'completed' | 'safeCancelled' = 'safeCancelled'; + try { + const result = await execute(artifacts); + disposition = 'completed'; + return result; + } finally { + if (prepared) { + await this.releasePreparedArtifacts(prepared, disposition); + } + } + } + + async releasePreparedArtifacts( + prepared: IPreparedFirmwareArtifacts, + disposition: 'completed' | 'safeCancelled', + ): Promise { + let hostBindingReleased = false; + let leaseReleased = false; + try { + hostBindingReleased = this.releaseHost(prepared.transactionId); + } catch { + hostBindingReleased = false; + } + if (disposition === 'safeCancelled') { + await firmwareArtifactAdapter + .cancelDownloads(prepared.transactionId) + .catch(() => undefined); + } + await withFirmwareArtifactStageTimeout( + 'LEASE_RELEASE', + firmwareArtifactAdapter.releaseLease({ + leaseRef: prepared.leaseRef, + disposition, + }), + ) + .then(() => { + leaseReleased = true; + }) + .catch(() => undefined); + firmwareUpdateTrace({ + transactionId: prepared.transactionId, + stage: 'release-complete', + executor: prepared.plan.executor, + inputMode: 'artifact-reader', + disposition, + hostBindingReleased, + leaseReleased, + }); + return { hostBindingReleased, leaseReleased }; + } + + sweepOrphanedArtifacts(): Promise<{ + deletedFiles: number; + deletedBytes: number; + }> { + return firmwareArtifactAdapter.sweepOrphans(); + } +} diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts new file mode 100644 index 000000000000..a318ca5469f8 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwarePreparedExecution.ts @@ -0,0 +1,408 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import { isDirectFirmwareHostBindingTransport } from '@onekeyhq/shared/src/hardware/instance'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import type { EHardwareTransportType } from '@onekeyhq/shared/types'; + +import { + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, +} from './FirmwareArtifactPreflight'; + +import type { IFirmwareArtifactReference } from './FirmwareArtifactPreflight'; +import type { IFirmwareExecutionArtifacts } from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePreparedPlan, + FirmwareUpdateV4Target, +} from '@onekeyfe/hd-core'; +import type { EFirmwareType } from '@onekeyfe/hd-shared'; + +type IFirmwarePlatform = 'native' | 'desktop' | 'ext' | 'web' | 'web-embed'; + +const FIRMWARE_UPDATE_V4_TARGETS = new Set([ + 'boot', + 'app_v1', + 'app_v2', + 'coprocessor', + 'resource', + 'se01', + 'se02', + 'se03', + 'se04', +]); + +export const getFirmwareUpdateV4Targets = ( + targets: readonly string[], +): FirmwareUpdateV4Target[] => { + const supported = targets.filter((target): target is FirmwareUpdateV4Target => + FIRMWARE_UPDATE_V4_TARGETS.has(target as FirmwareUpdateV4Target), + ); + if (supported.length !== targets.length) { + throw new OneKeyLocalError( + 'Protocol V2 firmware update plan contains an invalid target', + ); + } + return supported; +}; + +export const assertFirmwareUpdateV4Artifacts = ( + { preparedArtifacts }: IFirmwareExecutionArtifacts, + transportType?: EHardwareTransportType, +): void => { + const directDesktopExecution = + platformEnv.isDesktop && + transportType !== undefined && + isDirectFirmwareHostBindingTransport(transportType); + if ((platformEnv.isNative || directDesktopExecution) && !preparedArtifacts) { + throw new OneKeyLocalError( + 'Protocol V2 firmware artifacts are not prepared', + ); + } +}; + +const requireHostBindingGeneration = ( + preparedArtifacts: IFirmwareExecutionArtifacts['preparedArtifacts'], + hostBindingGeneration: number | undefined, +): number => { + if (!preparedArtifacts || !hostBindingGeneration) { + throw new OneKeyLocalError('Firmware host binding is unavailable'); + } + return hostBindingGeneration; +}; + +export const executePreparedFirmwareUpdateV2Bootloader = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; +} & IFirmwareExecutionArtifacts) => { + const artifact = preparedArtifacts?.selected.bootloader; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError('Prepared bootloader artifact is unavailable'); + } + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + updateType: 'firmware'; + platform: string; + isUpdateBootloader: true; + artifact: IFirmwareArtifactReference; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + updateType: 'firmware', + platform, + isUpdateBootloader: true, + artifact, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = bridgeBinaries?.targetBinaries.bootloader; + if (bridgeBinary) { + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + binary: ArrayBuffer; + updateType: 'firmware'; + platform: string; + isUpdateBootloader: true; + }, + ) => ReturnType + )(connectId, { + binary: bridgeBinary, + updateType: 'firmware', + platform, + isUpdateBootloader: true, + }); + } + return sdk.firmwareUpdateV2(connectId, { + updateType: 'firmware', + platform, + isUpdateBootloader: true, + }); +}; + +export const executePreparedDeviceUpdateBootloader = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, +}: { + sdk: CoreApi; + connectId: string; +} & IFirmwareExecutionArtifacts) => { + const artifact = preparedArtifacts?.selected.bootloader; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError('Prepared bootloader artifact is unavailable'); + } + return ( + sdk.deviceUpdateBootloader as unknown as ( + id: string, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + artifact: IFirmwareArtifactReference; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + artifact, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = bridgeBinaries?.targetBinaries.bootloader; + return sdk.deviceUpdateBootloader( + connectId, + bridgeBinary ? { binary: bridgeBinary } : {}, + ); +}; + +export const executePreparedFirmwareUpdateV2 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + updateType, + forcedUpdateRes, + platform, + firmwareType, + version, +}: { + sdk: CoreApi; + connectId: string | undefined; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + version: number[]; +} & IFirmwareExecutionArtifacts) => { + const artifact = + updateType === 'ble' + ? preparedArtifacts?.selected.ble + : preparedArtifacts?.selected.firmware; + if (preparedArtifacts) { + if (!artifact) { + throw new OneKeyLocalError( + `Prepared ${updateType} artifact is unavailable`, + ); + } + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: string; + firmwareType: EFirmwareType | undefined; + artifact: IFirmwareArtifactReference; + resourceEntries?: readonly { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + hostBindingGeneration: number; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + updateType, + forcedUpdateRes: + preparedArtifacts.plan.targetsToUpdate.includes('resource'), + platform, + firmwareType, + artifact, + ...(updateType === 'firmware' && + preparedArtifacts.selected.resourceEntries + ? { resourceEntries: preparedArtifacts.selected.resourceEntries } + : {}), + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + }); + } + const bridgeBinary = + bridgeBinaries?.targetBinaries[updateType === 'ble' ? 'ble' : 'firmware']; + if (bridgeBinary) { + return ( + sdk.firmwareUpdateV2 as unknown as ( + id: string | undefined, + params: { + binary: ArrayBuffer; + updateType: 'firmware' | 'ble'; + forcedUpdateRes: boolean; + platform: string; + firmwareType: EFirmwareType | undefined; + }, + ) => ReturnType + )(connectId, { + binary: bridgeBinary, + updateType, + forcedUpdateRes, + platform, + firmwareType, + }); + } + return sdk.firmwareUpdateV2(connectId, { + updateType, + forcedUpdateRes, + version, + platform, + firmwareType, + }); +}; + +export const executePreparedFirmwareUpdateV4 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, + firmwareType, + targetsToUpdate, + forcedUpdateRes, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + targetsToUpdate: FirmwareUpdateV4Target[]; + forcedUpdateRes: boolean; +} & IFirmwareExecutionArtifacts) => + 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, + ), + } + : {}), + }); + +export const executePreparedFirmwareUpdateV3 = ({ + sdk, + connectId, + preparedArtifacts, + bridgeBinaries, + hostBindingGeneration, + platform, + firmwareType, + bleVersion, + firmwareVersion, + bootloaderVersion, +}: { + sdk: CoreApi; + connectId: string | undefined; + platform: IFirmwarePlatform; + firmwareType: EFirmwareType | undefined; + bleVersion: number[] | undefined; + firmwareVersion: number[] | undefined; + bootloaderVersion: number[] | undefined; +} & IFirmwareExecutionArtifacts) => { + if (preparedArtifacts) { + return ( + sdk.firmwareUpdateV3 as unknown as ( + id: string | undefined, + params: { + preparedPlan: FirmwareUpdatePreparedPlan; + platform: string; + bleVersion: number[] | undefined; + firmwareVersion: number[] | undefined; + bootloaderVersion: number[] | undefined; + firmwareType: EFirmwareType | undefined; + hostBindingGeneration: number; + artifacts: { + ble?: IFirmwareArtifactReference; + firmware?: IFirmwareArtifactReference; + bootloader?: IFirmwareArtifactReference; + resourceEntries?: readonly { + entryName: string; + artifact: IFirmwareArtifactReference; + }[]; + }; + }, + ) => ReturnType + )(connectId, { + preparedPlan: preparedArtifacts.preparedPlan, + platform, + bleVersion, + firmwareVersion, + bootloaderVersion, + firmwareType, + hostBindingGeneration: requireHostBindingGeneration( + preparedArtifacts, + hostBindingGeneration, + ), + artifacts: { + ...(preparedArtifacts.selected.ble + ? { ble: preparedArtifacts.selected.ble } + : {}), + ...(preparedArtifacts.selected.firmware + ? { firmware: preparedArtifacts.selected.firmware } + : {}), + ...(preparedArtifacts.selected.bootloader + ? { bootloader: preparedArtifacts.selected.bootloader } + : {}), + ...(preparedArtifacts.selected.resourceEntries + ? { resourceEntries: preparedArtifacts.selected.resourceEntries } + : {}), + }, + }); + } + return sdk.firmwareUpdateV3(connectId, { + platform, + bleVersion, + firmwareVersion, + bootloaderVersion, + firmwareType, + ...getBridgeFirmwareV3BinaryParams(bridgeBinaries), + }); +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts new file mode 100644 index 000000000000..17bba2657c2f --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateCapabilities.test.ts @@ -0,0 +1,1124 @@ +import { EFirmwareType } from '@onekeyfe/hd-shared'; + +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import loggerUtils from '@onekeyhq/shared/src/logger/utils'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import { EHardwareTransportType } from '@onekeyhq/shared/types'; +import type { ICheckAllFirmwareReleaseResult } from '@onekeyhq/shared/types/device'; + +import { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +import { + cancelFirmwareArtifactPreparations, + downloadFirmwareArtifact, + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReadyValue, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + withFirmwareArtifactStageTimeout, +} from './FirmwareArtifactPreflight'; +import { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; +import { + executePreparedFirmwareUpdateV2, + executePreparedFirmwareUpdateV3, +} from './FirmwarePreparedExecution'; + +import type { IPreparedFirmwareArtifacts } from './FirmwareArtifactPreflight'; +import type { IFirmwarePreparedArtifactReleaseResult } from './FirmwarePreparedArtifactController'; +import type { + CoreApi, + FirmwareUpdatePlan, + FirmwareUpdatePreparedPlan, +} from '@onekeyfe/hd-core'; + +const ready = { + planSchemaVersion: 2, + preparedPlanSchemaVersion: 2, + hostBindingProtocolVersion: 2, + manifestModes: ['external-only', 'sdk-managed'], + supportsArtifactReader: true, +}; + +const testFirmwareArtifact = { + url: 'https://firmware.example/bootloader.bin', + role: 'bootloader' as const, + expectedSize: 1024, + expectedSha256: '1'.repeat(64), + container: 'raw' as const, +}; + +describe('isExternalFirmwareCapabilityReady', () => { + test('requires the exact cross-repo capability contract', () => { + expect(isExternalFirmwareCapabilityReady(ready)).toBe(true); + expect( + isExternalFirmwareCapabilityReady({ + ...ready, + planSchemaVersion: 1, + }), + ).toBe(false); + expect( + isExternalFirmwareCapabilityReady({ + ...ready, + unexpected: true, + }), + ).toBe(false); + }); + + test('requires the exact native artifact protocol and bounded reader contract', () => { + const nativeReady = { + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }; + expect(isFirmwareArtifactCapabilityReadyValue(nativeReady)).toBe(true); + expect( + isFirmwareArtifactCapabilityReadyValue({ + ...nativeReady, + maxReadBytes: 512 * 1024, + }), + ).toBe(false); + expect( + isFirmwareArtifactCapabilityReadyValue({ + ...nativeReady, + supportedRouteTypes: [], + }), + ).toBe(false); + }); +}); + +describe('firmware artifact stage watchdog', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test('fails a hung synchronous native bridge stage without retrying it', async () => { + jest.useFakeTimers(); + const operation = jest.fn(() => new Promise(() => undefined)); + const pending = withFirmwareArtifactStageTimeout( + 'LEASE_CREATE', + operation(), + ); + + jest.advanceTimersByTime(15_000); + + await expect(pending).rejects.toThrow('ARTIFACT_LEASE_CREATE_TIMEOUT'); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); + +describe('prepared firmware execution', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createController = () => + new FirmwarePreparedArtifactController({ + getHardwareTransportType: async () => EHardwareTransportType.Bridge, + getSDKInstance: async () => ({}) as CoreApi, + }); + + const createPreparedArtifacts = ( + selected: Partial = {}, + ) => + ({ + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + preparedPlan: {}, + plan: { + deviceIdentity: 'device', + artifacts: [], + targetsToUpdate: [], + }, + selected: { + componentArtifacts: {}, + resourceBundleArtifacts: [], + ...selected, + }, + }) as unknown as IPreparedFirmwareArtifacts; + + test('releases prepared artifacts with the exact workflow disposition', async () => { + const controller = createController(); + const prepared = createPreparedArtifacts({}); + jest + .spyOn(controller, 'prepareWorkflowArtifacts') + .mockResolvedValue(prepared); + const release = jest + .spyOn(controller, 'releasePreparedArtifacts') + .mockResolvedValue({ + hostBindingReleased: true, + leaseReleased: true, + }); + const releaseResult = {} as ICheckAllFirmwareReleaseResult; + + await controller.withWorkflowArtifacts(releaseResult, async () => 'done'); + await expect( + controller.withWorkflowArtifacts(releaseResult, async () => { + throw new OneKeyLocalError('install failed'); + }), + ).rejects.toThrow('install failed'); + + expect(release).toHaveBeenNthCalledWith(1, prepared, 'completed'); + expect(release).toHaveBeenNthCalledWith(2, prepared, 'safeCancelled'); + }); + + test('never falls back to SDK network for malformed prepared artifacts', () => { + const firmwareUpdateV2 = jest.fn(); + const sdk = { firmwareUpdateV2 } as unknown as CoreApi; + const baseParams = { + sdk, + connectId: 'device', + forcedUpdateRes: false, + platform: 'native' as const, + firmwareType: EFirmwareType.Universal, + version: [1, 0, 0], + }; + + expect(() => + executePreparedFirmwareUpdateV2({ + ...baseParams, + updateType: 'firmware', + preparedArtifacts: createPreparedArtifacts({}), + hostBindingGeneration: 1, + }), + ).toThrow('Prepared firmware artifact is unavailable'); + expect(() => + executePreparedFirmwareUpdateV2({ + ...baseParams, + updateType: 'firmware', + preparedArtifacts: createPreparedArtifacts({ + firmware: {} as NonNullable< + IPreparedFirmwareArtifacts['selected']['firmware'] + >, + }), + }), + ).toThrow('Firmware host binding is unavailable'); + expect(firmwareUpdateV2).not.toHaveBeenCalled(); + }); + + test('derives prepared V2 resource mode from the immutable Plan', async () => { + const firmwareUpdateV2 = jest.fn(); + const sdk = { firmwareUpdateV2 } as unknown as CoreApi; + const firmware = { + artifactRef: 'fw:firmware', + size: 4, + sha256: 'a'.repeat(64), + }; + const resource = { + artifactRef: 'fw:resource', + size: 6, + sha256: 'b'.repeat(64), + }; + const baseParams = { + sdk, + connectId: 'device', + updateType: 'firmware' as const, + platform: 'native' as const, + firmwareType: EFirmwareType.Universal, + version: [1, 0, 0], + hostBindingGeneration: 1, + }; + const preparedWithResource = createPreparedArtifacts({ + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }); + preparedWithResource.plan.targetsToUpdate = ['firmware', 'resource']; + + await executePreparedFirmwareUpdateV2({ + ...baseParams, + preparedArtifacts: preparedWithResource, + forcedUpdateRes: false, + }); + + const preparedWithoutResource = createPreparedArtifacts({ firmware }); + preparedWithoutResource.plan.targetsToUpdate = ['firmware']; + await executePreparedFirmwareUpdateV2({ + ...baseParams, + preparedArtifacts: preparedWithoutResource, + forcedUpdateRes: true, + }); + await executePreparedFirmwareUpdateV2({ + ...baseParams, + forcedUpdateRes: true, + }); + + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 1, + 'device', + expect.objectContaining({ forcedUpdateRes: true }), + ); + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 2, + 'device', + expect.objectContaining({ forcedUpdateRes: false }), + ); + expect(firmwareUpdateV2).toHaveBeenNthCalledWith( + 3, + 'device', + expect.objectContaining({ forcedUpdateRes: true }), + ); + }); + + test('logs and passes matching prepared firmware and resource inputs to V3', async () => { + const firmwareUpdateV3 = jest.fn(); + const sdk = { firmwareUpdateV3 } as unknown as CoreApi; + const firmware = { + artifactRef: 'fw:firmware', + size: 4, + sha256: 'a'.repeat(64), + }; + const resource = { + artifactRef: 'fw:resource', + size: 6, + sha256: 'b'.repeat(64), + }; + const prepared = createPreparedArtifacts({ + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }); + prepared.artifactsById = { firmware, resource }; + prepared.plan.executor = 'v3'; + const controller = createController(); + jest + .spyOn(controller, 'getExecutionBindingParams') + .mockReturnValue({ hostBindingGeneration: 105 }); + const localLog = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const executionArtifacts = controller.getExecutionArtifacts( + prepared, + 'firmwareUpdateV3', + ); + + await executePreparedFirmwareUpdateV3({ + sdk, + connectId: 'device', + ...executionArtifacts, + platform: 'native', + firmwareType: EFirmwareType.Universal, + bleVersion: undefined, + firmwareVersion: [4, 21, 0], + bootloaderVersion: undefined, + }); + + expect(firmwareUpdateV3).toHaveBeenCalledWith( + 'device', + expect.objectContaining({ + preparedPlan: prepared.preparedPlan, + hostBindingGeneration: 105, + artifacts: { + firmware, + resourceEntries: [{ entryName: 'resource.bin', artifact: resource }], + }, + }), + ); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"stage":"sdk-handoff","executor":"v3","sdkMethod":"firmwareUpdateV3"', + ), + ); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"artifacts":{"count":2,"bytes":10,"firmwareBytes":4,"resourceCount":1,"resourceBytes":6,"integrityVerified":true}', + ), + ); + }); + + test('logs and passes Desktop Bridge binaries without claiming a resource input', async () => { + const firmwareUpdateV3 = jest.fn(); + const sdk = { firmwareUpdateV3 } as unknown as CoreApi; + const firmware = new ArrayBuffer(4); + const controller = createController(); + const localLog = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const executionArtifacts = controller.getExecutionArtifacts( + { + transactionId: 'bridge:test', + executor: 'v3', + planDigest: 'a'.repeat(64), + targetBinaries: { firmware }, + }, + 'firmwareUpdateV3', + ); + + await executePreparedFirmwareUpdateV3({ + sdk, + connectId: 'device', + ...executionArtifacts, + platform: 'desktop', + firmwareType: EFirmwareType.Universal, + bleVersion: undefined, + firmwareVersion: [4, 21, 0], + bootloaderVersion: undefined, + }); + + expect(firmwareUpdateV3).toHaveBeenCalledWith( + 'device', + expect.objectContaining({ + firmwareBinary: firmware, + }), + ); + expect(firmwareUpdateV3.mock.calls[0][1]).not.toHaveProperty( + 'resourceBinary', + ); + expect(localLog).toHaveBeenCalledWith( + expect.stringContaining( + '"artifacts":{"count":1,"bytes":4,"firmwareBytes":4,"resourceCount":0,"resourceBytes":0,"integrityVerified":true}', + ), + ); + }); + + test('uses artifact capability to enable non-empty Desktop Bridge plan caching', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const controller = createController(); + const plan = { + planDigest: 'a'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: testFirmwareArtifact.url, + container: 'raw', + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanIfPreparedSupported({ + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).resolves.toBe(true); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('keeps a config Plan App-managed when integrity fields are absent', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const controller = createController(); + const plan = { + planDigest: 'c'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: 'https://firmware.example/pro.bin', + container: 'raw', + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).resolves.toBe(plan.planDigest); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); + + test('rejects empty and partial Desktop Bridge plans before preparation', async () => { + const previousPlatform = { + isDesktop: platformEnv.isDesktop, + appPlatform: platformEnv.appPlatform, + symbol: platformEnv.symbol, + }; + Object.assign(platformEnv, { + isDesktop: true, + appPlatform: 'desktop', + symbol: 'desktop', + }); + try { + jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const download = jest.spyOn(firmwareArtifactAdapter, 'download'); + const controller = createController(); + const emptyPlan = { + planDigest: 'a'.repeat(64), + deviceIdentity: 'device', + deviceModel: 'pro', + platform: 'desktop', + artifacts: [], + targetsToUpdate: [], + } as unknown as FirmwareUpdatePlan; + const firmwareOnlyPlan = { + ...emptyPlan, + planDigest: 'b'.repeat(64), + artifacts: [ + { + artifactId: 'firmware', + role: 'firmware', + target: 'firmware', + url: 'https://common.onekey-asset.com/firmware.bin', + container: 'raw', + }, + ], + targetsToUpdate: ['firmware'], + } as unknown as FirmwareUpdatePlan; + + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan: emptyPlan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware'], + }), + ).rejects.toThrow('Firmware update plan has no executable artifacts'); + await expect( + controller.cachePlanDigestIfPreparedSupported({ + hasUpgrade: true, + plan: firmwareOnlyPlan, + connectId: 'device', + transportType: EHardwareTransportType.Bridge, + expectedTargets: ['firmware', 'ble'], + }), + ).rejects.toThrow( + 'Firmware update plan does not cover every selected target', + ); + expect(download).not.toHaveBeenCalled(); + } finally { + Object.assign(platformEnv, previousPlatform); + } + }); +}); + +describe('downloadFirmwareArtifact', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('uses the canonical domain route with the remaining deadline', async () => { + const trace = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: true, + }); + const deadlineAt = Date.now() + 20 * 60 * 1000; + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:single-route-deadline', + leaseRef: 'fwlease:single-route-deadline', + deadlineAt, + }), + ).resolves.toMatchObject({ + artifactRef: `fw:${artifact.expectedSha256}`, + }); + + expect(download).toHaveBeenCalledTimes(1); + expect(download.mock.calls[0][0].route).toEqual({ + routeType: 'domain', + }); + expect(download.mock.calls[0][0].overallDeadlineSeconds).toBeGreaterThan( + 15 * 60, + ); + expect( + download.mock.calls[0][0].overallDeadlineSeconds, + ).toBeLessThanOrEqual(20 * 60); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"stage":"artifact-download-start"'), + ); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"stage":"artifact-download-complete"'), + ); + }); + + test('records a bounded error code when the native download fails', async () => { + const trace = jest + .spyOn(loggerUtils, 'consoleFunc') + .mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockRejectedValue(new Error('ARTIFACT_NETWORK_FAILED: request failed')); + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:trace-failure', + leaseRef: 'fwlease:trace-failure', + deadlineAt: Date.now() + 20 * 60 * 1000, + }), + ).rejects.toThrow('ARTIFACT_NETWORK_FAILED'); + + expect(trace).toHaveBeenCalledWith( + expect.stringContaining( + '"stage":"artifact-download-failed","artifactId":"bootloader"', + ), + ); + expect(trace).toHaveBeenCalledWith( + expect.stringContaining('"errorCode":"ARTIFACT_NETWORK_FAILED"'), + ); + }); + + test('rejects a receipt that did not verify the expected SHA-256', async () => { + jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); + const artifact = testFirmwareArtifact; + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: false, + }); + + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:unverified-receipt', + leaseRef: 'fwlease:unverified-receipt', + deadlineAt: Date.now() + 20 * 60 * 1000, + }), + ).rejects.toThrow( + 'Firmware artifact receipt does not match the update plan', + ); + }); + + test('does not start a download after the preparation deadline', async () => { + const artifact = testFirmwareArtifact; + const download = jest.spyOn(firmwareArtifactAdapter, 'download'); + await expect( + downloadFirmwareArtifact({ + artifact, + artifactId: 'bootloader', + taskId: 'bootloader-download', + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + deadlineAt: Date.now() - 1, + }), + ).rejects.toThrow('Firmware artifact preparation exceeded its deadline'); + expect(download).not.toHaveBeenCalled(); + }); +}); + +describe('Desktop Bridge firmware binaries', () => { + const createBridgePlan = () => { + const artifact = testFirmwareArtifact; + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [ + { + artifactId: 'bootloader', + role: 'bootloader', + target: 'bootloader', + url: artifact.url, + container: 'raw', + expectedSize: artifact.expectedSize, + expectedSha256: artifact.expectedSha256, + }, + ], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + return { artifact, plan }; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('downloads and reads a small admitted artifact before releasing its lease', async () => { + const { artifact, plan } = createBridgePlan(); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:test' }); + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${artifact.expectedSha256}`, + size: artifact.expectedSize, + sha256: artifact.expectedSha256, + expectedSha256Verified: true, + }); + jest.spyOn(firmwareArtifactAdapter, 'open').mockResolvedValue({ + readerId: 'reader', + size: artifact.expectedSize, + }); + const read = jest + .spyOn(firmwareArtifactAdapter, 'read') + .mockImplementation(async ({ length }) => new ArrayBuffer(length)); + jest.spyOn(firmwareArtifactAdapter, 'close').mockResolvedValue(); + const release = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + + const result = await prepareBridgeFirmwareBinaries(plan); + + expect(result?.targetBinaries.bootloader?.byteLength).toBe( + artifact.expectedSize, + ); + expect(read.mock.calls.every(([input]) => input.length <= 256 * 1024)).toBe( + true, + ); + expect(release).toHaveBeenCalledWith({ + leaseRef: 'fwlease:test', + disposition: 'completed', + }); + }); + + test('downloads a 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, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:catalog' }); + const actualSha256 = '2'.repeat(64); + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${actualSha256}`, + size: testFirmwareArtifact.expectedSize, + sha256: actualSha256, + expectedSha256Verified: false, + }); + jest.spyOn(firmwareArtifactAdapter, 'open').mockResolvedValue({ + readerId: 'reader', + size: testFirmwareArtifact.expectedSize, + }); + jest + .spyOn(firmwareArtifactAdapter, 'read') + .mockImplementation(async ({ length }) => new ArrayBuffer(length)); + jest.spyOn(firmwareArtifactAdapter, 'close').mockResolvedValue(); + jest.spyOn(firmwareArtifactAdapter, 'releaseLease').mockResolvedValue(); + + await expect(prepareBridgeFirmwareBinaries(plan)).resolves.toMatchObject({ + targetBinaries: { + bootloader: expect.any(ArrayBuffer), + }, + }); + expect(download).toHaveBeenCalledWith( + expect.objectContaining({ + artifactId: 'bootloader', + url: plan.artifacts[0].url, + maxBytes: 512 * 1024 * 1024, + }), + ); + 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 () => { + const { plan } = createBridgePlan(); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:test' }); + let rejectDownload: (reason?: unknown) => void = () => undefined; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectDownload = reject; + }), + ); + const cancel = jest + .spyOn(firmwareArtifactAdapter, 'cancelDownloads') + .mockImplementation(async () => { + rejectDownload(new Error('ARTIFACT_CANCELLED')); + }); + const release = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + + const preparing = prepareBridgeFirmwareBinaries(plan); + for ( + let attempt = 0; + attempt < 10 && !download.mock.calls.length; + attempt += 1 + ) { + await Promise.resolve(); + } + await cancelFirmwareArtifactPreparations(); + + await expect(preparing).rejects.toThrow('ARTIFACT_CANCELLED'); + expect(download).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith(expect.stringMatching(/^bridge:/u)); + expect(release).toHaveBeenCalledWith({ + leaseRef: 'fwlease:test', + disposition: 'safeCancelled', + }); + }); + + test('maps prefetched binaries onto existing V3 and V4 SDK fields', () => { + const firmware = new ArrayBuffer(1); + const ble = new ArrayBuffer(2); + const boot = new ArrayBuffer(3); + expect( + getBridgeFirmwareV3BinaryParams({ + transactionId: 'bridge:v3', + executor: 'v3', + planDigest: 'a', + targetBinaries: { firmware, ble, bootloader: boot }, + }), + ).toEqual({ + firmwareBinary: firmware, + bleBinary: ble, + bootloaderBinary: boot, + }); + expect( + getBridgeFirmwareV4BinaryParams({ + transactionId: 'bridge:v4', + executor: 'v4', + planDigest: 'b', + targetBinaries: { + boot, + app_v1: firmware, + coprocessor: ble, + }, + }), + ).toEqual({ + bootloaderBinary: boot, + applicationP1Binary: firmware, + coprocessorBinary: ble, + }); + }); +}); + +describe('external firmware artifact preparation', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('cancels sibling downloads before surfacing a preparation failure', async () => { + const artifact = { + artifactId: 'bootloader', + role: 'bootloader' as const, + target: 'bootloader' as const, + url: testFirmwareArtifact.url, + container: 'raw' as const, + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }; + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'desktop', + artifacts: [artifact, { ...artifact, artifactId: 'bootloader-copy' }], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + let rejectSibling: (reason?: unknown) => void = () => undefined; + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSibling = reject; + }), + ) + .mockRejectedValueOnce( + new Error('ARTIFACT_TLS_FAILED: firmware TLS validation failed'), + ); + const cancel = jest + .spyOn(firmwareArtifactAdapter, 'cancelDownloads') + .mockImplementation(async () => { + rejectSibling(new Error('ARTIFACT_CANCELLED')); + }); + + await expect( + prepareFirmwareArtifacts(plan, { + transactionId: 'fwtx:test', + leaseRef: 'fwlease:test', + preparePlan: jest.fn(), + }), + ).rejects.toThrow('ARTIFACT_TLS_FAILED'); + expect(download).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('fwtx:test'); + }); + + test('materializes a ZIP without remote entry metadata and passes actual receipts to the SDK', async () => { + const plan = { + schemaVersion: 2, + planDigest: 'd'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'native', + artifacts: [ + { + artifactId: 'resource', + role: 'resource', + target: 'resource', + url: 'https://firmware.example/resources.zip', + container: 'zip', + }, + ], + targetsToUpdate: ['resource'], + } as unknown as FirmwareUpdatePlan; + const archiveSha256 = '3'.repeat(64); + const entrySha256 = '4'.repeat(64); + const preparePlan = jest.fn(() => ({ + preparedPlanDigest: 'e'.repeat(64), + planDigest: plan.planDigest, + })) as unknown as CoreApi['prepareFirmwareUpdatePlan']; + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + const download = jest + .spyOn(firmwareArtifactAdapter, 'download') + .mockResolvedValue({ + artifactRef: `fw:${archiveSha256}`, + size: 2048, + sha256: archiveSha256, + expectedSha256Verified: false, + }); + const materialize = jest + .spyOn(firmwareArtifactAdapter, 'materialize') + .mockResolvedValue([ + { + entryName: 'resource.bin', + receipt: { + artifactRef: `fw:${entrySha256}`, + size: 1024, + sha256: entrySha256, + expectedSha256Verified: false, + }, + }, + ]); + + await expect( + prepareFirmwareArtifacts(plan, { + transactionId: 'fwtx:zip-without-catalog', + leaseRef: 'fwlease:zip-without-catalog', + preparePlan, + }), + ).resolves.toMatchObject({ + selected: { + resourceEntries: [ + { + entryName: 'resource.bin', + artifact: { sha256: entrySha256 }, + }, + ], + }, + }); + expect(download.mock.calls[0][0]).toMatchObject({ + maxBytes: 512 * 1024 * 1024, + }); + expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSize'); + expect(download.mock.calls[0][0]).not.toHaveProperty('expectedSha256'); + expect(materialize).toHaveBeenCalledWith({ + leaseRef: 'fwlease:zip-without-catalog', + archiveArtifactRef: `fw:${archiveSha256}`, + }); + expect(preparePlan).toHaveBeenCalledWith( + expect.objectContaining({ + artifacts: [ + expect.objectContaining({ + artifactId: 'resource', + materializedEntries: [ + { + entryName: 'resource.bin', + artifact: { + artifactRef: `fw:${entrySha256}`, + size: 1024, + sha256: entrySha256, + expectedSha256Verified: false, + }, + }, + ], + }), + ], + }), + ); + }); + + test('uses one production session for plan validation, host binding, and release', async () => { + const plan = { + schemaVersion: 2, + planDigest: 'a'.repeat(64), + executor: 'v2', + deviceIdentity: 'device', + deviceModel: 'classic', + firmwareType: EFirmwareType.Universal, + platform: 'native', + artifacts: [ + { + artifactId: 'bootloader', + role: 'bootloader', + target: 'bootloader', + url: testFirmwareArtifact.url, + container: 'raw', + expectedSize: testFirmwareArtifact.expectedSize, + expectedSha256: testFirmwareArtifact.expectedSha256, + }, + ], + targetsToUpdate: ['bootloader'], + } as unknown as FirmwareUpdatePlan; + const preparedPlan = { + preparedPlanDigest: 'b'.repeat(64), + planDigest: plan.planDigest, + } as FirmwareUpdatePreparedPlan; + const validateFirmwareUpdatePreparedPlan = jest.fn(() => preparedPlan); + const unregisterFirmwareUpdateHostBinding = jest.fn(() => true); + const sdk = { + prepareFirmwareUpdatePlan: jest.fn(() => preparedPlan), + validateFirmwareUpdatePreparedPlan, + registerFirmwareUpdateHostBinding: jest.fn(() => 9), + unregisterFirmwareUpdateHostBinding, + } as unknown as CoreApi; + jest.spyOn(loggerUtils, 'consoleFunc').mockImplementation(() => undefined); + jest.spyOn(firmwareArtifactAdapter, 'getCapabilities').mockReturnValue({ + firmwareArtifactProtocolVersion: 4, + maxReadBytes: 256 * 1024, + supportsArchiveMaterialization: true, + supportedRouteTypes: ['domain'], + }); + jest + .spyOn(firmwareArtifactAdapter, 'createLease') + .mockResolvedValue({ leaseRef: 'fwlease:session' }); + jest.spyOn(firmwareArtifactAdapter, 'download').mockResolvedValue({ + artifactRef: `fw:${testFirmwareArtifact.expectedSha256}`, + size: testFirmwareArtifact.expectedSize, + sha256: testFirmwareArtifact.expectedSha256, + expectedSha256Verified: true, + }); + const releaseLease = jest + .spyOn(firmwareArtifactAdapter, 'releaseLease') + .mockResolvedValue(); + const controller = new FirmwarePreparedArtifactController({ + getHardwareTransportType: async () => EHardwareTransportType.Bridge, + getSDKInstance: async () => sdk, + }); + let cleanup: IFirmwarePreparedArtifactReleaseResult | undefined; + + await controller.withPreparedPlanArtifacts( + { plan, sdk, transactionId: 'fwtx:session' }, + async (prepared) => { + expect(prepared.selected.bootloader).toMatchObject({ + size: testFirmwareArtifact.expectedSize, + }); + }, + (result) => { + cleanup = result; + }, + ); + + expect(validateFirmwareUpdatePreparedPlan).toHaveBeenCalledWith( + preparedPlan, + ); + expect(unregisterFirmwareUpdateHostBinding).toHaveBeenCalledWith(9); + expect(releaseLease).toHaveBeenCalledWith({ + leaseRef: 'fwlease:session', + disposition: 'completed', + }); + expect(cleanup).toEqual({ + hostBindingReleased: true, + leaseReleased: true, + }); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts new file mode 100644 index 000000000000..8eda83d9e667 --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateRuntime.ts @@ -0,0 +1,49 @@ +import { FirmwareArtifactSelfTestController } from './FirmwareArtifactSelfTestController'; +import { FirmwarePreparedArtifactController } from './FirmwarePreparedArtifactController'; + +export { firmwareArtifactAdapter } from './FirmwareArtifactAdapter'; +export { FirmwareArtifactSelfTestController }; +export { FirmwarePreparedArtifactController }; +export { + assertFirmwareUpdateV4Artifacts, + executePreparedDeviceUpdateBootloader, + executePreparedFirmwareUpdateV2, + executePreparedFirmwareUpdateV2Bootloader, + executePreparedFirmwareUpdateV3, + executePreparedFirmwareUpdateV4, + getFirmwareUpdateV4Targets, +} from './FirmwarePreparedExecution'; +export { + cancelFirmwareArtifactPreparations, + getBridgeFirmwareV3BinaryParams, + getBridgeFirmwareV4BinaryParams, + isExternalFirmwareCapabilityReady, + isFirmwareArtifactCapabilityReady, + prepareBridgeFirmwareBinaries, + prepareFirmwareArtifacts, + resolveFirmwarePlanArtifact, +} from './FirmwareArtifactPreflight'; +export { + executeFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestArtifact, + getFirmwareArtifactSelfTestErrorCode, + getFirmwareArtifactSelfTestPlatform, +} from './FirmwareArtifactSelfTest'; +export { getFirmwareManifestSnapshot } from './FirmwareManifestProvider'; + +export const createFirmwareUpdateRuntimeHost = ( + dependencies: ConstructorParameters< + typeof FirmwareArtifactSelfTestController + >[0] & + ConstructorParameters[0], +) => { + const artifacts = new FirmwarePreparedArtifactController(dependencies); + return { + selfTest: new FirmwareArtifactSelfTestController(dependencies, artifacts), + artifacts, + }; +}; + +export type IFirmwareUpdateRuntimeHost = ReturnType< + typeof createFirmwareUpdateRuntimeHost +>; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts new file mode 100644 index 000000000000..df9e50c905bd --- /dev/null +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareUpdateTrace.ts @@ -0,0 +1,84 @@ +import loggerUtils from '@onekeyhq/shared/src/logger/utils'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +export type IFirmwareUpdateTraceInputMode = + | 'artifact-reader' + | 'bridge-binary' + | 'sdk-managed'; + +export type IFirmwareUpdateTraceStage = + | 'preflight-start' + | 'lease-created' + | 'artifact-download-start' + | 'artifact-download-complete' + | 'artifact-download-failed' + | 'artifact-ready' + | 'preflight-complete' + | 'reader-complete' + | 'sdk-handoff' + | 'device-boundary' + | 'release-complete' + | 'sdk-tip'; + +export type IFirmwareUpdateArtifactTraceSummary = { + count: number; + bytes: number; + firmwareBytes?: number; + bleBytes?: number; + bootloaderBytes?: number; + resourceCount: number; + resourceBytes: number; + integrityVerified: boolean; +}; + +export type IFirmwareUpdateTraceParams = { + transactionId: string; + stage: IFirmwareUpdateTraceStage; + executor?: 'v2' | 'v3' | 'v4'; + sdkMethod?: string; + inputMode?: IFirmwareUpdateTraceInputMode; + expectedArtifactCount?: number; + artifactId?: string; + artifactRole?: string; + expectedBytes?: number; + durationMs?: number; + errorCode?: string; + artifacts?: IFirmwareUpdateArtifactTraceSummary; + preparedPlanProvided?: boolean; + hostBindingProvided?: boolean; + readerBytes?: number; + readerChunks?: number; + boundaryCode?: string; + disposition?: 'completed' | 'safeCancelled'; + hostBindingReleased?: boolean; + leaseReleased?: boolean; + tipMessage?: string; +}; + +const getFirmwareUpdateTracePlatform = () => { + if (platformEnv.isNativeIOS) return 'ios'; + if (platformEnv.isNativeAndroid) return 'android'; + return platformEnv.symbol ?? platformEnv.appPlatform ?? 'web'; +}; + +const stringifyTrace = (value: unknown): string => { + try { + return JSON.stringify(value); + } catch (error) { + return JSON.stringify({ + stringifyError: error instanceof Error ? error.message : String(error), + }); + } +}; + +export const firmwareUpdateTrace = ( + params: IFirmwareUpdateTraceParams, +): void => { + loggerUtils.consoleFunc( + `[FirmwareUpdateTrace] ${stringifyTrace({ + runtime: 'bg', + platform: getFirmwareUpdateTracePlatform(), + ...params, + })}`, + ); +}; diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts index 23ce99d88889..bfe97606f057 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.detect.test.ts @@ -1,3 +1,5 @@ +import { EDeviceType } from '@onekeyfe/hd-shared'; + import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; import { defaultLogger } from '@onekeyhq/shared/src/logger/logger'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -104,6 +106,43 @@ jest.mock('../ServiceHardware/serviceHardwareUtils', () => ({ })); const mockedLocalDb = jest.mocked(localDb); + +describe('ServiceFirmwareUpdate firmware manifest refresh', () => { + it('forces an App-managed manifest refresh before a release check', async () => { + const checkAllFirmwareRelease = jest.fn().mockResolvedValue({ + success: true, + payload: { features: {} }, + }); + const getSDKInstance = jest.fn().mockResolvedValue({ + checkAllFirmwareRelease, + }); + const service = new ServiceFirmwareUpdate({ + backgroundApi: { + serviceHardware: { + getSDKInstance, + }, + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB), + }, + } as unknown as IBackgroundApi, + }); + + await service.baseCheckAllFirmwareRelease({ + connectId: 'device-1', + firmwareType: undefined, + skipChangeTransportType: true, + }); + + expect(getSDKInstance).toHaveBeenCalledWith({ + connectId: 'device-1', + forceFirmwareManifestRefresh: true, + }); + expect(checkAllFirmwareRelease).toHaveBeenCalledTimes(1); + }); +}); + describe('ServiceFirmwareUpdate.detectActiveAccountFirmwareUpdates', () => { beforeEach(() => { jest.clearAllMocks(); @@ -244,7 +283,7 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { } as unknown as IBackgroundApi, }); - await service.updatingFirmwareV3({ + await service.updatingFirmwareV4({ connectId: 'PRO2_CONNECT_ID', bleVersion: undefined, firmwareVersion: undefined, @@ -252,6 +291,8 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { firmwareType: undefined, isPro2Device: true, pro2TargetsToUpdate: ['resource'], + requirePreparedArtifacts: false, + targetsToUpdate: ['resource'], }); expect(firmwareUpdateV4).toHaveBeenCalledTimes(1); @@ -297,7 +338,7 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { } as unknown as IBackgroundApi, }); - await service.updatingFirmwareV3({ + await service.updatingFirmwareV4({ connectId: bleConnectId, bleVersion: undefined, firmwareVersion: undefined, @@ -305,6 +346,8 @@ describe('ServiceFirmwareUpdate Pro2 resource update options', () => { firmwareType: undefined, isPro2Device: true, pro2TargetsToUpdate: ['app_v1'], + requirePreparedArtifacts: false, + targetsToUpdate: ['app_v1'], }); expect(firmwareUpdateV4).toHaveBeenCalledWith( @@ -593,3 +636,102 @@ describe('ServiceFirmwareUpdate workflow tracking', () => { ); }); }); + +describe('ServiceFirmwareUpdate legacy Pro firmware fallback', () => { + const createService = () => + new ServiceFirmwareUpdate({ + backgroundApi: {} as IBackgroundApi, + }); + + test('keeps Pro on the existing V3 path when no prepared Plan exists', async () => { + const service = createService(); + const updatingFirmwareV3 = jest + .spyOn(service, 'updatingFirmwareV3') + .mockResolvedValue({ message: 'ok' }); + const runtimeHost = jest.spyOn( + service as unknown as { + getFirmwareUpdateRuntimeHost: () => Promise; + }, + 'getFirmwareUpdateRuntimeHost', + ); + jest + .spyOn(service, 'createRunTaskWithRetry') + .mockImplementation(async ({ fn }) => fn({ id: 1 })); + + await service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro, + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }); + + expect(updatingFirmwareV3).toHaveBeenCalledTimes(1); + expect(runtimeHost).not.toHaveBeenCalled(); + }); + + test('keeps the legacy Pro2 rejection when no Plan exists', async () => { + const service = createService(); + const updatingFirmwareV4 = jest + .spyOn(service, 'updatingFirmwareV4') + .mockResolvedValue({ message: 'ok' }); + + await expect( + service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }), + ).rejects.toThrow('Do not support update firmware for this device'); + + expect(updatingFirmwareV4).not.toHaveBeenCalled(); + }); + + test('requires App-prepared artifacts whenever the Pro2 Plan is present', async () => { + const service = createService(); + const plan = { + executor: 'v4', + targetsToUpdate: ['boot', 'app_v1'], + }; + jest + .spyOn( + service as unknown as { + getFirmwareUpdateRuntimeHost: () => Promise; + }, + 'getFirmwareUpdateRuntimeHost', + ) + .mockResolvedValue({ + artifacts: { + getPlan: jest.fn(() => plan), + }, + }); + jest + .spyOn(service, 'createRunTaskWithRetry') + .mockImplementation(async ({ fn }) => fn({ id: 1 })); + const updatingFirmwareV4 = jest + .spyOn(service, 'updatingFirmwareV4') + .mockResolvedValue({ message: 'ok' }); + + await service.startUpdateFirmwareTaskForNewBootVersion({ + backuped: true, + usbConnected: true, + releaseResult: { + deviceType: EDeviceType.Pro2, + firmwareUpdatePlanDigest: 'c'.repeat(64), + updateInfos: {}, + } as ICheckAllFirmwareReleaseResult, + }); + + expect(updatingFirmwareV4).toHaveBeenCalledWith( + expect.objectContaining({ + requirePreparedArtifacts: true, + targetsToUpdate: ['boot', 'app_v1'], + }), + undefined, + ); + }); +}); diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts index 0036f316f070..4de9d27648e4 100644 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts +++ b/packages/kit-bg/src/services/ServiceFirmwareUpdate/ServiceFirmwareUpdate.ts @@ -1,10 +1,16 @@ -import { EDeviceType, HardwareErrorCode } from '@onekeyfe/hd-shared'; +import { + EDeviceType, + type EFirmwareType, + HardwareErrorCode, +} from '@onekeyfe/hd-shared'; import { get, isArray, isNil } from 'lodash'; import semver from 'semver'; import { + type IBackgroundMethodWithDevOnlyPassword, backgroundClass, backgroundMethod, + backgroundMethodForDev, toastIfError, } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { makeTimeoutPromise } from '@onekeyhq/shared/src/background/backgroundUtils'; @@ -85,7 +91,19 @@ import { FIRMWARE_UPDATE_MIN_VERSION_ALLOWED, } from './firmwareUpdateConsts'; import { FirmwareUpdateDetectMap } from './FirmwareUpdateDetectMap'; +import { firmwareUpdateTrace } from './FirmwareUpdateTrace'; +import type { + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from './FirmwareArtifactSelfTest'; +import type { + IFirmwareExecutionArtifacts, + IFirmwareWorkflowArtifacts, +} from './FirmwarePreparedArtifactController'; +import type { IFirmwareUpdateRuntimeHost } from './FirmwareUpdateRuntime'; +import type { IFirmwareUpdateTraceInputMode } from './FirmwareUpdateTrace'; +import type { IDBDevice } from '../../dbs/local/types'; import type { IPromiseContainerCallbackCreate, IPromiseContainerReject, @@ -97,10 +115,19 @@ import type { Success as CoreSuccess, DeviceSuccess, DeviceUploadResourceParams, + FirmwareUpdatePlanForceTarget, + FirmwareUpdateV4Target, IDeviceType, IVersionArray, } from '@onekeyfe/hd-core'; -import type { EFirmwareType } from '@onekeyfe/hd-shared'; +import type { Success } from '@onekeyfe/hd-transport'; + +let firmwareUpdateRuntimeModulePromise: + | Promise + | undefined; + +const loadFirmwareUpdateRuntime = () => + (firmwareUpdateRuntimeModulePromise ??= import('./FirmwareUpdateRuntime')); export type IAutoUpdateFirmwareParams = { connectId: string | undefined; @@ -156,28 +183,19 @@ interface IFirmwareUpdateResult { bootloaderVersion?: string; } +type IFirmwareUpdateV4AppParams = IFirmwareUpdateV3VersionParams & { + requirePreparedArtifacts: boolean; + targetsToUpdate: FirmwareUpdateV4Target[]; +}; + @backgroundClass() class ServiceFirmwareUpdate extends ServiceBase { + private firmwareUpdateRuntimeHost?: Promise; + constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); } - async getSDKInstance({ - connectId, - hardwareTransportType, - }: { - connectId: string | undefined; - hardwareTransportType?: EHardwareTransportType; - }): Promise { - const hardwareSDK = await this.backgroundApi.serviceHardware.getSDKInstance( - { - connectId, - hardwareTransportType, - }, - ); - return hardwareSDK; - } - private async getActiveTransportType(): Promise { const serviceHardware = this.backgroundApi.serviceHardware; if (typeof serviceHardware?.getCurrentTransportType === 'function') { @@ -186,15 +204,66 @@ class ServiceFirmwareUpdate extends ServiceBase { return this.backgroundApi.serviceSetting.getHardwareTransportType(); } - async clearOnceUpdateDevSettings() { - await this.backgroundApi.serviceDevSetting.updateFirmwareUpdateDevSettings({ - forceUpdateOnceFirmware: false, - forceUpdateOnceBle: false, - forceUpdateOnceBootloader: false, - pro2ForceUpdateOnceTargets: [], + private getFirmwareUpdateRuntimeHost(): Promise { + return (this.firmwareUpdateRuntimeHost ??= loadFirmwareUpdateRuntime().then( + ({ createFirmwareUpdateRuntimeHost }) => + createFirmwareUpdateRuntimeHost({ + getHardwareTransportType: () => this.getActiveTransportType(), + getSDKInstance: (connectId?: string) => + this.getSDKInstance({ connectId }), + }), + )); + } + + @backgroundMethodForDev() + startFirmwareArtifactSelfTest({ + scenario, + }: IBackgroundMethodWithDevOnlyPassword & { + scenario: IFirmwareArtifactSelfTestScenario; + }): Promise { + return this.getFirmwareUpdateRuntimeHost().then((runtime) => + runtime.selfTest.start(scenario), + ); + } + + @backgroundMethodForDev() + getFirmwareArtifactSelfTestState( + _params: IBackgroundMethodWithDevOnlyPassword, + ): Promise { + return Promise.resolve(this.firmwareUpdateRuntimeHost).then((runtime) => + runtime?.selfTest.getState(), + ); + } + + getSDKInstance({ + connectId, + hardwareTransportType, + forceFirmwareManifestRefresh, + }: { + connectId: string | undefined; + hardwareTransportType?: EHardwareTransportType; + forceFirmwareManifestRefresh?: boolean; + }): Promise { + return this.backgroundApi.serviceHardware.getSDKInstance({ + connectId, + hardwareTransportType, + ...(forceFirmwareManifestRefresh + ? { forceFirmwareManifestRefresh: true } + : {}), }); } + clearOnceUpdateDevSettings(): Promise { + return this.backgroundApi.serviceDevSetting.updateFirmwareUpdateDevSettings( + { + forceUpdateOnceFirmware: false, + forceUpdateOnceBle: false, + forceUpdateOnceBootloader: false, + pro2ForceUpdateOnceTargets: [], + }, + ); + } + @backgroundMethod() async rebootToBootloader(connectId: string): Promise { const hardwareSDK = await this.getSDKInstance({ @@ -425,6 +494,30 @@ class ServiceFirmwareUpdate extends ServiceBase { return undefined; } + private async getFirmwareUpdateDevForceTargets(): Promise< + FirmwareUpdatePlanForceTarget[] + > { + const settings = + await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettingsSnapshot(); + const targets: FirmwareUpdatePlanForceTarget[] = []; + if (settings?.forceUpdateFirmware || settings?.forceUpdateOnceFirmware) { + targets.push('firmware'); + if (settings.forceUpdateResEvenSameVersion) { + targets.push('resource'); + } + } + if (settings?.forceUpdateBle || settings?.forceUpdateOnceBle) { + targets.push('ble'); + } + if ( + settings?.forceUpdateBootloader || + settings?.forceUpdateOnceBootloader + ) { + targets.push('bootloader'); + } + return targets; + } + @backgroundMethod() @toastIfError() async checkAllFirmwareRelease({ @@ -449,12 +542,14 @@ class ServiceFirmwareUpdate extends ServiceBase { getDeviceSerialNo?: typeof hardwareSdk.getDeviceUUID; } ).getDeviceSerialNo ?? hardwareSdk.getDeviceUUID; + const forceUpdateTargets = await this.getFirmwareUpdateDevForceTargets(); - const releaseInfoCache = checkFirmwareHash - ? undefined - : this._checkCacheMeetExpectations({ - baseReleaseInfo: baseReleaseInfoCache, - }); + const releaseInfoCache = + !checkFirmwareHash && forceUpdateTargets.length === 0 + ? this._checkCacheMeetExpectations({ + baseReleaseInfo: baseReleaseInfoCache, + }) + : undefined; let resolvedTransport: | { @@ -494,6 +589,7 @@ class ServiceFirmwareUpdate extends ServiceBase { const sdk = await this.getSDKInstance({ connectId: originalConnectId, hardwareTransportType: currentTransportType, + forceFirmwareManifestRefresh: true, }); try { if (!needSkipCancel) { @@ -554,16 +650,20 @@ class ServiceFirmwareUpdate extends ServiceBase { const deviceType = await deviceUtils.getDeviceTypeFromFeatures({ features, }); - - const releaseInfo = - releaseInfoCache ?? - (await this.baseCheckAllFirmwareRelease({ - connectId: originalConnectId, - firmwareType, - skipChangeTransportType: true, - checkFirmwareHash, - resolvedTransportType: currentTransportType, - })); + const forceUpdateTargetsForDevice = isProtocolV2ProductType(deviceType) + ? [] + : forceUpdateTargets; + + const releaseInfo = releaseInfoCache?.firmwareUpdatePlan + ? releaseInfoCache + : await this.loadBaseFirmwareRelease({ + connectId: originalConnectId, + firmwareType, + skipChangeTransportType: true, + checkFirmwareHash, + resolvedTransportType: currentTransportType, + forceUpdateTargets: forceUpdateTargetsForDevice, + }); const currentFirmwareType = await deviceUtils.getFirmwareType({ features: releaseInfo.features as unknown as @@ -577,6 +677,7 @@ class ServiceFirmwareUpdate extends ServiceBase { firmwareReleasePayload: releaseInfo.firmware as unknown as IFirmwareReleasePayload, saveUpdateInfo: currentFirmwareType === firmwareType, + forceUpdate: forceUpdateTargetsForDevice.includes('firmware'), }); let ble; @@ -593,17 +694,22 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO mock bridge?.shouldUpdate bridge.shouldUpdate = true; } - - // TODO only check bootloader upgrade? - if (!bridge?.shouldUpdate && releaseInfo.bootloader) { - bootloader = await this.checkBootloaderRelease({ - connectId: updatingConnectId, - features, - firmwareUpdateInfo: firmware, - bootloaderReleasePayload: - releaseInfo.bootloader as unknown as IBootloaderReleasePayload, - }); - } + } + if ( + !bridge?.shouldUpdate && + releaseInfo.bootloader && + firmware?.toVersion && + (firmware.hasUpgrade || + forceUpdateTargetsForDevice.includes('bootloader')) + ) { + bootloader = await this.checkBootloaderRelease({ + connectId: updatingConnectId, + features, + firmwareUpdateInfo: firmware, + bootloaderReleasePayload: + releaseInfo.bootloader as unknown as IBootloaderReleasePayload, + forceUpdate: forceUpdateTargetsForDevice.includes('bootloader'), + }); } if (!bridge?.shouldUpdate) { @@ -612,6 +718,7 @@ class ServiceFirmwareUpdate extends ServiceBase { features, bleReleasePayload: releaseInfo.ble as unknown as IBleFirmwareReleasePayload, + forceUpdate: forceUpdateTargetsForDevice.includes('ble'), }); } @@ -715,15 +822,51 @@ class ServiceFirmwareUpdate extends ServiceBase { }) : undefined; + let fixedUpdatingConnectId = updatingConnectId; + try { + if (platformEnv.isSupportDesktopBle) { + const device: IDBDevice | undefined = await localDb.getDeviceByQuery({ + connectId: originalConnectId, + }); + fixedUpdatingConnectId = deviceUtils.getFixedUpdatingConnectId({ + updatingConnectId, + currentTransportType, + device, + }); + } + } catch (_error) { + // Keep the transport-derived connect ID when the local device is absent. + } + + const effectiveHasUpgrade = + hasUpgrade || Boolean(pro2TargetsToUpdate?.length); + const firmwareUpdatePlanDigest = await ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts.cachePlanDigestIfPreparedSupported({ + hasUpgrade: effectiveHasUpgrade, + plan: releaseInfo.firmwareUpdatePlan, + connectId: updatingConnectId, + transportType: currentTransportType, + expectedTargets: [ + ...new Set([ + ...forceUpdateTargetsForDevice, + ...(firmware?.hasUpgrade ? (['firmware'] as const) : []), + ...(ble?.hasUpgrade ? (['ble'] as const) : []), + ...(bootloader?.hasUpgrade ? (['bootloader'] as const) : []), + ]), + ], + }); + const result = { - updatingConnectId, + updatingConnectId: fixedUpdatingConnectId, originalConnectId, features, deviceType, deviceName, deviceBleName, deviceUUID: deviceSerialNo, - hasUpgrade: hasUpgrade || Boolean(pro2TargetsToUpdate?.length), + firmwareUpdatePlanDigest, + hasUpgrade: effectiveHasUpgrade, isBootloaderMode: features ? (await deviceUtils.getDeviceModeFromFeatures({ features })) === EOneKeyDeviceMode.bootloader @@ -759,11 +902,13 @@ class ServiceFirmwareUpdate extends ServiceBase { features, firmwareReleasePayload, saveUpdateInfo = true, + forceUpdate, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; firmwareReleasePayload: IFirmwareReleasePayload; saveUpdateInfo?: boolean; + forceUpdate?: boolean; }): Promise { const releasePayload: IFirmwareReleasePayload = { ...firmwareReleasePayload, @@ -774,11 +919,14 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check releaseInfo.version with current version // 1. manual check here // 2. auto check by event: FIRMWARE_EVENT (event emit by method calling like sdk.getFeatures()) - return this.setFirmwareUpdateInfo(releasePayload, saveUpdateInfo); + return this.setFirmwareUpdateInfo( + releasePayload, + saveUpdateInfo, + forceUpdate, + ); } - @backgroundMethod() - async baseCheckAllFirmwareRelease({ + private async loadBaseFirmwareRelease({ connectId, firmwareType, skipChangeTransportType, @@ -786,6 +934,8 @@ class ServiceFirmwareUpdate extends ServiceBase { silentMode, checkFirmwareHash, resolvedTransportType, + forceUpdateTargets, + forceFirmwareManifestRefresh, }: { connectId: string | undefined; firmwareType: EFirmwareType | undefined; @@ -794,10 +944,13 @@ class ServiceFirmwareUpdate extends ServiceBase { silentMode?: boolean; checkFirmwareHash?: boolean; resolvedTransportType?: EHardwareTransportType; + forceUpdateTargets?: FirmwareUpdatePlanForceTarget[]; + forceFirmwareManifestRefresh?: boolean; }) { const hardwareSDK = await this.getSDKInstance({ connectId, hardwareTransportType: resolvedTransportType, + forceFirmwareManifestRefresh, }); const checkBridgeRelease = await this._hasUseBridge(); let currentConnectId = connectId; @@ -809,15 +962,21 @@ class ServiceFirmwareUpdate extends ServiceBase { currentTransportType, }); } + const checkReleaseParams = { + checkBridgeRelease, + firmwareType, + platform: platformEnv.symbol ?? 'web', + retryCount, + checkFirmwareHash, + forceUpdateTargets, + }; const result = await convertDeviceResponse( () => // method fail if device on boot mode - hardwareSDK.checkAllFirmwareRelease(currentConnectId, { - checkBridgeRelease, - checkFirmwareHash, - firmwareType, - retryCount, - }), + hardwareSDK.checkAllFirmwareRelease( + currentConnectId, + checkReleaseParams, + ), { silentMode, }, @@ -826,6 +985,19 @@ class ServiceFirmwareUpdate extends ServiceBase { return result; } + @backgroundMethod() + baseCheckAllFirmwareRelease( + params: Parameters[0], + ) { + return this.loadBaseFirmwareRelease({ + ...params, + forceFirmwareManifestRefresh: true, + }).then((result) => ({ + ...result, + firmwareUpdatePlan: undefined, + })); + } + @backgroundMethod() async checkFirmwareTypeAvailable({ connectId, @@ -852,10 +1024,12 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId, features, bleReleasePayload, + forceUpdate, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; bleReleasePayload: IBleFirmwareReleasePayload; + forceUpdate?: boolean; }): Promise { const releasePayload: IBleFirmwareReleasePayload = { ...bleReleasePayload, @@ -866,7 +1040,7 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check releaseInfo.version with current version // 1. manual check here // 2. auto check by event: FIRMWARE_EVENT (event emit by method calling like sdk.getFeatures()) - return this.setBleFirmwareUpdateInfo(releasePayload); + return this.setBleFirmwareUpdateInfo(releasePayload, forceUpdate); } // TODO only for classic and mini? @@ -876,11 +1050,13 @@ class ServiceFirmwareUpdate extends ServiceBase { features, firmwareUpdateInfo, bootloaderReleasePayload, + forceUpdate, }: { connectId: string | undefined; features: IOneKeyDeviceFeatures; firmwareUpdateInfo: IFirmwareUpdateInfo; bootloaderReleasePayload: IBootloaderReleasePayload; + forceUpdate?: boolean; }): Promise { const usedReleasePayload = bootloaderReleasePayload; @@ -916,6 +1092,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType: undefined, toFirmwareType: undefined, + forceUpdate, }); const updateInfo: IBootloaderUpdateInfo = { @@ -940,6 +1117,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType, toFirmwareType, + forceUpdate, }: { releasePayload: | IFirmwareReleasePayload @@ -950,6 +1128,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion: string; fromFirmwareType: EFirmwareType | undefined; toFirmwareType: EFirmwareType | undefined; + forceUpdate?: boolean; }) { let hasUpgradeForce = false; let hasUpgrade = false; @@ -1007,56 +1186,13 @@ class ServiceFirmwareUpdate extends ServiceBase { hasUpgrade = false; } - const mockUpdateFirmware = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateFirmware', - ); - const mockUpdateOnceFirmware = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceFirmware', - ); - const mockUpdateBle = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateBle', - ); - const mockUpdateOnceBle = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceBle', - ); - const mockUpdateBootloader = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateBootloader', - ); - const mockUpdateOnceBootloader = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateOnceBootloader', - ); - const releaseFeatures = - 'features' in releasePayload ? releasePayload.features : undefined; - const protocolV2DeviceType = releaseFeatures - ? await deviceUtils.getDeviceTypeFromFeatures({ - features: releaseFeatures, - }) - : undefined; - const isPro2Device = - protocolV2DeviceType === EDeviceType.Pro2 || - protocolV2DeviceType === EDeviceType.Neo; - if ( - firmwareType === 'firmware' && - !isPro2Device && - (mockUpdateFirmware || mockUpdateOnceFirmware) - ) { - hasUpgrade = true; - } - if (firmwareType === 'ble' && (mockUpdateBle || mockUpdateOnceBle)) { - hasUpgrade = true; - } - if ( - firmwareType === 'bootloader' && - !isPro2Device && - (mockUpdateBootloader || mockUpdateOnceBootloader) - ) { + if (forceUpdate === true) { hasUpgrade = true; + } else if (forceUpdate === undefined) { + const forceUpdateTargets = await this.getFirmwareUpdateDevForceTargets(); + if (forceUpdateTargets.includes(firmwareType)) { + hasUpgrade = true; + } } return { @@ -1110,6 +1246,7 @@ class ServiceFirmwareUpdate extends ServiceBase { async setFirmwareUpdateInfo( payload: IFirmwareReleasePayload, saveUpdateInfo = true, + forceUpdate?: boolean, ): Promise { serviceHardwareUtils.hardwareLog('_checkFirmwareUpdate', payload); if (!payload?.features) { @@ -1138,6 +1275,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType, toFirmwareType, + forceUpdate, }); const updateInfo: IFirmwareUpdateInfo = { @@ -1166,7 +1304,10 @@ class ServiceFirmwareUpdate extends ServiceBase { } @backgroundMethod() - async setBleFirmwareUpdateInfo(payload: IBleFirmwareReleasePayload) { + async setBleFirmwareUpdateInfo( + payload: IBleFirmwareReleasePayload, + forceUpdate?: boolean, + ) { serviceHardwareUtils.hardwareLog('showBleFirmwareReleaseInfo', payload); if (!payload.features) { throw new OneKeyLocalError( @@ -1188,6 +1329,7 @@ class ServiceFirmwareUpdate extends ServiceBase { toVersion, fromFirmwareType: undefined, toFirmwareType: undefined, + forceUpdate, }); const updateInfo: IBleFirmwareUpdateInfo = { @@ -1211,12 +1353,40 @@ class ServiceFirmwareUpdate extends ServiceBase { return updateInfo; } - async withFirmwareUpdateEvents(fn: () => Promise): Promise { + async withFirmwareUpdateEvents( + fn: () => Promise, + executionArtifacts?: IFirmwareExecutionArtifacts, + ): Promise { const hardwareSDK = await this.getSDKInstance({ connectId: undefined, }); + const transactionId = + executionArtifacts?.preparedArtifacts?.transactionId ?? + executionArtifacts?.bridgeBinaries?.transactionId; + const executor = + executionArtifacts?.preparedArtifacts?.plan.executor ?? + executionArtifacts?.bridgeBinaries?.executor; + let inputMode: IFirmwareUpdateTraceInputMode = 'sdk-managed'; + if (executionArtifacts?.preparedArtifacts) { + inputMode = 'artifact-reader'; + } else if (executionArtifacts?.bridgeBinaries) { + inputMode = 'bridge-binary'; + } const listener = (data: any) => { serviceHardwareUtils.hardwareLog('autoUpdateFirmware', data); + const tipMessage = + get(data, 'data.message') ?? + get(data, 'payload.data.message') ?? + get(data, 'message'); + if (transactionId && typeof tipMessage === 'string') { + firmwareUpdateTrace({ + transactionId, + stage: 'sdk-tip', + executor, + inputMode, + tipMessage, + }); + } // dispatch(setUpdateFirmwareStep(get(data, 'data.message', ''))); }; hardwareSDK.on(EHardwareUiStateAction.FIRMWARE_TIP, listener); @@ -1270,7 +1440,19 @@ class ServiceFirmwareUpdate extends ServiceBase { async updatingBootloader( params: IUpdateFirmwareWorkflowParams, updateInfo: IBootloaderUpdateInfo, - ): Promise { + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'bootloaderUpdate', + ); + const { + executePreparedDeviceUpdateBootloader, + executePreparedFirmwareUpdateV2Bootloader, + } = await loadFirmwareUpdateRuntime(); const hardwareSDK = await this.getSDKInstance({ connectId: params.releaseResult.updatingConnectId, }); @@ -1300,11 +1482,12 @@ class ServiceFirmwareUpdate extends ServiceBase { }, }, }); - const result = convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV2(params.releaseResult.updatingConnectId, { - updateType: 'firmware', + const result = convertDeviceResponse(() => + executePreparedFirmwareUpdateV2Bootloader({ + sdk: hardwareSDK, + connectId: params.releaseResult.updatingConnectId, + ...executionArtifacts, platform: platformEnv.symbol ?? 'web', - isUpdateBootloader: true, }), ); return result; @@ -1320,15 +1503,15 @@ class ServiceFirmwareUpdate extends ServiceBase { }, }, }); - return convertDeviceResponse(async () => - // TODO connectId can be undefined - hardwareSDK.deviceUpdateBootloader( - params.releaseResult.updatingConnectId as string, - {}, - ), + return convertDeviceResponse(() => + executePreparedDeviceUpdateBootloader({ + sdk: hardwareSDK, + connectId: params.releaseResult.updatingConnectId as string, + ...executionArtifacts, + }), ); } - }); + }, executionArtifacts); } updatingBootloaderForTouchAndProLegacy( @@ -1389,7 +1572,17 @@ class ServiceFirmwareUpdate extends ServiceBase { { connectId, version, firmwareType, deviceType }: IAutoUpdateFirmwareParams, updateInfo: IBleFirmwareUpdateInfo | IFirmwareUpdateInfo, workflowParams: IUpdateFirmwareWorkflowParams, - ): Promise { + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV2', + ); + const { executePreparedFirmwareUpdateV2 } = + await loadFirmwareUpdateRuntime(); // const { dispatch } = this.backgroundApi; // dispatch(setUpdateFirmwareStep('')); @@ -1407,9 +1600,12 @@ class ServiceFirmwareUpdate extends ServiceBase { // const version = settings.deviceUpdates?.[connectId][firmwareType]?.version; const forceUpdateResEvenIfSameVersion = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + executionArtifacts.preparedArtifacts?.plan.targetsToUpdate.includes( + 'resource', + ) ?? + (await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( 'forceUpdateResEvenSameVersion', - ); + )); const versionArr = version.split('.').map((v) => parseInt(v, 10)); // TODO move to utils await firmwareUpdateStepInfoAtom.set({ step: EFirmwareUpdateSteps.installing, @@ -1424,20 +1620,23 @@ class ServiceFirmwareUpdate extends ServiceBase { const currentTransportType = await this.getActiveTransportType(); - const result = await convertDeviceResponse(async () => - hardwareSDK.firmwareUpdateV2( - deviceUtils.getUpdatingConnectId({ connectId, currentTransportType }), - { - updateType: firmwareType as any, - // update res is always enabled when firmware version changed - // forcedUpdateRes for TEST only, means always update res even if firmware version is same (re-flash the same firmware) - forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, - version: versionArr, - platform: platformEnv.symbol ?? 'web', - firmwareType: updateInfo.toFirmwareType, - }, - ), - ); + const updateType = firmwareType === 'ble' ? 'ble' : 'firmware'; + const result = await convertDeviceResponse(async () => { + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId, + currentTransportType, + }); + return executePreparedFirmwareUpdateV2({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + updateType, + forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, + version: versionArr, + platform: platformEnv.symbol ?? 'web', + firmwareType: updateInfo.toFirmwareType, + }); + }); if ( result && deviceType === EDeviceType.Touch && @@ -1448,7 +1647,7 @@ class ServiceFirmwareUpdate extends ServiceBase { } // TODO handleErrors UpdatingModal return result; - }); + }, executionArtifacts); } @backgroundMethod() @@ -1698,8 +1897,14 @@ class ServiceFirmwareUpdate extends ServiceBase { @backgroundMethod() async exitUpdateWorkflow() { this.closeUpdateWorkflowTracking(); - await this.updateTasksClear('exitUpdateWorkflow'); - await firmwareUpdateWorkflowRunningAtom.set(false); + try { + const { cancelFirmwareArtifactPreparations } = + await loadFirmwareUpdateRuntime(); + await cancelFirmwareArtifactPreparations(); + } finally { + await this.updateTasksClear('exitUpdateWorkflow'); + await firmwareUpdateWorkflowRunningAtom.set(false); + } } async cancelUpdateWorkflowIfExit() { @@ -1799,107 +2004,124 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.validateDeviceBattery(params); await this.validateShouldUpdateBridge(params); - // ** clear all retry tasks - await this.updateTasksClear('startUpdateWorkflow'); - - let shouldRebootAfterUpdate = false; - - const waitRebootDelayForNextPhase = async () => { - if (shouldRebootAfterUpdate) { - await this.waitDeviceRestart({ - actionType: 'nextPhase', - releaseResult: params.releaseResult, - }); - shouldRebootAfterUpdate = false; - } - }; - - // ** bootloader update - await this.cancelUpdateWorkflowIfExit(); - if (params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade) { - await waitRebootDelayForNextPhase(); - await this.startUpdateBootloaderTask(params); - - shouldRebootAfterUpdate = true; - - // await 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, - ); - - 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, - ); - - shouldRebootAfterUpdate = true; + await this.setFirmwareArtifactDownloadState(true); + await ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts.withWorkflowArtifacts( + params.releaseResult, + async (firmwareArtifacts) => { + await this.setFirmwareArtifactDownloadState(false); + // ** clear all retry tasks + await this.updateTasksClear('startUpdateWorkflow'); + + let shouldRebootAfterUpdate = false; + + const waitRebootDelayForNextPhase = async () => { + if (shouldRebootAfterUpdate) { + await this.waitDeviceRestart({ + actionType: 'nextPhase', + releaseResult: params.releaseResult, + }); + shouldRebootAfterUpdate = false; + } + }; + + // ** bootloader update + await this.cancelUpdateWorkflowIfExit(); + if (params?.releaseResult?.updateInfos?.bootloader?.hasUpgrade) { + await waitRebootDelayForNextPhase(); + await this.startUpdateBootloaderTask(params, firmwareArtifacts); + + shouldRebootAfterUpdate = true; + + // await hardware boot install and reboot + // move sdk + await this.waitDeviceRestart({ + actionType: 'boot-done', + releaseResult: params.releaseResult, + }); + } + + // TODO cancel workflow if modal closed or back + + // ** firmware update (including res update) + if (params?.releaseResult?.updateInfos?.firmware?.hasUpgrade) { + await waitRebootDelayForNextPhase(); + + const deviceType = params?.releaseResult?.deviceType; + // TODO recheck release if match with current connect device + // TODO check update version gt current version + // TODO check features matched + await this.cancelUpdateWorkflowIfExit(); + await this.startUpdateFirmwareTaskBase( + { + connectId: params?.releaseResult?.updatingConnectId, + version: + params?.releaseResult?.updateInfos?.firmware?.toVersion, + firmwareType: 'firmware', + deviceType, + }, + params?.releaseResult?.updateInfos?.firmware, + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; + } + + // ble update + if (params?.releaseResult?.updateInfos?.ble?.hasUpgrade) { + await waitRebootDelayForNextPhase(); + + const deviceType = params?.releaseResult?.deviceType; + + // TODO recheck release if match with current connect device + await this.cancelUpdateWorkflowIfExit(); + await this.startUpdateFirmwareTaskBase( + { + connectId: params?.releaseResult?.updatingConnectId, + version: params?.releaseResult?.updateInfos?.ble?.toVersion, + firmwareType: 'ble', + deviceType, + }, + params?.releaseResult?.updateInfos?.ble, + params, + firmwareArtifacts, + ); + + shouldRebootAfterUpdate = true; + + await this.waitDeviceRestart({ + actionType: 'ble-done', + releaseResult: params.releaseResult, + }); + } - await this.waitDeviceRestart({ - actionType: 'ble-done', - releaseResult: params.releaseResult, - }); - } - - serviceHardwareUtils.hardwareLog('startUpdateWorkflow DONE', params); + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', + params, + ); - await firmwareUpdateRetryAtom.set(undefined); - if (params.releaseResult.originalConnectId) { - await this.waitDeviceRestart({ - actionType: 'done', - releaseResult: params.releaseResult, - }); - await this.detectMap.deleteUpdateInfo({ - connectId: params.releaseResult.originalConnectId, - }); - await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( - params, - ); - await this.clearOnceUpdateDevSettings(); - appEventBus.emit(EAppEventBusNames.FinishFirmwareUpdate, undefined); - } + await 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(); @@ -2029,6 +2251,22 @@ class ServiceFirmwareUpdate extends ServiceBase { } } + private async setFirmwareArtifactDownloadState( + isDownloadingArtifacts: boolean, + ) { + const stepInfo = await firmwareUpdateStepInfoAtom.get(); + if (stepInfo.step !== EFirmwareUpdateSteps.updateStart) { + return; + } + await firmwareUpdateStepInfoAtom.set({ + step: EFirmwareUpdateSteps.updateStart, + payload: { + ...stepInfo.payload, + isDownloadingArtifacts, + }, + }); + } + async runUpdateWorkflowV2(params: IUpdateFirmwareWorkflowParams) { try { await this.backgroundApi.serviceHardwareUI.withHardwareProcessing( @@ -2061,53 +2299,64 @@ class ServiceFirmwareUpdate extends ServiceBase { await this.validateDeviceBattery(params); await this.validateShouldUpdateBridge(params); - // ** clear all retry tasks - await this.updateTasksClear('startUpdateWorkflow'); - - await this.cancelUpdateWorkflowIfExit(); - - const deviceType = params?.releaseResult?.deviceType; - if ( - deviceType !== EDeviceType.Pro && - !isProtocolV2ProductType(deviceType) - ) { - throw new OneKeyLocalError( - 'Do not support update firmware for this device', - ); - } - - const updateResult = - await this.startUpdateFirmwareTaskForNewBootVersion(params); - console.log( - 'startUpdateFirmwareTaskForNewBootVersion result: ===> ', - updateResult, - ); - - serviceHardwareUtils.hardwareLog( - 'startUpdateWorkflow DONE', - params, + await 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, + ); + console.log( + 'startUpdateFirmwareTaskForNewBootVersion result: ===> ', + updateResult, + ); + + serviceHardwareUtils.hardwareLog( + 'startUpdateWorkflow DONE', + params, + ); + + await firmwareUpdateRetryAtom.set(undefined); + if (params.releaseResult.originalConnectId) { + await this.waitDeviceRestart({ + actionType: 'done', + releaseResult: params.releaseResult, + }); + await this.detectMap.deleteUpdateInfo({ + connectId: params.releaseResult.originalConnectId, + }); + await this.backgroundApi.serviceHardware.updateDeviceVersionAfterFirmwareUpdate( + params, + ); + await this.clearOnceUpdateDevSettings(); + appEventBus.emit( + EAppEventBusNames.FinishFirmwareUpdate, + undefined, + ); + } + // wait verify + await timerUtils.wait(2000); + }, ); - - 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 { if (shouldClearForceTransportType) { // Always clear transport type lock when firmware update completes @@ -2165,7 +2414,10 @@ class ServiceFirmwareUpdate extends ServiceBase { return { backgroundTaskStarted: true }; } - async startUpdateBootloaderTask(params: IUpdateFirmwareWorkflowParams) { + async startUpdateBootloaderTask( + params: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ) { const firmwareUpdateInfo = params?.releaseResult?.updateInfos?.firmware; const firmwareToVersion = firmwareUpdateInfo?.toVersion; if (!firmwareUpdateInfo || !firmwareToVersion) { @@ -2180,7 +2432,7 @@ class ServiceFirmwareUpdate extends ServiceBase { }); // TODO move to fn - const releaseInfo = await this.baseCheckAllFirmwareRelease({ + const releaseInfo = await this.loadBaseFirmwareRelease({ connectId: params?.releaseResult?.updatingConnectId, firmwareType: params?.releaseResult?.updateInfos?.firmware?.toFirmwareType, @@ -2204,7 +2456,8 @@ class ServiceFirmwareUpdate extends ServiceBase { // TODO check update version gt current version if (updateInfo?.hasUpgrade || mockUpdateBootloader) { return this.createRunTaskWithRetry({ - fn: async () => this.updatingBootloader(params, updateInfo), + fn: async () => + this.updatingBootloader(params, updateInfo, firmwareArtifacts), }); } } @@ -2213,9 +2466,16 @@ class ServiceFirmwareUpdate extends ServiceBase { params: IAutoUpdateFirmwareParams, updateInfo: IBleFirmwareUpdateInfo | IFirmwareUpdateInfo, workflowParams: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ) { return this.createRunTaskWithRetry({ - fn: async () => this.updatingFirmware(params, updateInfo, workflowParams), + fn: async () => + this.updatingFirmware( + params, + updateInfo, + workflowParams, + firmwareArtifacts, + ), }); } @@ -2422,13 +2682,23 @@ class ServiceFirmwareUpdate extends ServiceBase { async startUpdateFirmwareTaskForNewBootVersion( params: IUpdateFirmwareWorkflowParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, ): Promise { const { releaseResult } = params; const { updateInfos } = releaseResult; // Keep the legacy field name while routing every Protocol V2 product through V4. - const isPro2Device = - releaseResult.deviceType === EDeviceType.Pro2 || - releaseResult.deviceType === EDeviceType.Neo; + const isPro2Device = isProtocolV2ProductType(releaseResult.deviceType); + const plan = releaseResult.firmwareUpdatePlanDigest + ? (await this.getFirmwareUpdateRuntimeHost()).artifacts.getPlan( + releaseResult, + ) + : undefined; + if (!plan && isPro2Device) { + throw new OneKeyLocalError( + 'Do not support update firmware for this device', + ); + } + const executor = plan?.executor ?? 'v3'; const updateParams: IFirmwareUpdateV3VersionParams = { connectId: releaseResult.updatingConnectId, @@ -2445,14 +2715,115 @@ 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); + return this.createRunTaskWithRetry({ + fn: async () => + this.updatingFirmwareV4( + { + ...updateParams, + requirePreparedArtifacts: Boolean( + releaseResult.firmwareUpdatePlanDigest, + ), + targetsToUpdate, + }, + firmwareArtifacts, + ), + }) as Promise; + } + if (executor !== 'v3') { + throw new OneKeyLocalError( + 'Firmware update plan selected an incompatible executor', + ); + } return this.createRunTaskWithRetry({ - fn: async () => this.updatingFirmwareV3(updateParams), + fn: async () => this.updatingFirmwareV3(updateParams, firmwareArtifacts), }) as Promise; } + async updatingFirmwareV4( + params: IFirmwareUpdateV4AppParams, + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV4', + ); + const { assertFirmwareUpdateV4Artifacts, executePreparedFirmwareUpdateV4 } = + await loadFirmwareUpdateRuntime(); + if (params.requirePreparedArtifacts) { + assertFirmwareUpdateV4Artifacts(executionArtifacts); + } + const currentTransportType = await this.getActiveTransportType(); + const hardwareSDK = await this.getSDKInstance({ + connectId: params.connectId, + hardwareTransportType: currentTransportType, + }); + + return this.withFirmwareUpdateEvents(async () => { + await firmwareUpdateStepInfoAtom.set({ + step: EFirmwareUpdateSteps.installing, + payload: { + installingTarget: {} as any, + }, + }); + if (params.requirePreparedArtifacts) { + assertFirmwareUpdateV4Artifacts( + executionArtifacts, + currentTransportType, + ); + } + const updatingConnectId = deviceUtils.getUpdatingConnectId({ + connectId: params.connectId, + currentTransportType, + }); + const forceUpdateResEvenIfSameVersion = + await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( + 'forceUpdateResEvenSameVersion', + ); + const updateResult = await convertDeviceResponse(() => + executePreparedFirmwareUpdateV4({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + platform: platformEnv.symbol ?? 'web', + firmwareType: params.firmwareType, + targetsToUpdate: params.targetsToUpdate, + forcedUpdateRes: forceUpdateResEvenIfSameVersion === true, + }), + ); + + await firmwareUpdateResultVerifyAtom.set({ + finalBleVersion: updateResult?.bleVersion || '', + finalFirmwareVersion: updateResult?.firmwareVersion || '', + finalBootloaderVersion: updateResult?.bootloaderVersion || '', + }); + return { message: 'success', ...updateResult }; + }, executionArtifacts); + } + async updatingFirmwareV3( params: IFirmwareUpdateV3VersionParams, - ): Promise { + firmwareArtifacts?: IFirmwareWorkflowArtifacts, + ): Promise { + const preparedArtifactController = ( + await this.getFirmwareUpdateRuntimeHost() + ).artifacts; + const executionArtifacts = preparedArtifactController.getExecutionArtifacts( + firmwareArtifacts, + 'firmwareUpdateV3', + ); + const { executePreparedFirmwareUpdateV3 } = + await loadFirmwareUpdateRuntime(); const currentTransportType = await this.getActiveTransportType(); const hardwareSDK = await this.getSDKInstance({ connectId: params.connectId, @@ -2479,11 +2850,6 @@ class ServiceFirmwareUpdate extends ServiceBase { const toBleVersion = convertVersion(params.bleVersion); const toBootloaderVersion = convertVersion(params.bootloaderVersion); const versionMismatches: string[] = []; - const forceUpdateResEvenIfSameVersion = - await this.backgroundApi.serviceDevSetting.getFirmwareUpdateDevSettings( - 'forceUpdateResEvenSameVersion', - ); - const forcedUpdateRes = forceUpdateResEvenIfSameVersion === true; const shouldVerifyFirmwareVersion = !params.isPro2Device || !params.pro2TargetsToUpdate?.length || @@ -2496,22 +2862,17 @@ class ServiceFirmwareUpdate extends ServiceBase { connectId, currentTransportType, }); - const updateResult = await convertDeviceResponse(async () => - params.isPro2Device - ? hardwareSDK.firmwareUpdateV4(updatingConnectId, { - platform: platformEnv.symbol ?? 'web', - forcedUpdateRes, - firmwareType: params.firmwareType, - targetsToUpdate: params.pro2TargetsToUpdate, - }) - : hardwareSDK.firmwareUpdateV3(updatingConnectId, { - platform: platformEnv.symbol ?? 'web', - bleVersion: toBleVersion, - firmwareVersion: toFirmwareVersion, - bootloaderVersion: toBootloaderVersion, - forcedUpdateRes, - firmwareType: params.firmwareType, - }), + const updateResult = await convertDeviceResponse(() => + executePreparedFirmwareUpdateV3({ + sdk: hardwareSDK, + connectId: updatingConnectId, + ...executionArtifacts, + platform: platformEnv.symbol ?? 'web', + bleVersion: toBleVersion, + firmwareVersion: toFirmwareVersion, + bootloaderVersion: toBootloaderVersion, + firmwareType: params.firmwareType, + }), ); // verify final version @@ -2560,7 +2921,7 @@ class ServiceFirmwareUpdate extends ServiceBase { console.log('updatingFirmwareV3 error: ', error); throw error; } - }); + }, executionArtifacts); } async validateShouldUpdateFullResource( diff --git a/packages/kit-bg/src/services/ServiceFirmwareUpdate/firewareUpdateFixtures.ts b/packages/kit-bg/src/services/ServiceFirmwareUpdate/firewareUpdateFixtures.ts deleted file mode 100644 index 387be4f91833..000000000000 --- a/packages/kit-bg/src/services/ServiceFirmwareUpdate/firewareUpdateFixtures.ts +++ /dev/null @@ -1,390 +0,0 @@ -export const FIRMWARE_UPDATE_UPDATE_INFO_SAMPLE = { - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - 'deviceType': 'touch', - 'deviceName': 'OneKey Touch', - 'deviceUUID': 'TC01WBD202207290844270000046', - 'hasUpgrade': true, - 'isBootloaderMode': false, - 'updateInfos': { - 'firmware': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '4.9.0', - 'toVersion': '4.9.1', - 'releasePayload': { - 'status': 'outdated', - 'changelog': [ - { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [4, 9, 1], - 'bootloaderResource': - 'https://web.onekey-asset.com/hardware/touch/bootloader/v2.4.9/bootloader.2.4.9-Stable-0807-3e033eb.signed.bin', - 'bootloaderVersion': [2, 4, 8], - 'fullResource': - 'https://web.onekey-asset.com/hardware/touch/resource/res-4.9.0.zip', - 'fullResourceRange': ['3.5.0', '4.0.0'], - 'resource': - 'https://web.onekey-asset.com/hardware/touch/resource/resource-4.0.0-4.9.0.zip', - 'url': - 'https://web.onekey-asset.com/hardware/touch/firmware/v4.9.0/touch.4.9.0-Stable-0410-fccbac8.signed.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - }, - 'bootloaderMode': false, - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [ - 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, - ], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - }, - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.', - }, - 'firmwareType': 'firmware', - }, - 'ble': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '2.1.0', - 'toVersion': '2.1.1', - 'releasePayload': { - 'status': 'outdated', - 'changelog': [ - { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [2, 1, 1], - 'webUpdate': - 'https://common.onekey-asset.com/hw/touch/2.1.0/touch_ble_signed-2022-1102_2.1.0.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - }, - 'bootloaderMode': false, - 'features': { - 'vendor': 'trezor.io', - 'major_version': 2, - 'minor_version': 99, - 'patch_version': 99, - 'bootloader_mode': null, - 'device_id': '851D737A09073D51A26A8EEB', - 'pin_protection': true, - 'passphrase_protection': null, - 'language': 'zh_hk', - 'label': 'OneKey Touch', - 'initialized': true, - 'revision': 'fccbac81cef7877e6e3b761677ff0d83808380ad', - 'bootloader_hash': null, - 'imported': null, - 'unlocked': false, - '_passphrase_cached': null, - 'firmware_present': null, - 'needs_backup': null, - 'flags': null, - 'model': 'T', - 'fw_major': null, - 'fw_minor': null, - 'fw_patch': null, - 'fw_vendor': null, - 'unfinished_backup': null, - 'no_backup': null, - 'recovery_mode': null, - 'capabilities': [ - 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, - ], - 'sd_card_present': true, - 'sd_protection': null, - 'wipe_code_protection': null, - 'session_id': - '47d8463911f0354f4dc1fa45d403984c43a921926f04bd7f6581c52e6973bd2d', - 'passphrase_always_on_device': null, - 'auto_lock_delay_ms': null, - 'display_rotation': null, - 'experimental_features': null, - 'offset': null, - 'ble_name': 'Touch 3F3B', - 'ble_ver': '2.1.0', - 'ble_enable': true, - 'se_enable': null, - 'se_ver': null, - 'backup_only': null, - 'onekey_version': '4.9.0', - 'onekey_serial': null, - 'bootloader_version': '2.4.9', - 'serial_no': 'TC01WBD202207290844270000046', - 'spi_flash': null, - 'initstates': null, - 'NFT_voucher': null, - 'cpu_info': null, - 'pre_firmware': null, - 'coin_switch': null, - 'build_id': - '746f7563682e342e392e302d537461626c652d303431302d66636362616338', - 'boardloader_version': '1.0.0', - 'busy': false, - 'onekey_device_type': 'TOUCH', - 'onekey_se_type': 'SE608A', - 'onekey_board_version': '1.0.0', - 'onekey_board_hash': null, - 'onekey_boot_version': '2.4.9', - 'onekey_boot_hash': null, - 'onekey_se01_version': null, - 'onekey_se01_hash': null, - 'onekey_se01_build_id': null, - 'onekey_firmware_version': '4.9.0', - 'onekey_firmware_hash': null, - 'onekey_firmware_build_id': null, - 'onekey_serial_no': 'TC01WBD202207290844270000046', - 'onekey_boot_build_id': null, - 'onekey_ble_name': 'Touch 3F3B', - 'onekey_ble_version': '2.1.0', - 'onekey_ble_build_id': null, - 'onekey_ble_hash': null, - 'onekey_se02_version': null, - 'onekey_se03_version': null, - 'onekey_se04_version': null, - }, - }, - 'changelog': { - 'zh-CN': - '### 🐞 问题修复\r\n- 充电电量显示问题\r\n- 关闭蓝牙后,能够正确关机\r\n', - 'en-US': - '### 🐞 Bug Fixes\r\n- Fixed charging power display problem\r\n- After turning off bluetooth, it can shut down properly\r\n', - }, - 'firmwareType': 'ble', - }, - 'bootloader': { - 'hasUpgrade': true, - 'hasUpgradeForce': false, - 'fromVersion': '2.4.9', - 'toVersion': '2.4.8', - 'releasePayload': { - 'status': 'valid', - 'changelog': [ - { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - ], - 'release': { - 'required': false, - 'version': [4, 9, 1], - 'bootloaderResource': - 'https://web.onekey-asset.com/hardware/touch/bootloader/v2.4.9/bootloader.2.4.9-Stable-0807-3e033eb.signed.bin', - 'bootloaderVersion': [2, 4, 8], - 'fullResource': - 'https://web.onekey-asset.com/hardware/touch/resource/res-4.9.0.zip', - 'fullResourceRange': ['3.5.0', '4.0.0'], - 'resource': - 'https://web.onekey-asset.com/hardware/touch/resource/resource-4.0.0-4.9.0.zip', - 'url': - 'https://web.onekey-asset.com/hardware/touch/firmware/v4.9.0/touch.4.9.0-Stable-0410-fccbac8.signed.bin', - 'fingerprint': '', - 'changelog': { - 'zh-CN': - '### ✨ 新功能\r\n- 支持 Manta, Neurai 以及 Nervos 网络\r\n- 支持 LNURL Auth 授权签名\r\n- 支持在设备信息中,查看固件版本号\r\n- 新增在 Celestia 网络下的精度展示\r\n\r\n### 🐞 问题修复\r\n- 修复在 Astar 网络下,查看转账数据卡死的问题\r\n- 修复在闪电网络下,签名信息页面文案缺失的问题\r\n- 修复在核对助记词过程中异常退出,再次进入未触发校验PIN码及退出锁屏失效的问题\r\n\r\n### 💎 改进\r\n- 优化在 Sui 网络下,分包处理签名数据的逻辑\r\n- 优化在 BTC 网络下的签名交易过程中,OP_RETURN 可展示原文\r\n- 优化在波卡网络下,将支持展示更多的签名信息细节\r\n', - 'en-US': - '### ✨ New Features\r\n- Support for Manta, Neurai, and Nervos networks.\r\n- Support for LNURL Auth authorization signing.\r\n- Ability to view firmware version in device information.\r\n- New precision display under the Celestia network.\r\n\r\n### 🐞 Bug Fixes\r\n- Fixed a freeze when viewing transfer data on the Astar network.\r\n- Fixed missing text on the signature information page on the Lightning network.\r\n- Fixed an issue where re-entering the app after an abnormal exit during recovery phrase verification did not trigger PIN verification and the lock screen was ineffective.\r\n\r\n### 💎 Improvements\r\n- Optimized packet handling logic for signing data on the Sui network.\r\n- Enhanced the display of original text in OP_RETURN during signing transactions on the BTC network.\r\n- Improved the display of detailed signature information on the Polkadot network.\r\n', - }, - }, - 'bootloaderMode': false, - 'shouldUpdate': false, - }, - 'changelog': { - 'zh-CN': '### 🐞 问题修复\r\n- xxxxx', - 'en-US': '### 🐞 Bug Fixes\r\n- xxxx\r\n', - }, - 'firmwareType': 'bootloader', - }, - 'bridge': { - 'shouldUpdate': false, - 'status': 'valid', - 'releaseVersion': '2.2.0', - }, - }, -} as const; diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts index fbcb50266391..5b6781bda51c 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.connect.test.ts @@ -617,6 +617,41 @@ describe('ServiceHardware.connect WebUSB reuse', () => { }, ); + it('uses the explicit Android USB transport without running stale BLE prechecks', async () => { + mutablePlatformEnv.isNative = true; + mutablePlatformEnv.isNativeAndroid = true; + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.BLE), + }, + } as unknown as IBackgroundApi, + }); + const connectDevice = jest + .spyOn(service, 'connectDevice') + .mockResolvedValue({ label: 'OneKey' } as Features); + + await expect( + service.connect({ + device: buildDevice({ connectProtocol: 'V1' }), + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).resolves.toEqual({ label: 'OneKey' }); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(connectDevice).toHaveBeenCalledWith({ + connectId: 'USB_SERIAL', + params: { connectProtocol: 'V1' }, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }); + }); + it('按设备及 USB/BLE 端点隔离绑定已确认协议', async () => { const setDeviceConnectProtocol = jest.fn(); const service = new ServiceHardware({ diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts index 6f42d90c69ce..097efdb70d2f 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.getCompatibleConnectId.test.ts @@ -1,10 +1,18 @@ import { DeviceSessionPinType } from '@onekeyfe/hd-transport'; import axios from 'axios'; +import { + EAppEventBusNames, + appEventBus, +} from '@onekeyhq/shared/src/eventBus/appEventBus'; import { checkBLEPermissions, checkBLEState, } from '@onekeyhq/shared/src/hardware/blePermissions'; +import { + getHardwareSDKInstance, + resetHardwareSDKInstance, +} from '@onekeyhq/shared/src/hardware/instance'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { EHardwareTransportType } from '@onekeyhq/shared/types'; import { @@ -15,13 +23,14 @@ import type { IOneKeyDeviceFeaturesWithAppParams } from '@onekeyhq/shared/types/ import localDb from '../../dbs/local/localDb'; import { hardwareForceTransportAtom } from '../../states/jotai/atoms'; +import { getFirmwareManifestSnapshot } from '../ServiceFirmwareUpdate/FirmwareManifestProvider'; import { HardwareConnectionManager } from './HardwareConnectionManager'; import ServiceHardware from './ServiceHardware'; import type { IBackgroundApi } from '../../apis/IBackgroundApi'; import type { IDBDevice } from '../../dbs/local/types'; -import type { SearchDevice } from '@onekeyfe/hd-core'; +import type { RemoteConfigResponse, SearchDevice } from '@onekeyfe/hd-core'; jest.mock('@onekeyhq/shared/src/background/backgroundDecorators', () => ({ backgroundClass: () => (target: unknown) => target, @@ -72,6 +81,12 @@ jest.mock('axios', () => ({ }, })); +jest.mock('@onekeyhq/shared/src/hardware/instance', () => ({ + CoreSDKLoader: jest.fn(), + getHardwareSDKInstance: jest.fn(), + resetHardwareSDKInstance: jest.fn(), +})); + jest.mock('@onekeyhq/shared/src/utils/deviceHomeScreenUtils', () => ({ __esModule: true, DEFAULT_T1_HOME_SCREEN_INFORMATION: {}, @@ -108,7 +123,13 @@ jest.mock('../../states/jotai/atoms', () => ({ }, hardwareUiStateAtom: {}, hardwareUiStateCompletedAtom: {}, - settingsPersistAtom: {}, + settingsPersistAtom: { + get: jest.fn(async () => ({ hardwareConnectSrc: undefined })), + }, +})); + +jest.mock('../ServiceFirmwareUpdate/FirmwareManifestProvider', () => ({ + getFirmwareManifestSnapshot: jest.fn(), })); jest.mock('../../states/jotai/atoms/desktopBluetooth', () => ({ @@ -126,6 +147,12 @@ const mockedLocalDb = jest.mocked(localDb); const mockedCheckBLEPermissions = jest.mocked(checkBLEPermissions); const mockedCheckBLEState = jest.mocked(checkBLEState); const mockedAxios = jest.mocked(axios); +const mockedAppEventBus = jest.mocked(appEventBus); +const mockedGetFirmwareManifestSnapshot = jest.mocked( + getFirmwareManifestSnapshot, +); +const mockedGetHardwareSDKInstance = jest.mocked(getHardwareSDKInstance); +const mockedResetHardwareSDKInstance = jest.mocked(resetHardwareSDKInstance); const mutablePlatformEnv = platformEnv as unknown as { isDesktop: boolean; isJest: boolean; @@ -190,7 +217,13 @@ describe('ServiceHardware.getCompatibleConnectId', () => { await expect( new ServiceHardware({ - backgroundApi: {} as unknown as IBackgroundApi, + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest + .fn() + .mockResolvedValue(EHardwareTransportType.BLE), + }, + } as unknown as IBackgroundApi, }).getCompatibleConnectId({ connectId: 'PRB09B0088A', featuresDeviceId: 'STALE_DEVICE_ID', @@ -1041,6 +1074,345 @@ describe('ServiceHardware.getCompatibleConnectId', () => { ); }); + it('reloads the SDK only when a forced manifest refresh changes the snapshot', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + const manifest1 = { bridge: { version: '1' } }; + const manifest2 = { bridge: { version: '2' } }; + mockedGetFirmwareManifestSnapshot + .mockResolvedValueOnce(manifest1 as unknown as RemoteConfigResponse) + .mockResolvedValueOnce(manifest1 as unknown as RemoteConfigResponse) + .mockResolvedValueOnce(manifest2 as unknown as RemoteConfigResponse); + mockedGetHardwareSDKInstance.mockImplementation(async (params) => { + await params.loadFirmwareConfig?.(); + return {} as Awaited>; + }); + + const service = new ServiceHardware({ + backgroundApi: { + serviceApp: { + showToast: jest.fn(), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceSetting: { + setHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.connectionManager.getCurrentTransportType = jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn( + service as unknown as { + registerSdkEvents: () => Promise; + }, + 'registerSdkEvents', + ) + .mockResolvedValue(undefined); + + const options = { + connectId: undefined, + forceFirmwareManifestRefresh: true, + }; + await service.getSDKInstance(options); + await service.getSDKInstance(options); + expect(mockedResetHardwareSDKInstance).not.toHaveBeenCalled(); + + await service.getSDKInstance(options); + expect(mockedResetHardwareSDKInstance).toHaveBeenCalledTimes(1); + expect(mockedGetFirmwareManifestSnapshot).toHaveBeenCalledTimes(3); + expect(mockedGetFirmwareManifestSnapshot).toHaveBeenLastCalledWith({ + preRelease: false, + forceRefresh: true, + }); + }); + + it('keeps ordinary hardware available without a manifest and reloads after recovery', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + const recoveredManifest = { bridge: { version: 'recovered' } }; + mockedGetFirmwareManifestSnapshot + .mockRejectedValueOnce(new Error('manifest unavailable')) + .mockResolvedValueOnce( + recoveredManifest as unknown as RemoteConfigResponse, + ); + const loadedConfigs: Array = []; + mockedGetHardwareSDKInstance.mockImplementation(async (params) => { + loadedConfigs.push(await params.loadFirmwareConfig?.()); + return {} as Awaited>; + }); + + const service = new ServiceHardware({ + backgroundApi: { + serviceApp: { + showToast: jest.fn(), + }, + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + serviceSetting: { + setHardwareTransportType: jest.fn(), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + service.connectionManager.getCurrentTransportType = jest + .fn() + .mockResolvedValue(EHardwareTransportType.WEBUSB); + jest + .spyOn( + service as unknown as { + registerSdkEvents: () => Promise; + }, + 'registerSdkEvents', + ) + .mockResolvedValue(undefined); + + await expect( + service.getSDKInstance({ connectId: undefined }), + ).resolves.toBeDefined(); + expect(loadedConfigs).toEqual([undefined]); + expect(mockedResetHardwareSDKInstance).not.toHaveBeenCalled(); + + await expect( + service.getSDKInstance({ + connectId: undefined, + forceFirmwareManifestRefresh: true, + }), + ).resolves.toBeDefined(); + expect(mockedResetHardwareSDKInstance).toHaveBeenCalledTimes(1); + expect(loadedConfigs).toEqual([undefined, recoveredManifest]); + }); + + it('keeps an explicit firmware manifest refresh fail-closed', async () => { + Object.assign(mutablePlatformEnv, { + isSupportDesktopBle: false, + }); + mockedGetFirmwareManifestSnapshot.mockRejectedValueOnce( + new Error('manifest unavailable'), + ); + + const service = new ServiceHardware({ + backgroundApi: { + serviceDevSetting: { + getFirmwareUpdateDevSettings: jest.fn().mockResolvedValue(false), + }, + } as unknown as IBackgroundApi, + }); + service.checkSdkVersionValid = jest.fn(); + + await expect( + service.getSDKInstance({ + connectId: undefined, + forceFirmwareManifestRefresh: true, + }), + ).rejects.toThrow('manifest unavailable'); + expect(mockedGetHardwareSDKInstance).not.toHaveBeenCalled(); + }); + + it('shows BLE permission guidance before Android user hardware calls when permission is missing', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).rejects.toThrow('NeedBluetoothPermissions'); + + expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ + EAppEventBusNames.RequestHardwareUIDialog, + { + uiRequestType: 'ui-location_permission', + }, + ]); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); + }); + + it('shows Bluetooth settings guidance before Android user hardware calls when Bluetooth is off', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(true); + mockedCheckBLEState.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).rejects.toThrow('NeedBluetoothTurnedOn'); + + expect(mockedAppEventBus.emit.mock.calls).toContainEqual([ + EAppEventBusNames.RequestHardwareUIDialog, + { + uiRequestType: 'ui-bluetooth_permission', + }, + ]); + expect(mockedLocalDb.getDeviceByQuery.mock.calls).toHaveLength(1); + }); + + it('does not run BLE prechecks for an explicit Android USB call after BLE was active', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.BLE, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).resolves.toBe('ANDROID_USB_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + + it('runs BLE prechecks for an explicit Android BLE call after USB was active', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + + const service = new ServiceHardware({ + backgroundApi: { + serviceSetting: { + getHardwareTransportType: jest.fn( + async () => EHardwareTransportType.WEBUSB, + ), + }, + } as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: EHardwareTransportType.BLE, + }), + ).rejects.toThrow('NeedBluetoothPermissions'); + + expect(mockedCheckBLEPermissions).toHaveBeenCalledTimes(1); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + }); + + it('does not run BLE prechecks for Android third-party USB devices', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + mockedLocalDb.getDeviceByQuery.mockResolvedValue({ + id: 'db-device-1', + connectId: 'USB_ID', + usbConnectId: 'USB_ID', + deviceId: 'FEATURES_DEVICE_ID', + vendor: EHardwareVendor.trezor, + name: 'Trezor Safe 7', + features: '{}', + settingsRaw: '{}', + createdAt: 0, + updatedAt: 0, + } as IDBDevice); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'USB_ID', + hardwareCallContext: EHardwareCallContext.USER_INTERACTION, + }), + ).resolves.toBe('USB_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedCheckBLEState).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + + it('does not show BLE permission guidance for Android background hardware calls', async () => { + Object.assign(mutablePlatformEnv, { + isDesktop: false, + isSupportDesktopBle: false, + isNative: true, + isNativeAndroid: true, + }); + mockedCheckBLEPermissions.mockResolvedValue(false); + mockedLocalDb.getDeviceByQuery.mockResolvedValue(undefined); + + const service = new ServiceHardware({ + backgroundApi: {} as unknown as IBackgroundApi, + }); + + await expect( + service.getCompatibleConnectId({ + connectId: 'ANDROID_BLE_ID', + hardwareCallContext: EHardwareCallContext.BACKGROUND_TASK, + }), + ).resolves.toBe('ANDROID_BLE_ID'); + + expect(mockedCheckBLEPermissions).not.toHaveBeenCalled(); + expect(mockedAppEventBus.emit.mock.calls).toHaveLength(0); + }); + it('keeps OneKey standard wallet EVM address lookup on empty passphrase', async () => { const evmGetAddress = jest.fn().mockResolvedValue({ success: true, diff --git a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts index e92b626c24df..35b2ff9ff3e9 100644 --- a/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts +++ b/packages/kit-bg/src/services/ServiceHardware/ServiceHardware.ts @@ -23,6 +23,10 @@ import { EAppEventBusNames, appEventBus, } from '@onekeyhq/shared/src/eventBus/appEventBus'; +import { + checkBLEPermissions, + checkBLEState, +} from '@onekeyhq/shared/src/hardware/blePermissions'; import { DESKTOP_BLE_FIRMWARE_CONNECTION_TIMEOUT_MS } from '@onekeyhq/shared/src/hardware/connectionTimeouts'; import { projectLegacyDeviceFeaturesFromState } from '@onekeyhq/shared/src/hardware/deviceStateUtils'; import { @@ -79,6 +83,7 @@ import { settingsPersistAtom, } from '../../states/jotai/atoms'; import ServiceBase from '../ServiceBase'; +import { getFirmwareManifestSnapshot } from '../ServiceFirmwareUpdate/FirmwareManifestProvider'; import { DeviceSettingsManager } from './DeviceSettingsManager'; import { HardwareConnectionManager } from './HardwareConnectionManager'; @@ -186,12 +191,26 @@ type IProtocolAwareCoreApi = CoreApi & { ) => void; }; +type IProtocolV2NftCoreApi = CoreApi & { + deviceUploadNft?: ( + connectId: string, + params: { + image: { width: number; height: number; rgba: Uint8Array }; + thumbnail: { width: number; height: number; rgba: Uint8Array }; + title: string; + subtitle: string; + timestampMs?: number; + }, + ) => ReturnType; +}; + type IGetSDKInstanceOptions = { connectId: string | undefined; connectProtocol?: HardwareConnectProtocol; forceProtocolDetection?: boolean; hardwareCallContext?: EHardwareCallContext; hardwareTransportType?: EHardwareTransportType; + forceFirmwareManifestRefresh?: boolean; }; function isHardwareConnectProtocol( @@ -769,6 +788,10 @@ class ServiceHardware extends ServiceBase { this.hardwareUiEventState = createHardwareUiEventState(); } + private firmwareManifestRefreshMutex = new Semaphore(1); + + private loadedFirmwareManifestKey: string | undefined; + checkSdkVersionValid() { if (process.env.NODE_ENV !== 'production') { const { @@ -808,6 +831,18 @@ class ServiceHardware extends ServiceBase { private async getSDKInstanceWithLifecycleLock( options: IGetSDKInstanceOptions, ) { + if ( + options.forceFirmwareManifestRefresh && + (platformEnv.isNative || platformEnv.isDesktop) + ) { + return this.firmwareManifestRefreshMutex.runExclusive(() => + this.getSDKInstanceInternal(options), + ); + } + return this.getSDKInstanceInternal(options); + } + + private async getSDKInstanceInternal(options: IGetSDKInstanceOptions) { const { hardwareCallContext = EHardwareCallContext.USER_INTERACTION } = options || {}; this.checkSdkVersionValid(); @@ -839,6 +874,30 @@ class ServiceHardware extends ServiceBase { platformEnv.isDev === true && (platformEnv.isNative === true || platformEnv.isDesktop === true) && debugMode === true; + const isAppManagedManifest = Boolean( + platformEnv.isNative || platformEnv.isDesktop, + ); + const refreshedFirmwareManifest = + options.forceFirmwareManifestRefresh && isAppManagedManifest + ? await getFirmwareManifestSnapshot({ + preRelease: isPreRelease === true, + forceRefresh: true, + }) + : undefined; + const refreshedFirmwareManifestKey = refreshedFirmwareManifest + ? stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config: refreshedFirmwareManifest, + }) + : undefined; + if ( + refreshedFirmwareManifestKey && + this.loadedFirmwareManifestKey && + refreshedFirmwareManifestKey !== this.loadedFirmwareManifestKey + ) { + await resetHardwareSDKInstance(); + this.registeredEvents = false; + } if ( this.registeredEvents && @@ -910,6 +969,31 @@ class ServiceHardware extends ServiceBase { isPreRelease: isPreRelease === true, hardwareConnectSrc, debugMode, + loadFirmwareConfig: async () => { + let config = refreshedFirmwareManifest; + if (!config) { + try { + config = await getFirmwareManifestSnapshot({ + preRelease: isPreRelease === true, + }); + } catch { + this.loadedFirmwareManifestKey = stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config: null, + }); + defaultLogger.hardware.sdkLog.log( + 'firmware_manifest_unavailable', + isPreRelease === true ? 'pre-release' : 'stable', + ); + return undefined; + } + } + this.loadedFirmwareManifestKey = stringUtils.stableStringify({ + preRelease: isPreRelease === true, + config, + }); + return config; + }, }); this.activeHardwareTransportType = hardwareTransportType; @@ -2112,6 +2196,7 @@ class ServiceHardware extends ServiceBase { featuresDeviceId: device.deviceId, hardwareCallContext: hardwareCallContext || EHardwareCallContext.USER_INTERACTION, + hardwareTransportType: resolvedHardwareTransportType, }); const protocolAwareDevice = device as SearchDevice & { connectProtocol?: HardwareConnectProtocol; @@ -2981,26 +3066,15 @@ class ServiceHardware extends ServiceBase { }: { dbDeviceId: string | undefined; }): Promise { - const { getNftSize } = await CoreSDKLoader(); const device = await localDb.getDevice(checkIsDefined(dbDeviceId)); - const size = - getNftSize({ - deviceType: device.deviceType, - thumbnail: false, - }) ?? - serviceHardwareUtils.getPro2NftSizeFallback({ - deviceType: device.deviceType, - thumbnail: false, - }); - const thumbnailSize = - getNftSize({ - deviceType: device.deviceType, - thumbnail: true, - }) ?? - serviceHardwareUtils.getPro2NftSizeFallback({ - deviceType: device.deviceType, - thumbnail: true, - }); + const size = serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: device.deviceType, + thumbnail: false, + }); + const thumbnailSize = serviceHardwareUtils.getPro2NftSizeFallback({ + deviceType: device.deviceType, + thumbnail: true, + }); return { names: [], size, thumbnailSize }; } @@ -3080,8 +3154,15 @@ class ServiceHardware extends ServiceBase { connectId: compatibleConnectId, hardwareCallContext: EHardwareCallContext.USER_INTERACTION, }); + const protocolV2NftSDK = hardwareSDK as IProtocolV2NftCoreApi; + const uploadNft = protocolV2NftSDK.deviceUploadNft; + if (!uploadNft) { + throw new OneKeyLocalError( + 'Hardware SDK does not support Protocol V2 NFT upload', + ); + } return convertDeviceResponse(() => - hardwareSDK.deviceUploadNft(compatibleConnectId, { + uploadNft(compatibleConnectId, { image: { width: image.width, height: image.height, rgba: image.data }, thumbnail: { width: thumbnail.width, @@ -3718,6 +3799,57 @@ class ServiceHardware extends ServiceBase { return this.connectionManager.getCurrentTransportType(); } + private shouldPrecheckNativeBleForHardwareCall({ + hardwareCallContext, + }: { + hardwareCallContext: EHardwareCallContext; + }) { + return ( + platformEnv.isNativeAndroid && + hardwareCallContext === EHardwareCallContext.USER_INTERACTION + ); + } + + private async ensureNativeBleReadyForHardwareCall({ + connectId, + hardwareCallContext, + hardwareTransportType, + }: { + connectId: string; + hardwareCallContext: EHardwareCallContext; + hardwareTransportType?: EHardwareTransportType; + }) { + if (!this.shouldPrecheckNativeBleForHardwareCall({ hardwareCallContext })) { + return; + } + + const transportType = + hardwareTransportType ?? (await this.getCurrentTransportType()); + if (transportType !== EHardwareTransportType.BLE) { + return; + } + + const hasBlePermission = !!(await checkBLEPermissions()); + if (!hasBlePermission) { + appEventBus.emit(EAppEventBusNames.RequestHardwareUIDialog, { + uiRequestType: EHardwareUiStateAction.LOCATION_PERMISSION, + }); + throw new deviceErrors.NeedBluetoothPermissions({ + payload: { connectId }, + }); + } + + const isBluetoothOn = !!(await checkBLEState()); + if (!isBluetoothOn) { + appEventBus.emit(EAppEventBusNames.RequestHardwareUIDialog, { + uiRequestType: EHardwareUiStateAction.BLUETOOTH_PERMISSION, + }); + throw new deviceErrors.NeedBluetoothTurnedOn({ + payload: { connectId }, + }); + } + } + @backgroundMethod() async detectUSBDeviceAvailability(params?: { connectId?: string; @@ -3764,7 +3896,7 @@ class ServiceHardware extends ServiceBase { } // Step 2: Get expected device name from features - const expectedDeviceName = features.bleName; + const expectedDeviceName = features.bleName || features.ble_name; // Step 3: Find matching device by name const matchingDevice = searchResult.payload.find( @@ -3865,12 +3997,14 @@ class ServiceHardware extends ServiceBase { featuresDeviceId, features, vendor, + hardwareTransportType, }: { hardwareCallContext: EHardwareCallContext; connectId?: string; featuresDeviceId?: string | undefined | null; // rawDeviceId features?: IOneKeyDeviceFeatures; vendor?: EHardwareVendor; + hardwareTransportType?: EHardwareTransportType; }) { // Allow connectId to be null in the following EHardwareCallContext cases if ( @@ -3942,6 +4076,12 @@ class ServiceHardware extends ServiceBase { } } + await this.ensureNativeBleReadyForHardwareCall({ + connectId, + hardwareCallContext, + hardwareTransportType, + }); + if (!platformEnv.isSupportDesktopBle) { if (platformEnv.isNative) { if (device?.bleConnectId?.toLowerCase() === connectId.toLowerCase()) { diff --git a/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts b/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts index 65fb47a446b9..2c05b5d50612 100644 --- a/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts +++ b/packages/kit-bg/src/services/ServiceIpTable.resilience.test.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { + testDomainSpeed, + testIpSpeed, +} from '@onekeyhq/shared/src/request/helpers/ipTableAdapter'; import type { IIpTableRemoteConfig } from '@onekeyhq/shared/src/request/types/ipTable'; import ServiceIpTable from './ServiceIpTable'; @@ -66,6 +70,8 @@ jest.mock('./ServiceBase', () => ({ })); const DOMAIN = 'onekeycn.com'; +const mockedTestDomainSpeed = testDomainSpeed as jest.Mock; +const mockedTestIpSpeed = testIpSpeed as jest.Mock; function buildConfig(ip: string, version = 1): IIpTableRemoteConfig { return { @@ -160,6 +166,45 @@ describe('ServiceIpTable resilience', () => { ); }); + it('scores API endpoints only with wallet health probes', async () => { + const { service, ipTableDb } = createService(); + const config = buildConfig('1.1.1.1', 1); + + jest.spyOn(service as any, 'isIpTableEnabled').mockResolvedValue(true); + const testMultipleTimesSpy = jest + .spyOn(service as any, 'testMultipleTimes') + .mockImplementation(async (...args: unknown[]) => { + const testFn = args[0] as () => Promise; + return testFn(); + }); + jest.spyOn(service, 'getConfig').mockResolvedValue({ + config, + runtime: undefined, + }); + mockedTestDomainSpeed.mockResolvedValue(20); + mockedTestIpSpeed.mockResolvedValue(25); + + await (service as any).selectBestEndpointForDomainInternal(DOMAIN, { + trigger: 'periodic', + }); + + expect(testMultipleTimesSpy).toHaveBeenCalledTimes(2); + expect(mockedTestDomainSpeed).toHaveBeenCalledTimes(1); + expect(mockedTestIpSpeed).toHaveBeenCalledWith( + '1.1.1.1', + DOMAIN, + expect.any(String), + expect.any(Number), + ); + expect(ipTableDb.commitSpeedTestResult).toHaveBeenCalledWith( + expect.objectContaining({ + domain: DOMAIN, + lastBestIp: '1.1.1.1', + selection: '', + }), + ); + }); + it('starts the queued config-change rerun after the stale round settles', async () => { const { service } = createService(); const internalSpy = jest diff --git a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts index c2c6e149a7ac..1ef0f1126c39 100644 --- a/packages/kit-bg/src/states/jotai/atoms/devSettings.ts +++ b/packages/kit-bg/src/states/jotai/atoms/devSettings.ts @@ -94,8 +94,7 @@ export interface IDevSettings { // Force IP Table strict mode: always use IP even if runtime.selections is empty // Fallback to first available IP from config when no selection exists forceIpTableStrict?: boolean; - // Kill switch for the fast-failover behaviors introduced for extreme - // network conditions (adapter fail-open + service fast switch to last-best IP) + // Kill switch for fast failover under extreme network conditions. disableIpTableFailover?: boolean; // Enable mock market banner data for UI testing enableMockMarketBanner?: boolean; diff --git a/packages/kit-bg/src/states/jotai/atoms/hardware.ts b/packages/kit-bg/src/states/jotai/atoms/hardware.ts index df94009a8f2c..85563233056b 100644 --- a/packages/kit-bg/src/states/jotai/atoms/hardware.ts +++ b/packages/kit-bg/src/states/jotai/atoms/hardware.ts @@ -109,6 +109,7 @@ export type IFirmwareUpdateStepInfo = step: EFirmwareUpdateSteps.updateStart; payload: { startAtTime: number; + isDownloadingArtifacts?: boolean; }; } | { diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx b/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx index 121d22f87309..136354b39870 100644 --- a/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/HardwareUiStateContainer.tsx @@ -76,6 +76,7 @@ import { SHOW_CLOSE_LOADING_ACTION_MIN_DURATION, } from './constants'; import { isTrezorHardwareErrorDialogPayload } from './hardwareErrorDialogUtils'; +import { hardwareUiStateDialogLifecycle } from './hardwareUiStateDialogLifecycle'; let globalShowDeviceProgressDialogEnabled = true; @@ -688,6 +689,17 @@ function HardwareUiStateContainerCmpControlled() { state, ]); + useEffect(() => { + hardwareUiStateDialogLifecycle.updateOpenState(actionStatus.isDialogAction); + }, [actionStatus.isDialogAction]); + + useEffect( + () => () => { + hardwareUiStateDialogLifecycle.updateOpenState(false); + }, + [], + ); + // Block Android back button when hardware toast is showing const handleBackPress = useCallback(() => true, []); useBackHandler(handleBackPress, actionStatus.isToastAction); diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts new file mode 100644 index 000000000000..395954357c23 --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.test.ts @@ -0,0 +1,152 @@ +import { HardwareUiStateDialogLifecycle } from './hardwareUiStateDialogLifecycle'; + +describe('HardwareUiStateDialogLifecycle', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not wait when the hardware dialog is already closed', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + let closeCalled = false; + + await lifecycle.closeAndWait(async () => { + closeCalled = true; + }); + + expect(closeCalled).toBe(true); + }); + + it('waits for a pending hardware dialog to open before continuing', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + let completed = false; + + const openPromise = lifecycle + .openAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(true); + await openPromise; + + expect(completed).toBe(true); + }); + + it('serializes a pending open and close acknowledgement', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + + const openPromise = lifecycle.openAndWait(async () => undefined); + lifecycle.updateOpenState(true); + await openPromise; + + let closeCompleted = false; + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + closeCompleted = true; + }); + + await Promise.resolve(); + expect(closeCompleted).toBe(false); + + lifecycle.updateOpenState(false); + await closePromise; + + expect(closeCompleted).toBe(true); + }); + + it('waits for the UI commit that closes an open hardware dialog', async () => { + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 0); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(false); + await closePromise; + + expect(completed).toBe(true); + }); + + it('waits for the native Sheet exit window after the close commit', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 300); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(299); + await Promise.resolve(); + expect(completed).toBe(false); + + jest.advanceTimersByTime(1); + await closePromise; + expect(completed).toBe(true); + }); + + it('cancels close settlement when another hardware dialog opens', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(5000, 300); + lifecycle.updateOpenState(true); + let completed = false; + + const closePromise = lifecycle + .closeAndWait(async () => undefined) + .then(() => { + completed = true; + }); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(150); + lifecycle.updateOpenState(true); + jest.advanceTimersByTime(300); + await Promise.resolve(); + expect(completed).toBe(false); + + lifecycle.updateOpenState(false); + jest.advanceTimersByTime(300); + await closePromise; + expect(completed).toBe(true); + }); + + it('rejects instead of mounting another overlay without a close acknowledgement', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(1000, 0); + lifecycle.updateOpenState(true); + + const closePromise = lifecycle.closeAndWait(async () => undefined); + jest.advanceTimersByTime(1000); + + await expect(closePromise).rejects.toThrow( + 'Hardware UI dialog close acknowledgement timed out', + ); + }); + + it('rejects when a requested hardware dialog never opens', async () => { + jest.useFakeTimers(); + const lifecycle = new HardwareUiStateDialogLifecycle(1000, 0); + + const openPromise = lifecycle.openAndWait(async () => undefined); + jest.advanceTimersByTime(1000); + + await expect(openPromise).rejects.toThrow( + 'Hardware UI dialog open acknowledgement timed out', + ); + }); +}); diff --git a/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts new file mode 100644 index 000000000000..faf8bf157e56 --- /dev/null +++ b/packages/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle.ts @@ -0,0 +1,121 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; + +const DEFAULT_STATE_ACK_TIMEOUT_MS = 5000; +// Dialog.show keeps its portal mounted for 300 ms while the native Sheet exits. +// Treating the React state commit as "closed" lets the next Sheet mount inside +// that exit window, which can leave the old iOS overlay intercepting touches. +const DEFAULT_CLOSE_SETTLE_MS = 300; + +type IStateWaiter = { + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +export class HardwareUiStateDialogLifecycle { + private isOpen = false; + + private closeSettleTimer: ReturnType | undefined; + + private readonly openWaiters = new Set(); + + private readonly closeWaiters = new Set(); + + constructor( + private readonly stateAckTimeoutMs = DEFAULT_STATE_ACK_TIMEOUT_MS, + private readonly closeSettleMs = DEFAULT_CLOSE_SETTLE_MS, + ) {} + + updateOpenState(isOpen: boolean) { + clearTimeout(this.closeSettleTimer); + this.closeSettleTimer = undefined; + + if (isOpen) { + this.isOpen = true; + this.resolveWaiters(this.openWaiters); + return; + } + + if (!this.isOpen) { + this.resolveWaiters(this.closeWaiters); + return; + } + + this.closeSettleTimer = setTimeout(() => { + this.closeSettleTimer = undefined; + this.isOpen = false; + this.resolveWaiters(this.closeWaiters); + }, this.closeSettleMs); + } + + async openAndWait(openAction: () => Promise) { + const openWaiter = this.isOpen + ? undefined + : this.createStateWaiter( + this.openWaiters, + 'Hardware UI dialog open acknowledgement timed out', + ); + + try { + await openAction(); + await openWaiter?.promise; + } catch (error) { + openWaiter?.cancel(); + throw error; + } + } + + async closeAndWait(closeAction: () => Promise) { + const closeWaiter = this.isOpen + ? this.createStateWaiter( + this.closeWaiters, + 'Hardware UI dialog close acknowledgement timed out', + ) + : undefined; + + try { + await closeAction(); + await closeWaiter?.promise; + } catch (error) { + closeWaiter?.cancel(); + throw error; + } + } + + private resolveWaiters(waiters: Set) { + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve(); + } + waiters.clear(); + } + + private createStateWaiter( + waiters: Set, + timeoutMessage: string, + ) { + let waiter: IStateWaiter; + const promise = new Promise((resolve, reject) => { + waiter = { + resolve, + reject, + timer: setTimeout(() => { + waiters.delete(waiter); + reject(new OneKeyLocalError(timeoutMessage)); + }, this.stateAckTimeoutMs), + }; + waiters.add(waiter); + }); + + return { + promise, + cancel: () => { + clearTimeout(waiter.timer); + waiters.delete(waiter); + }, + }; + } +} + +export const hardwareUiStateDialogLifecycle = + new HardwareUiStateDialogLifecycle(); diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx index 0ee1b4edf1e4..dd243185f188 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/index.tsx @@ -71,6 +71,7 @@ const Index = () => { { // @ts-expect-error diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGallery.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGallery.tsx new file mode 100644 index 000000000000..bbaa20718379 --- /dev/null +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGallery.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + Badge, + Button, + Heading, + Progress, + SizableText, + XStack, + YStack, +} from '@onekeyhq/components'; +import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { DeveloperTestIDs } from '@onekeyhq/kit/src/views/Developer/testIDs'; +import { showDevOnlyPasswordDialog } from '@onekeyhq/kit/src/views/Setting/pages/Tab/DevSettingsSection/showDevOnlyPasswordDialog'; +import type { + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; + +import { + type IFirmwareArtifactGalleryClient, + createFirmwareArtifactGalleryClient, +} from './FirmwareArtifactGalleryClient'; +import { Layout } from './utils/Layout'; + +const scenarios: { + scenario: IFirmwareArtifactSelfTestScenario; + title: string; + description: string; + testID: string; +}[] = [ + { + scenario: 'pro-firmware', + title: 'Validate Pro firmware', + description: + 'Production preflight, SDK handoff, and 50 cached preflight cycles', + testID: DeveloperTestIDs.firmwareArtifactRunFirmware, + }, + { + scenario: 'pro-resource', + title: 'Validate incremental resource', + description: + 'Production ZIP preflight, exact materialization, and SDK handoff', + testID: DeveloperTestIDs.firmwareArtifactRunResource, + }, + { + scenario: 'pro-full-resource', + title: 'Validate full resource', + description: 'Production preflight for the large ZIP, approximately 84 MB', + testID: DeveloperTestIDs.firmwareArtifactRunFullResource, + }, +]; + +const formatBytes = (bytes: number): string => { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} KB`; + } + return `${bytes} B`; +}; + +const formatDuration = (state: IFirmwareArtifactSelfTestState): string => { + const end = state.completedAt ?? state.updatedAt; + return `${((end - state.startedAt) / 1000).toFixed(1)} s`; +}; + +function FirmwareArtifactStatus({ + state, +}: { + state: IFirmwareArtifactSelfTestState; +}) { + return ( + + + + {state.descriptor.label} + + Version {state.descriptor.version} · {state.platform} + + + + {state.status.toUpperCase()} + + + + + + + Phase: {state.phase} + + Expected:{' '} + {state.descriptor.expectedSize === undefined + ? 'not provided by config' + : formatBytes(state.descriptor.expectedSize)} + + + Reader: {formatBytes(state.bytesRead)} in {state.chunkCount} chunks + + + Materialized entries: {state.materializedEntryCount} + + {state.descriptor.scenario === 'pro-firmware' ? ( + + Cached preflight: {state.preflightCompletedIterations} / 50 + + ) : null} + + Prepared plan: {state.preparedPlanValidated ? 'validated' : 'pending'} + + + Production SDK handoff:{' '} + {state.sdkHandoffValidated ? 'validated' : 'pending'} + + + Completed cleanup: {state.cleanupValidated ? 'validated' : 'pending'} + + + Failure cleanup:{' '} + {state.failureCleanupValidated ? 'validated' : 'pending'} + + {state.sdkBoundaryCode ? ( + + SDK device boundary: {state.sdkBoundaryCode} + + ) : null} + + GC: {state.deletedFiles} files / {formatBytes(state.deletedBytes)} + + + Duration: {formatDuration(state)} + + {state.errorCode ? ( + + Error: {state.errorCode} + + ) : null} + + + + Run ID: {state.runId} + + + ); +} + +function FirmwareArtifactValidator() { + const [state, setState] = useState< + IFirmwareArtifactSelfTestState | undefined + >(); + const [startingScenario, setStartingScenario] = useState< + IFirmwareArtifactSelfTestScenario | undefined + >(); + const [uiError, setUiError] = useState(); + const [client, setClient] = useState(); + + const refresh = useCallback(async () => { + if (!client) return; + const next = await client.getState(); + setState(next); + }, [client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (state?.status !== 'running') return undefined; + const timer = setInterval(() => { + void refresh(); + }, 500); + return () => clearInterval(timer); + }, [refresh, state?.status]); + + const start = useCallback((scenario: IFirmwareArtifactSelfTestScenario) => { + showDevOnlyPasswordDialog({ + title: 'Run firmware artifact validation', + description: + 'Downloads and validates production firmware artifacts without connecting to a device.', + confirmButtonProps: { variant: 'primary' }, + onConfirm: async (params) => { + const authenticatedClient = createFirmwareArtifactGalleryClient( + backgroundApiProxy.serviceFirmwareUpdate, + params, + ); + setClient(authenticatedClient); + setStartingScenario(scenario); + setUiError(undefined); + try { + const next = await authenticatedClient.start(scenario); + setState(next); + } catch (error) { + setUiError(error instanceof Error ? error.message : String(error)); + } finally { + setStartingScenario(undefined); + } + }, + }); + }, []); + + const platformSupported = platformEnv.isNative || platformEnv.isDesktop; + const running = state?.status === 'running'; + + return ( + + + Pro 4.21.0 artifacts + + Runs the production preflight, host binding, SDK parameter builder, + and cleanup path in the background runtime. It stops at the device + boundary, so no OneKey device is required. + + + + {state ? : null} + + {scenarios.map((item) => ( + + + {item.title} + + {item.description} + + + + + ))} + + {uiError ? ( + UI error: {uiError} + ) : null} + + + + Filter app-latest.log by firmwareArtifactSelfTest or the Run ID. + + + A cached artifact is still integrity-checked. Clear App data before + the first run when a fresh network transfer is required. + + + + ); +} + +const FirmwareArtifactGallery = () => ( + __CURRENT_FILE_PATH__} + > + + +); + +export default FirmwareArtifactGallery; diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts new file mode 100644 index 000000000000..b21a25861e26 --- /dev/null +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.test.ts @@ -0,0 +1,29 @@ +import type { IFirmwareArtifactSelfTestState } from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest'; + +import { createFirmwareArtifactGalleryClient } from './FirmwareArtifactGalleryClient'; + +describe('FirmwareArtifactGalleryClient', () => { + it('passes the dev-only password to start and state requests', async () => { + const state = {} as IFirmwareArtifactSelfTestState; + const startFirmwareArtifactSelfTest = jest.fn().mockResolvedValue(state); + const getFirmwareArtifactSelfTestState = jest.fn().mockResolvedValue(state); + const client = createFirmwareArtifactGalleryClient( + { + startFirmwareArtifactSelfTest, + getFirmwareArtifactSelfTestState, + }, + { $$devOnlyPassword: 'dev-password' }, + ); + + await client.start('pro-firmware'); + await client.getState(); + + expect(startFirmwareArtifactSelfTest).toHaveBeenCalledWith({ + $$devOnlyPassword: 'dev-password', + scenario: 'pro-firmware', + }); + expect(getFirmwareArtifactSelfTestState).toHaveBeenCalledWith({ + $$devOnlyPassword: 'dev-password', + }); + }); +}); diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts new file mode 100644 index 000000000000..629eacdce55c --- /dev/null +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGalleryClient.ts @@ -0,0 +1,37 @@ +import type { + IFirmwareArtifactSelfTestScenario, + IFirmwareArtifactSelfTestState, +} from '@onekeyhq/kit-bg/src/services/ServiceFirmwareUpdate/FirmwareArtifactSelfTest'; +import type { IBackgroundMethodWithDevOnlyPassword } from '@onekeyhq/shared/src/background/backgroundDecorators'; + +type IFirmwareArtifactSelfTestService = { + startFirmwareArtifactSelfTest( + params: IBackgroundMethodWithDevOnlyPassword & { + scenario: IFirmwareArtifactSelfTestScenario; + }, + ): Promise; + getFirmwareArtifactSelfTestState( + params: IBackgroundMethodWithDevOnlyPassword, + ): Promise; +}; + +export function createFirmwareArtifactGalleryClient( + service: IFirmwareArtifactSelfTestService, + devOnlyParams: IBackgroundMethodWithDevOnlyPassword, +) { + return { + start(scenario: IFirmwareArtifactSelfTestScenario) { + return service.startFirmwareArtifactSelfTest({ + ...devOnlyParams, + scenario, + }); + }, + getState() { + return service.getFirmwareArtifactSelfTestState(devOnlyParams); + }, + }; +} + +export type IFirmwareArtifactGalleryClient = ReturnType< + typeof createFirmwareArtifactGalleryClient +>; diff --git a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx index 8b8e86220f0f..9dcad54f6c1d 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/Components/stories/Hardware.tsx @@ -1,9 +1,18 @@ /* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react/no-unstable-nested-components */ +import { useState } from 'react'; + import { EDeviceType } from '@onekeyfe/hd-shared'; -import { Button, Dialog, SizableText, Stack } from '@onekeyhq/components'; +import { + Button, + Dialog, + EInPageDialogType, + SizableText, + Stack, + useInPageDialog, +} from '@onekeyhq/components'; import { ConfirmOnDeviceToast, confirmByPin, @@ -13,6 +22,8 @@ import { confirmPhraseOnDevice, confirmPinOnDevice, } from '@onekeyhq/kit/src/components/Hardware'; +import { hardwareUiStateDialogLifecycle } from '@onekeyhq/kit/src/provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; +import { useFirmwareUpdateActions } from '@onekeyhq/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions'; import type { IHardwareUiPayload } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { EHardwareUiStateAction, @@ -20,6 +31,7 @@ import { hardwareUiStateCompletedAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import deviceHomeScreenUtils from '@onekeyhq/shared/src/utils/deviceHomeScreenUtils'; +import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EOneKeyDeviceMode } from '@onekeyhq/shared/types/device'; import { Layout } from './utils/Layout'; @@ -27,6 +39,60 @@ import { Layout } from './utils/Layout'; import type { IDeviceType } from '@onekeyfe/hd-core'; // https://i.mij.rip/2024/09/19/b0cdcbdb45494fe53b831fff02981fdb.jpeg +const BootloaderDialogHandoffTest = () => { + const [confirmCount, setConfirmCount] = useState(0); + const dialogHost = useInPageDialog(EInPageDialogType.inOnboardingPage); + const firmwareUpdateActions = useFirmwareUpdateActions(); + + return ( + + + + Confirm count: {confirmCount} + + + ); +}; + const HardwareActionTest = () => { const generateAction = async ( uiRequestType: EHardwareUiStateAction, @@ -81,6 +147,10 @@ const HardwareActionTest = () => { return ( + + + + Device transfer progress diff --git a/packages/kit/src/views/Developer/pages/Gallery/index.tsx b/packages/kit/src/views/Developer/pages/Gallery/index.tsx index f083a5aa6522..400ff0f1f2e7 100644 --- a/packages/kit/src/views/Developer/pages/Gallery/index.tsx +++ b/packages/kit/src/views/Developer/pages/Gallery/index.tsx @@ -77,6 +77,11 @@ const ErrorToastGallery = LazyLoadPage( import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/ErrorToastGallery'), ); +const FirmwareArtifactGallery = LazyLoadPage( + () => + import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/FirmwareArtifactGallery'), +); + const QRWalletGallery = LazyLoadPage( () => import('@onekeyhq/kit/src/views/Developer/pages/Gallery/Components/stories/QRWalletGallery'), @@ -647,6 +652,10 @@ export const galleryScreenList: { { name: EGalleryRoutes.ComponentJotaiGlobal, component: JotaiGlobalGallery }, { name: EGalleryRoutes.ComponentLocalDB, component: LocalDBGallery }, { name: EGalleryRoutes.ComponentErrorToast, component: ErrorToastGallery }, + { + name: EGalleryRoutes.ComponentFirmwareArtifact, + component: FirmwareArtifactGallery, + }, { name: EGalleryRoutes.ComponentQRWallet, component: QRWalletGallery, diff --git a/packages/kit/src/views/Developer/testIDs.ts b/packages/kit/src/views/Developer/testIDs.ts index eee324d39130..80f9376f562e 100644 --- a/packages/kit/src/views/Developer/testIDs.ts +++ b/packages/kit/src/views/Developer/testIDs.ts @@ -18,4 +18,11 @@ export const DeveloperTestIDs = { connectedSiteBtn: 'developer-connected-site-btn', customSignMessageInput: 'developer-custom-sign-message-input', customSignMessageBtn: 'developer-custom-sign-message-btn', + + // --- Firmware Artifact Gallery --- + firmwareArtifactScreen: 'firmware-artifact-validator-screen', + firmwareArtifactRunFirmware: 'firmware-artifact-run-pro-firmware', + firmwareArtifactRunResource: 'firmware-artifact-run-pro-resource', + firmwareArtifactRunFullResource: 'firmware-artifact-run-pro-full-resource', + firmwareArtifactStatus: 'firmware-artifact-status', } as const; diff --git a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx index cd31cfd1226f..cbe672b533b1 100644 --- a/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx +++ b/packages/kit/src/views/FirmwareUpdate/componentsV2/FirmwareUpdateProgressBarV2.tsx @@ -425,9 +425,18 @@ export function FirmwareUpdateProgressBarV2({ }, [isDone]); useEffect(() => { + if (stepInfo.step !== EFirmwareUpdateSteps.updateStart) { + return; + } + if (stepInfo.payload.isDownloadingArtifacts) { + updateProgressRef.current( + EFirmwareUpdateTipMessages.StartDownloadFirmware, + ); + return; + } updateProgressRef.current('checking'); setDesc(defaultDesc()); - }, [defaultDesc]); + }, [defaultDesc, stepInfo]); const installProgressList = useRef([]); useEffect(() => { diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts new file mode 100644 index 000000000000..2b5e30a18a47 --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.test.ts @@ -0,0 +1,71 @@ +import type { IDialogInstance } from '@onekeyhq/components'; + +import { BootloaderModeDialogManager } from './bootloaderModeDialogManager'; + +function createDialogMock() { + let onClose: (() => void) | undefined; + let onUpdate: (() => Promise) | undefined; + const dialog: IDialogInstance = { + close: jest.fn(async () => { + onClose?.(); + }), + getForm: jest.fn(() => undefined), + isExist: jest.fn(() => true), + }; + const createDialog = jest.fn((params) => { + onClose = params.onClose; + onUpdate = params.onUpdate; + return dialog; + }); + + return { + createDialog, + dialog, + runUpdate: async () => onUpdate?.(), + }; +} + +describe('BootloaderModeDialogManager', () => { + it('reuses the active dialog across independent callers', async () => { + const manager = new BootloaderModeDialogManager(); + const first = createDialogMock(); + const second = createDialogMock(); + const firstUpdate = jest.fn(async () => undefined); + const secondUpdate = jest.fn(async () => undefined); + + expect( + manager.show({ + createDialog: first.createDialog, + onUpdate: firstUpdate, + }), + ).toBe(first.dialog); + expect( + manager.show({ + createDialog: second.createDialog, + onUpdate: secondUpdate, + }), + ).toBe(first.dialog); + expect(first.createDialog).toHaveBeenCalledTimes(1); + expect(second.createDialog).not.toHaveBeenCalled(); + + await first.runUpdate(); + expect(firstUpdate).not.toHaveBeenCalled(); + expect(secondUpdate).toHaveBeenCalledTimes(1); + }); + + it('allows a new dialog after the active one closes', async () => { + const manager = new BootloaderModeDialogManager(); + const first = createDialogMock(); + const second = createDialogMock(); + const onUpdate = jest.fn(async () => undefined); + + manager.show({ createDialog: first.createDialog, onUpdate }); + await manager.close(); + + expect(first.dialog.close).toHaveBeenCalledTimes(1); + expect(manager.show({ createDialog: second.createDialog, onUpdate })).toBe( + second.dialog, + ); + expect(second.createDialog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts new file mode 100644 index 000000000000..9469e6b8464b --- /dev/null +++ b/packages/kit/src/views/FirmwareUpdate/hooks/bootloaderModeDialogManager.ts @@ -0,0 +1,50 @@ +import type { IDialogInstance } from '@onekeyhq/components'; + +type ICreateBootloaderModeDialog = (params: { + onClose: () => void; + onUpdate: () => Promise; +}) => IDialogInstance; + +export class BootloaderModeDialogManager { + private activeDialog: IDialogInstance | undefined; + + private updateAction: (() => Promise) | undefined; + + show({ + createDialog, + onUpdate, + }: { + createDialog: ICreateBootloaderModeDialog; + onUpdate: () => Promise; + }) { + this.updateAction = onUpdate; + if (this.activeDialog) { + return this.activeDialog; + } + + const dialog = createDialog({ + onClose: () => { + if (this.activeDialog === dialog) { + this.activeDialog = undefined; + this.updateAction = undefined; + } + }, + onUpdate: async () => { + await this.updateAction?.(); + }, + }); + this.activeDialog = dialog; + return dialog; + } + + async close() { + const dialog = this.activeDialog; + if (!dialog) { + return; + } + + await dialog.close(); + } +} + +export const bootloaderModeDialogManager = new BootloaderModeDialogManager(); diff --git a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx index 9a5d5d6dd86b..a4f27f70be70 100644 --- a/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx +++ b/packages/kit/src/views/FirmwareUpdate/hooks/useFirmwareUpdateActions.tsx @@ -21,9 +21,13 @@ import useAppNavigation from '../../../hooks/useAppNavigation'; import { FirmwareUpdateCheckList } from '../components/FirmwareUpdateCheckList'; import { getTargetFirmwareTypeLabel } from '../utils'; +import { bootloaderModeDialogManager } from './bootloaderModeDialogManager'; + import type { AllFirmwareRelease } from '@onekeyfe/hd-core'; import type { EDeviceType, EFirmwareType } from '@onekeyfe/hd-shared'; +export type IBootloaderModeDialogHost = Pick; + export function useFirmwareUpdateActions() { const intl = useIntl(); const navigation = useAppNavigation(); @@ -165,10 +169,12 @@ export function useFirmwareUpdateActions() { connectId, existsFirmware, onBeforeUpdate, + dialogHost = Dialog, }: { connectId: string | undefined; existsFirmware?: boolean; onBeforeUpdate?: () => Promise; + dialogHost?: IBootloaderModeDialogHost; }) => { const handleUpdateClick = async () => { // Call onBeforeUpdate callback if provided (for onboarding USB preparation) @@ -184,43 +190,57 @@ export function useFirmwareUpdateActions() { }; if (existsFirmware) { - Dialog.show({ - title: intl.formatMessage({ - id: ETranslations.update_device_in_bootloader_mode, - }), - description: intl.formatMessage({ - id: ETranslations.update_hardware_wallet_in_bootloader_mode_restart, - }), - dismissOnOverlayPress: false, - onConfirm: async ({ close }) => { - void close?.(); - }, - onConfirmText: intl.formatMessage({ - id: ETranslations.global_got_it, - }), - onCancel: async () => { - await handleUpdateClick(); - }, - onCancelText: intl.formatMessage({ - id: ETranslations.update_update_now, - }), + bootloaderModeDialogManager.show({ + onUpdate: handleUpdateClick, + createDialog: ({ onClose, onUpdate }) => + dialogHost.show({ + trackID: 'firmware-bootloader-mode-dialog', + title: intl.formatMessage({ + id: ETranslations.update_device_in_bootloader_mode, + }), + description: intl.formatMessage({ + id: ETranslations.update_hardware_wallet_in_bootloader_mode_restart, + }), + dismissOnOverlayPress: false, + onConfirm: async ({ close }) => { + void close?.(); + }, + onConfirmText: intl.formatMessage({ + id: ETranslations.global_got_it, + }), + onCancel: onUpdate, + onCancelText: intl.formatMessage({ + id: ETranslations.update_update_now, + }), + cancelButtonProps: { + testID: 'firmware-bootloader-mode-update-btn', + }, + onClose, + }), }); } else { - Dialog.show({ - title: intl.formatMessage({ - id: ETranslations.update_device_in_bootloader_mode, - }), - description: intl.formatMessage({ - id: ETranslations.update_hardware_wallet_in_bootloader_mode, - }), - dismissOnOverlayPress: false, - showCancelButton: false, - onConfirm: async () => { - await handleUpdateClick(); - }, - onConfirmText: intl.formatMessage({ - id: ETranslations.update_update_now, - }), + bootloaderModeDialogManager.show({ + onUpdate: handleUpdateClick, + createDialog: ({ onClose, onUpdate }) => + dialogHost.show({ + trackID: 'firmware-bootloader-mode-dialog', + title: intl.formatMessage({ + id: ETranslations.update_device_in_bootloader_mode, + }), + description: intl.formatMessage({ + id: ETranslations.update_hardware_wallet_in_bootloader_mode, + }), + dismissOnOverlayPress: false, + showCancelButton: false, + onConfirm: onUpdate, + onConfirmText: intl.formatMessage({ + id: ETranslations.update_update_now, + }), + confirmButtonProps: { + testID: 'firmware-bootloader-mode-update-btn', + }, + onClose, + }), }); } }, diff --git a/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx b/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx index 63e990140724..73199b95682e 100644 --- a/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx +++ b/packages/kit/src/views/Home/components/HomeSupportedWallet/index.tsx @@ -33,7 +33,7 @@ export function HomeSupportedWallet({ [EDeviceType.Pro2]: 'Pro 2', [NEO_DEVICE_TYPE]: 'Neo', [EDeviceType.Unknown]: '', - 'watching': intl.formatMessage({ + watching: intl.formatMessage({ id: ETranslations.faq_watched_account, }), }; diff --git a/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts new file mode 100644 index 000000000000..eadcd9c5fce1 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.test.ts @@ -0,0 +1,115 @@ +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import { + resolveFirmwareReconnectDevice, + selectFirmwareReconnectDevice, +} from './firmwareReconnectUtils'; + +import type { SearchDevice } from '@onekeyfe/hd-core'; + +const createDevice = ( + overrides: Partial & { + mode?: string; + features?: IOneKeyDeviceFeatures; + } = {}, +) => + ({ + connectId: 'CLA45F0023', + serialNo: 'CLA45F0023', + uuid: 'CLA45F0023', + deviceId: 'device-id', + deviceType: 'classic1s', + name: 'OneKey Classic 1S', + commType: 'webusb', + mode: 'normal', + ...overrides, + }) as SearchDevice; + +describe('firmwareReconnectUtils', () => { + const bootloaderDevice = createDevice({ + connectId: '000000000000000000000000', + serialNo: '', + uuid: '', + deviceId: null, + mode: 'bootloader', + features: { bootloader_mode: true } as IOneKeyDeviceFeatures, + }); + const normalDevice = createDevice(); + + it('selects the unique normal device after a bootloader identity change', () => { + expect( + selectFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [bootloaderDevice, normalDevice], + }), + ).toBe(normalDevice); + }); + + it('reads fresh features with the newly enumerated connectId', async () => { + const features = { + bootloader_mode: null, + device_id: 'device-id', + } as IOneKeyDeviceFeatures; + const getFeatures = jest.fn().mockResolvedValue(features); + const onConnectId = jest.fn(); + + await expect( + resolveFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [normalDevice], + getFeatures, + onConnectId, + }), + ).resolves.toEqual({ device: normalDevice, features }); + expect(onConnectId).toHaveBeenCalledWith('CLA45F0023'); + expect(getFeatures).toHaveBeenCalledWith('CLA45F0023'); + expect(getFeatures).not.toHaveBeenCalledWith('000000000000000000000000'); + }); + + it('uses stable identity when more than one same-model device is present', () => { + const previousNormalDevice = createDevice(); + const otherNormalDevice = createDevice({ + connectId: 'CLA45F0099', + serialNo: 'CLA45F0099', + uuid: 'CLA45F0099', + deviceId: 'other-device-id', + }); + + expect( + selectFirmwareReconnectDevice({ + previousDevice: previousNormalDevice, + devices: [otherNormalDevice, normalDevice], + }), + ).toBe(normalDevice); + }); + + it('keeps retrying while the only device is still in bootloader mode', async () => { + const getFeatures = jest.fn().mockResolvedValue({ + bootloader_mode: true, + } as IOneKeyDeviceFeatures); + + await expect( + resolveFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [bootloaderDevice], + getFeatures, + }), + ).rejects.toThrow('Firmware device is still in bootloader mode'); + }); + + it('fails closed when multiple same-model devices cannot be distinguished', () => { + const secondNormalDevice = createDevice({ + connectId: 'CLA45F0099', + serialNo: 'CLA45F0099', + uuid: 'CLA45F0099', + deviceId: 'other-device-id', + }); + + expect( + selectFirmwareReconnectDevice({ + previousDevice: bootloaderDevice, + devices: [normalDevice, secondNormalDevice], + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts new file mode 100644 index 000000000000..2f0d413e2f98 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/hooks/firmwareReconnectUtils.ts @@ -0,0 +1,117 @@ +import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; +import type { IOneKeyDeviceFeatures } from '@onekeyhq/shared/types/device'; + +import type { SearchDevice } from '@onekeyfe/hd-core'; + +type IFirmwareSearchDevice = SearchDevice & { + mode?: string; + features?: IOneKeyDeviceFeatures; +}; + +function isMeaningfulIdentifier(value: string | null | undefined) { + return Boolean(value && !/^0+$/.test(value)); +} + +function getDeviceIdentifiers(device: SearchDevice) { + return [ + device.serialNo, + device.deviceId, + device.uuid, + device.connectId, + ].filter(isMeaningfulIdentifier) as string[]; +} + +function isBootloaderDevice(device: IFirmwareSearchDevice) { + return ( + device.mode === 'bootloader' || device.features?.bootloader_mode === true + ); +} + +function findUniqueDevice( + devices: IFirmwareSearchDevice[], + predicate: (device: IFirmwareSearchDevice) => boolean, +) { + const matches = devices.filter(predicate); + return matches.length === 1 ? matches[0] : undefined; +} + +export function selectFirmwareReconnectDevice({ + previousDevice, + devices, +}: { + previousDevice: SearchDevice; + devices: SearchDevice[]; +}) { + const availableDevices = (devices as IFirmwareSearchDevice[]).filter( + (device) => Boolean(device.connectId), + ); + const previousIdentifiers = new Set(getDeviceIdentifiers(previousDevice)); + const hasMatchingIdentifier = (device: SearchDevice) => + getDeviceIdentifiers(device).some((identifier) => + previousIdentifiers.has(identifier), + ); + const hasSameTransport = (device: SearchDevice) => + device.commType === previousDevice.commType; + const hasSameDeviceType = (device: SearchDevice) => + device.deviceType === previousDevice.deviceType; + const normalDevices = availableDevices.filter( + (device) => !isBootloaderDevice(device), + ); + + return ( + findUniqueDevice( + normalDevices, + (device) => hasMatchingIdentifier(device) && hasSameTransport(device), + ) || + findUniqueDevice(normalDevices, hasMatchingIdentifier) || + findUniqueDevice( + normalDevices, + (device) => hasSameDeviceType(device) && hasSameTransport(device), + ) || + findUniqueDevice(normalDevices, hasSameDeviceType) || + findUniqueDevice( + availableDevices, + (device) => hasMatchingIdentifier(device) && hasSameTransport(device), + ) || + findUniqueDevice(availableDevices, hasMatchingIdentifier) || + findUniqueDevice( + availableDevices, + (device) => hasSameDeviceType(device) && hasSameTransport(device), + ) + ); +} + +export async function resolveFirmwareReconnectDevice({ + previousDevice, + devices, + getFeatures, + onConnectId, +}: { + previousDevice: SearchDevice; + devices: SearchDevice[]; + getFeatures: (connectId: string) => Promise; + onConnectId?: (connectId: string) => void; +}) { + const device = selectFirmwareReconnectDevice({ previousDevice, devices }); + if (!device?.connectId) { + throw new OneKeyLocalError('Firmware device was not uniquely identified'); + } + + onConnectId?.(device.connectId); + const features = await getFeatures(device.connectId); + if (features.bootloader_mode) { + throw new OneKeyLocalError('Firmware device is still in bootloader mode'); + } + + if ( + isMeaningfulIdentifier(previousDevice.deviceId) && + isMeaningfulIdentifier(features.device_id) && + previousDevice.deviceId !== features.device_id + ) { + throw new OneKeyLocalError( + 'Firmware device identity changed after reconnect', + ); + } + + return { device, features }; +} diff --git a/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx b/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx index c0dfa22981c5..7e114b91783a 100644 --- a/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx +++ b/packages/kit/src/views/Onboardingv2/hooks/useDeviceConnect.tsx @@ -48,8 +48,13 @@ import backgroundApiProxy from '../../../background/instance/backgroundApiProxy' import { ListItem } from '../../../components/ListItem'; import useAppNavigation from '../../../hooks/useAppNavigation'; import { useUserWalletProfile } from '../../../hooks/useUserWalletProfile'; +import { hardwareUiStateDialogLifecycle } from '../../../provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; import { useAccountSelectorActions } from '../../../states/jotai/contexts/accountSelector/actions'; -import { useFirmwareUpdateActions } from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; +import { bootloaderModeDialogManager } from '../../FirmwareUpdate/hooks/bootloaderModeDialogManager'; +import { + type IBootloaderModeDialogHost, + useFirmwareUpdateActions, +} from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; import { useFirmwareVerifyDialog } from '../../Onboarding/pages/ConnectHardwareWallet/FirmwareVerifyDialog'; import { useSelectAddWalletTypeDialog } from '../../Onboarding/pages/ConnectHardwareWallet/SelectAddWalletTypeDialog'; import { @@ -64,6 +69,7 @@ import { trackHardwareWalletConnection, } from '../utils'; +import { resolveFirmwareReconnectDevice } from './firmwareReconnectUtils'; import { usePrepareUSBConnectForFirmwareUpdate } from './usePrepareUSBConnectForFirmwareUpdate'; import type { IDeviceType, SearchDevice } from '@onekeyfe/hd-core'; @@ -143,10 +149,12 @@ async function createLedgerHwWallet({ export function useDeviceConnect({ setCurrentDevice, + getBootloaderDialogHost, }: { setCurrentDevice?: React.Dispatch< React.SetStateAction >; + getBootloaderDialogHost?: () => IBootloaderModeDialogHost | undefined; } = {}) { const intl = useIntl(); const actions = useAccountSelectorActions(); @@ -281,11 +289,10 @@ export function useDeviceConnect({ isSameHardware(device, activeDeviceRef.current) && activeFeaturesRef.current ) { + await bootloaderModeDialogManager.close(); return activeFeaturesRef.current; } - // Clear bootloader mode flag when reconnecting - wasInBootloaderModeRef.current = false; let hardwareCallContext: EHardwareCallContext | undefined; let isBootMode = false; if ( @@ -303,8 +310,23 @@ export function useDeviceConnect({ options?.connectProtocol, options?.forceProtocolDetection, ); - // If device was in bootloader mode and connectId is empty, search for the updated device - if (device.connectId === '' && isBootMode && !features?.bootloaderMode) { + let isConnectedBootloaderMode = false; + if (features) { + isConnectedBootloaderMode = + await deviceUtils.isBootloaderModeByFeatures({ features }); + wasInBootloaderModeRef.current = isConnectedBootloaderMode; + if (!isConnectedBootloaderMode) { + await bootloaderModeDialogManager.close(); + } + } + const hasPlaceholderConnectId = + !device.connectId || /^0+$/.test(device.connectId); + if ( + hasPlaceholderConnectId && + isBootMode && + features && + !isConnectedBootloaderMode + ) { const searchedDevices = await backgroundApiProxy.serviceHardware.searchDevices(); if (searchedDevices.success && searchedDevices.payload.length === 1) { @@ -320,6 +342,53 @@ export function useDeviceConnect({ [connectDevice, isSameHardware, setCurrentDevice], ); + const rebindDeviceAfterFirmwareUpdate = useCallback( + async ( + previousDevice: SearchDevice, + onConnectId?: (connectId: string) => void, + ) => { + wasInBootloaderModeRef.current = true; + await ensureStopScan(); + const searchedDevices = + await backgroundApiProxy.serviceHardware.searchDevices(); + if (!searchedDevices.success) { + throw new OneKeyLocalError( + 'Unable to search for device after firmware update', + ); + } + + const result = await resolveFirmwareReconnectDevice({ + previousDevice, + devices: searchedDevices.payload, + getFeatures: (connectId) => + backgroundApiProxy.serviceHardware.getFeaturesWithoutCache({ + connectId, + params: { + retryCount: 1, + skipWebDevicePrompt: true, + }, + }), + onConnectId, + }); + activeDeviceRef.current = { ...result.device }; + activeFeaturesRef.current = result.features; + wasInBootloaderModeRef.current = false; + await bootloaderModeDialogManager.close(); + setCurrentDevice?.(result.device); + defaultLogger.hardware.sdkLog.log( + 'Firmware reconnect succeeded', + JSON.stringify({ + deviceType: result.device.deviceType, + commType: result.device.commType, + connectIdChanged: + previousDevice.connectId !== result.device.connectId, + }), + ); + return result; + }, + [ensureStopScan, setCurrentDevice], + ); + const getActiveDevice = useCallback(() => { return activeDeviceRef.current ?? undefined; }, []); @@ -495,12 +564,21 @@ export function useDeviceConnect({ } let connectionFailureTracked = false; + let bootloaderDialogShown = false; let forceTransportType: EHardwareTransportType | undefined; const confirmedConnectProtocol = device.connectProtocol; try { - void backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ - connectId: device.connectId ?? '', - }); + const showCheckingDeviceDialog = () => + backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ + connectId: device.connectId ?? '', + }); + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.openAndWait( + showCheckingDeviceDialog, + ); + } else { + void showCheckingDeviceDialog(); + } const handleBootloaderMode = async (existsFirmware: boolean) => { // Set bootloader mode flag so retry will force reconnect @@ -535,11 +613,26 @@ export function useDeviceConnect({ return usbPrepareResult.connectId ?? device.connectId ?? undefined; }; + // Wait until the hardware dialog has left the global iOS overlay before + // mounting the page-owned bootloader dialog. + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.closeAndWait(async () => + backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ + connectId: device.connectId ?? undefined, + skipDeviceCancel: true, + skipDelayClose: true, + reason: 'open bootloader mode dialog', + }), + ); + } + fwUpdateActions.showBootloaderMode({ connectId: device.connectId ?? undefined, existsFirmware, onBeforeUpdate: prepareUSBForUpdate, + dialogHost: getBootloaderDialogHost?.(), }); + bootloaderDialogShown = true; console.log('Device is in bootloader mode', device); // Bootloader mode hands off to the firmware-update flow, so the throw // below is not a connection failure — suppress the catch-block tracking. @@ -736,8 +829,11 @@ export function useDeviceConnect({ }, }; } catch (error) { - // Clear force transport type on device connection error - void backgroundApiProxy.serviceHardwareUI.cleanHardwareUiState(); + // The hardware dialog was already closed before the bootloader dialog + // mounted. A late cleanup write here can race with that handoff on iOS. + if (!platformEnv.isNativeIOS || !bootloaderDialogShown) { + void backgroundApiProxy.serviceHardwareUI.cleanHardwareUiState(); + } console.error('handleDeviceConnect error:', error); if (!connectionFailureTracked) { // Fire-and-forget; an analytics rejection must not mask the original error @@ -763,6 +859,7 @@ export function useDeviceConnect({ showFirmwareVerifyDialog, prepareUSBConnect, getActiveDevice, + getBootloaderDialogHost, ], ); @@ -976,6 +1073,7 @@ export function useDeviceConnect({ onSelectAddWalletType, createHWWallet: onSelectAddWalletType, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, getActiveDeviceFeatures, }), @@ -985,6 +1083,7 @@ export function useDeviceConnect({ verifyHardware, onSelectAddWalletType, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, getActiveDeviceFeatures, ], diff --git a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx index d9a8a226cd8f..4755d34f2b22 100644 --- a/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/CheckAndUpdate.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import pRetry, { AbortError } from 'p-retry'; @@ -11,11 +19,13 @@ import { Button, Dialog, DialogContainer, + EInPageDialogType, HeightTransition, SizableText, Theme, XStack, YStack, + useInPageDialog, } from '@onekeyhq/components'; import { ANIMATE_ONLY_OPACITY_TRANSFORM } from '@onekeyhq/components/src/utils/animationConstants'; import { OneKeyLocalError } from '@onekeyhq/shared/src/errors'; @@ -33,7 +43,10 @@ import { EHardwareCallContext } from '@onekeyhq/shared/types/device'; import backgroundApiProxy from '../../../background/instance/backgroundApiProxy'; import { AccountSelectorProviderMirror } from '../../../components/AccountSelector'; import useAppNavigation from '../../../hooks/useAppNavigation'; -import { useFirmwareUpdateActions } from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; +import { + type IBootloaderModeDialogHost, + useFirmwareUpdateActions, +} from '../../FirmwareUpdate/hooks/useFirmwareUpdateActions'; import { CheckStepIllustration, type ICheckStepIllustrationTone, @@ -48,24 +61,19 @@ import { usePrepareUSBConnectForFirmwareUpdate } from '../hooks/usePrepareUSBCon import { OnboardingTestIDs } from '../testIDs'; import { getForceTransportType } from '../utils'; +import { + ECheckAndUpdateStepId, + ECheckAndUpdateStepState, + beginPostUpdateRecheck, + isCheckAndUpdateReady, + isCheckAndUpdateRetryDisabled, + isCheckAndUpdateStepAccepted, + keepPostUpdateGenuineRecheckArmed, +} from './checkAndUpdateStepState'; import { createFirmwareRecheckTimer } from './firmwareRecheckUtils'; import type { Features, KnownDevice, SearchDevice } from '@onekeyfe/hd-core'; -enum ECheckAndUpdateStepState { - Idle = 'idle', - InProgress = 'inProgress', - Warning = 'warning', - Skipped = 'skipped', - Success = 'success', - Error = 'error', -} - -enum ECheckAndUpdateStepId { - GenuineCheck = 'genuine-check', - FirmwareCheck = 'firmware-check', -} - // Illustration glyph tint per step state (idle / in progress stay neutral). const STEP_STATE_TONE: Partial< Record @@ -83,6 +91,14 @@ const STEP_STATE_TONE: Partial< const STEP_TIMEOUT_MS = 30 * 1000; const POST_UPDATE_STEP_TIMEOUT_MS = 90 * 1000; +const BootloaderDialogHostBridge = forwardRef( + function BootloaderDialogHostBridge(_props, ref) { + const dialogHost = useInPageDialog(EInPageDialogType.inOnboardingPage); + useImperativeHandle(ref, () => dialogHost, [dialogHost]); + return null; + }, +); + function CheckAndUpdatePage({ route: routeParams, }: IPageScreenProps< @@ -111,6 +127,11 @@ function CheckAndUpdatePage({ // While set, every round — including a manual Retry — upgrades to the // patient reconnect path and the longer watchdog budget. const pendingPostUpdateReconnectRef = useRef(false); + const bootloaderDialogHostRef = useRef(null); + const getBootloaderDialogHost = useCallback( + () => bootloaderDialogHostRef.current ?? undefined, + [], + ); const [isFirmwareRecheckPending, setIsFirmwareRecheckPending] = useState(false); @@ -144,11 +165,13 @@ function CheckAndUpdatePage({ const { verifyHardware, ensureActiveConnection, + rebindDeviceAfterFirmwareUpdate, getActiveDevice, getActiveDeviceFeatures, ensureStopScan, } = useDeviceConnect({ setCurrentDevice, + getBootloaderDialogHost, }); const { prepareUSBConnect, restoreOriginalTransport } = usePrepareUSBConnectForFirmwareUpdate(); @@ -217,26 +240,35 @@ function CheckAndUpdatePage({ ]); const [celebrate, setCelebrate] = useState(false); - // The flow may proceed once the firmware step is terminal-ok: Success or - // Skipped both reveal the "Continue" button, but only a real Success fires - // the celebratory confetti — skipping a check is not a pass. + // Both checks must be terminal-ok before setup can continue. Bootloader + // recovery can temporarily restart the two checks in a different order. + const genuineStepState = steps.find( + (step) => step.id === ECheckAndUpdateStepId.GenuineCheck, + )?.state; const firmwareStepState = steps.find( (step) => step.id === ECheckAndUpdateStepId.FirmwareCheck, )?.state; - const isReady = - firmwareStepState === ECheckAndUpdateStepState.Success || - firmwareStepState === ECheckAndUpdateStepState.Skipped; + const isReady = isCheckAndUpdateReady(steps); + const isAnyStepInProgress = isCheckAndUpdateRetryDisabled(steps); useEffect(() => { - if (firmwareStepState === ECheckAndUpdateStepState.Success) { - setCelebrate(true); - } - }, [firmwareStepState]); + setCelebrate( + isReady && firmwareStepState === ECheckAndUpdateStepState.Success, + ); + }, [firmwareStepState, isReady]); // Lets the focus-effect guard read the latest firmware step state without // joining the focus callback's dependency array. const firmwareStepStateRef = useRef(firmwareStepState); useEffect(() => { firmwareStepStateRef.current = firmwareStepState; }, [firmwareStepState]); + const genuineStepStateRef = useRef(genuineStepState); + useEffect(() => { + genuineStepStateRef.current = genuineStepState; + }, [genuineStepState]); + const [ + shouldReverifyGenuineAfterUpdate, + setShouldReverifyGenuineAfterUpdate, + ] = useState(false); const actions = useFirmwareUpdateActions(); const toFirmwareUpgradePage = useCallback(async () => { @@ -339,9 +371,13 @@ function CheckAndUpdatePage({ // Retry connecting to device after firmware update const retryDeviceConnectionAfterUpdate = useCallback( - async (connectId: string, isStale: () => boolean) => { + async ( + previousDevice: SearchDevice, + isStale: () => boolean, + onConnectId: (connectId: string) => void, + ) => { try { - await pRetry( + return await pRetry( async (attemptCount) => { if (isStale()) { // A newer round took over — stop touching the device from this @@ -349,18 +385,16 @@ function CheckAndUpdatePage({ throw new AbortError('stale firmware check round'); } console.log( - `Attempting to connect to device after firmware update (attempt ${attemptCount}/5)...`, + `Attempting to reconnect device after firmware update (attempt ${attemptCount}/11)...`, ); - await backgroundApiProxy.serviceHardware.getFeaturesWithoutCache({ - connectId, - params: { - retryCount: 1, - skipWebDevicePrompt: true, - }, - }); + const result = await rebindDeviceAfterFirmwareUpdate( + previousDevice, + onConnectId, + ); console.log('Device connection successful after firmware update'); + return result.device; }, { retries: 10, @@ -398,7 +432,7 @@ function CheckAndUpdatePage({ ); } }, - [intl], + [intl, rebindDeviceAfterFirmwareUpdate], ); const checkFirmwareUpdate = useCallback( @@ -458,14 +492,25 @@ function CheckAndUpdatePage({ await ensureTransportType(); const baseDevice = getActiveDevice() ?? currentDevice ?? deviceData.device; - if (!baseDevice?.connectId) { + if (!baseDevice || (!checkAfterUpdate && !baseDevice.connectId)) { setDeviceNotFoundErrorMessageStep(); return; } - watchdogConnectId = baseDevice.connectId; - await ensureActiveConnection(baseDevice as SearchDevice); - const latestDevice = getActiveDevice() ?? baseDevice; - setCurrentDevice(latestDevice as SearchDevice); + watchdogConnectId = baseDevice.connectId ?? undefined; + let latestDevice: SearchDevice; + if (checkAfterUpdate) { + latestDevice = await retryDeviceConnectionAfterUpdate( + baseDevice as SearchDevice, + isStale, + (connectId) => { + watchdogConnectId = connectId; + }, + ); + } else { + await ensureActiveConnection(baseDevice as SearchDevice); + latestDevice = (getActiveDevice() ?? baseDevice) as SearchDevice; + } + setCurrentDevice(latestDevice); if (!latestDevice?.connectId) { setDeviceNotFoundErrorMessageStep(); @@ -479,11 +524,6 @@ function CheckAndUpdatePage({ const compatibleConnectId = resolvedTransport.connectId; watchdogConnectId = compatibleConnectId; - // Wait for hardware to restart after firmware update - if (checkAfterUpdate) { - await retryDeviceConnectionAfterUpdate(compatibleConnectId, isStale); - } - const r = await backgroundApiProxy.serviceFirmwareUpdate.checkAllFirmwareRelease( { @@ -622,6 +662,14 @@ function CheckAndUpdatePage({ return; } + setShouldReverifyGenuineAfterUpdate((wasArmed) => + keepPostUpdateGenuineRecheckArmed( + wasArmed, + genuineStepStateRef.current, + ), + ); + setSteps((prev) => beginPostUpdateRecheck(prev)); + // Wait for remaining delay (0 if already >= 10s), then recheck firmware. // Keep the previous state during this cancellable window so a blur can // safely reschedule the check on the next focus. The check owns the @@ -671,97 +719,121 @@ function CheckAndUpdatePage({ return unsubscribe; }, [reactNavigation]); - const handleVerifyHardware = useCallback(async () => { - // Double-check: ensure device scanning is fully stopped before starting verification - await ensureStopScan(); - await ensureTransportType(); - - setSteps((prev) => { - const newSteps = [...prev]; - newSteps[0] = { - ...newSteps[0], - state: ECheckAndUpdateStepState.InProgress, - errorMessage: undefined, - }; - return newSteps; - }); + const runGenuineCheck = useCallback( + async (checkFirmwareAfterSuccess: boolean) => { + // Double-check: ensure device scanning is fully stopped before starting verification + await ensureStopScan(); + await ensureTransportType(); - try { - const [result] = await Promise.all([ - verifyHardware(currentDevice as SearchDevice, tabValue), - new Promise((resolve) => { - setTimeout(resolve, 1200); - }), - ]); - const latestDevice = - getActiveDevice() ?? - currentDevice ?? - (deviceData.device as SearchDevice | undefined); - setCurrentDevice(latestDevice); - console.log('verifyHardware', result); - if (!result) { - throw new OneKeyLocalError( - intl.formatMessage({ id: ETranslations.global_unknown_error }), - ); - } - // Skipping (dev skip or "continue anyway" when the verify service is - // unavailable) is not a verification pass — record it as Skipped so the - // step doesn't claim the device is genuine. - let genuineState = ECheckAndUpdateStepState.Error; - if (result.skipVerification) { - genuineState = ECheckAndUpdateStepState.Skipped; - } else if (result.verified) { - genuineState = ECheckAndUpdateStepState.Success; - } - const shouldContinueToFirmwareCheck = - genuineState !== ECheckAndUpdateStepState.Error; setSteps((prev) => { const newSteps = [...prev]; newSteps[0] = { ...newSteps[0], - state: genuineState, - errorMessage: - genuineState === ECheckAndUpdateStepState.Error - ? result.result?.message - : undefined, + state: ECheckAndUpdateStepState.InProgress, + errorMessage: undefined, }; - if (shouldContinueToFirmwareCheck) { - newSteps[1] = { - ...newSteps[1], - state: ECheckAndUpdateStepState.InProgress, - }; - } return newSteps; }); - if (shouldContinueToFirmwareCheck) { - setTimeout(() => { - void checkFirmwareUpdate(); - }, 150); + + try { + const verificationDevice = + getActiveDevice() ?? currentDevice ?? deviceData.device; + const [result] = await Promise.all([ + verifyHardware(verificationDevice as SearchDevice, tabValue), + new Promise((resolve) => { + setTimeout(resolve, 1200); + }), + ]); + const latestDevice = + getActiveDevice() ?? + currentDevice ?? + (deviceData.device as SearchDevice | undefined); + setCurrentDevice(latestDevice); + console.log('verifyHardware', result); + if (!result) { + throw new OneKeyLocalError( + intl.formatMessage({ id: ETranslations.global_unknown_error }), + ); + } + // Skipping (dev skip or "continue anyway" when the verify service is + // unavailable) is not a verification pass — record it as Skipped so the + // step doesn't claim the device is genuine. + let genuineState = ECheckAndUpdateStepState.Error; + if (result.verified) { + genuineState = ECheckAndUpdateStepState.Success; + } else if (result.skipVerification) { + genuineState = ECheckAndUpdateStepState.Skipped; + } + const shouldContinueToFirmwareCheck = + genuineState !== ECheckAndUpdateStepState.Error; + setSteps((prev) => { + const newSteps = [...prev]; + newSteps[0] = { + ...newSteps[0], + state: genuineState, + errorMessage: + genuineState === ECheckAndUpdateStepState.Error + ? result.result?.message + : undefined, + }; + if (shouldContinueToFirmwareCheck && checkFirmwareAfterSuccess) { + newSteps[1] = { + ...newSteps[1], + state: ECheckAndUpdateStepState.InProgress, + }; + } + return newSteps; + }); + if (shouldContinueToFirmwareCheck && checkFirmwareAfterSuccess) { + setTimeout(() => { + void checkFirmwareUpdate(); + }, 150); + } + isFirmwareVerifiedRef.current = !!result.verified; + } catch (_error) { + setSteps((prev) => { + const newSteps = [...prev]; + newSteps[0] = { + ...newSteps[0], + state: ECheckAndUpdateStepState.Error, + }; + return newSteps; + }); } - isFirmwareVerifiedRef.current = !!result.verified; - } catch (_error) { - setSteps((prev) => { - const newSteps = [...prev]; - newSteps[0] = { - ...newSteps[0], - state: ECheckAndUpdateStepState.Error, - }; - return newSteps; - }); + }, + [ + ensureStopScan, + ensureTransportType, + verifyHardware, + deviceData.device, + tabValue, + intl, + checkFirmwareUpdate, + getActiveDevice, + currentDevice, + ], + ); + + const handleVerifyHardware = useCallback(async () => { + await runGenuineCheck(true); + }, [runGenuineCheck]); + + useEffect(() => { + if ( + !shouldReverifyGenuineAfterUpdate || + !isCheckAndUpdateStepAccepted(firmwareStepState) + ) { + return; } - }, [ - ensureStopScan, - ensureTransportType, - verifyHardware, - deviceData.device, - tabValue, - intl, - checkFirmwareUpdate, - getActiveDevice, - currentDevice, - ]); + + setShouldReverifyGenuineAfterUpdate(false); + void runGenuineCheck(false); + }, [firmwareStepState, runGenuineCheck, shouldReverifyGenuineAfterUpdate]); const handleRetry = useCallback(async () => { + if (isAnyStepInProgress) { + return; + } const currentErrorStep = steps.find( (step) => step.state === ECheckAndUpdateStepState.Error, ); @@ -770,11 +842,18 @@ function CheckAndUpdatePage({ return; } if (currentErrorStep.id === ECheckAndUpdateStepId.GenuineCheck) { - await handleVerifyHardware(); + await runGenuineCheck(!isCheckAndUpdateStepAccepted(firmwareStepState)); } else if (currentErrorStep.id === ECheckAndUpdateStepId.FirmwareCheck) { await checkFirmwareUpdate(); } - }, [checkFirmwareUpdate, handleVerifyHardware, steps]); + }, [ + checkFirmwareUpdate, + firmwareStepState, + handleVerifyHardware, + isAnyStepInProgress, + runGenuineCheck, + steps, + ]); const handleSkipUpdate = useCallback(() => { Dialog.show({ @@ -917,6 +996,7 @@ function CheckAndUpdatePage({ contentContainerProps={{ gap: '$10', pt: '$5' }} foregroundLayer={celebrate ? : null} > + {steps.map((step, index) => { // On Success, collapse the row to a single celebratory title and hide // the description. The genuine title interpolates the product model @@ -1126,6 +1206,7 @@ function CheckAndUpdatePage({ testID={OnboardingTestIDs.checkAndUpdateRetryBtn} variant="primary" onPress={handleRetry} + disabled={isAnyStepInProgress} > {intl.formatMessage({ id: ETranslations.global_retry, diff --git a/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx b/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx index d57791363171..a757bea4a00c 100644 --- a/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx +++ b/packages/kit/src/views/Onboardingv2/pages/ConnectYourDevice.tsx @@ -85,6 +85,7 @@ import { HyperlinkText } from '../../../components/HyperlinkText'; import { ListItem } from '../../../components/ListItem'; import { WalletAvatar } from '../../../components/WalletAvatar'; import useAppNavigation from '../../../hooks/useAppNavigation'; +import { hardwareUiStateDialogLifecycle } from '../../../provider/Container/HardwareUiStateContainer/hardwareUiStateDialogLifecycle'; import { OnboardingPage } from '../components/Layout'; import { EBluetoothStatus, @@ -1167,6 +1168,15 @@ function ConnectYourDevicePage({ const connectId = item.device.connectId ?? ''; let detectedConnectProtocol: HardwareConnectProtocol | undefined; let connectedDevice = item.device; + let checkingDialogOpened = false; + let checkingDialogClosed = false; + const closeCheckingDeviceDialog = () => + backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ + connectId, + hardClose: false, + skipDelayClose: true, + deviceResetToHome: false, + }); try { // For third-party devices, skip CheckAndUpdate and go directly to FinalizeWalletSetup if ( @@ -1189,9 +1199,18 @@ function ConnectYourDevicePage({ item.device.commType === 'electron-ble' ? EHardwareTransportType.DesktopWebBle : EHardwareTransportType.BLE; - void backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ - connectId, - }); + const showCheckingDeviceDialog = () => + backgroundApiProxy.serviceHardwareUI.showCheckingDeviceDialog({ + connectId, + }); + checkingDialogOpened = true; + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.openAndWait( + showCheckingDeviceDialog, + ); + } else { + void showCheckingDeviceDialog(); + } const features = await backgroundApiProxy.serviceHardware.getFeaturesWithoutCache({ connectId, @@ -1212,6 +1231,13 @@ function ConnectYourDevicePage({ ...item.device, connectProtocol: detectedConnectProtocol, }; + + if (platformEnv.isNativeIOS) { + await hardwareUiStateDialogLifecycle.closeAndWait( + closeCheckingDeviceDialog, + ); + checkingDialogClosed = true; + } } navigation.push(EOnboardingPagesV2.CheckAndUpdate, { deviceData: { @@ -1238,12 +1264,22 @@ function ConnectYourDevicePage({ console.error('connectDevice error:', get(error, 'message', '')); } } finally { - void backgroundApiProxy.serviceHardwareUI.closeHardwareUiStateDialog({ - connectId, - hardClose: false, - skipDelayClose: true, - deviceResetToHome: false, - }); + if (!checkingDialogClosed) { + if (platformEnv.isNativeIOS && checkingDialogOpened) { + try { + await hardwareUiStateDialogLifecycle.closeAndWait( + closeCheckingDeviceDialog, + ); + } catch (error) { + console.error( + 'Failed to close onboarding hardware dialog:', + error, + ); + } + } else { + void closeCheckingDeviceDialog(); + } + } } }, [navigation], diff --git a/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.test.ts b/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.test.ts new file mode 100644 index 000000000000..62ad6b1f8350 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.test.ts @@ -0,0 +1,121 @@ +import { + ECheckAndUpdateStepId, + ECheckAndUpdateStepState, + beginPostUpdateRecheck, + isCheckAndUpdateReady, + isCheckAndUpdateRetryDisabled, + keepPostUpdateGenuineRecheckArmed, +} from './checkAndUpdateStepState'; + +describe('checkAndUpdateStepState', () => { + it('retires a stale genuine-check error when post-update recheck starts', () => { + const steps = beginPostUpdateRecheck([ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Error, + errorMessage: 'Device is in bootloader mode', + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.Warning, + errorMessage: 'Previous firmware check failed', + }, + ]); + + expect(steps).toEqual([ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Idle, + errorMessage: undefined, + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.InProgress, + errorMessage: undefined, + }, + ]); + expect(isCheckAndUpdateRetryDisabled(steps)).toBe(true); + expect(isCheckAndUpdateReady(steps)).toBe(false); + }); + + it('preserves a completed genuine check during post-update recheck', () => { + const steps = beginPostUpdateRecheck([ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Success, + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.Warning, + }, + ]); + + expect(steps[0].state).toBe(ECheckAndUpdateStepState.Success); + expect(steps[1].state).toBe(ECheckAndUpdateStepState.InProgress); + }); + + it('keeps a pending genuine recheck armed across a second firmware update', () => { + const armedAfterFirstUpdate = keepPostUpdateGenuineRecheckArmed( + false, + ECheckAndUpdateStepState.Error, + ); + + expect( + keepPostUpdateGenuineRecheckArmed( + armedAfterFirstUpdate, + ECheckAndUpdateStepState.Idle, + ), + ).toBe(true); + }); + + it('does not allow continuation until both checks are accepted', () => { + expect( + isCheckAndUpdateReady([ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Idle, + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.Success, + }, + ]), + ).toBe(false); + + expect( + isCheckAndUpdateReady([ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Success, + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.Success, + }, + ]), + ).toBe(true); + }); + + it('enables retry only after the active recheck reaches an error', () => { + const inProgressSteps = [ + { + id: ECheckAndUpdateStepId.GenuineCheck, + state: ECheckAndUpdateStepState.Idle, + }, + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.InProgress, + }, + ]; + const failedSteps = [ + inProgressSteps[0], + { + id: ECheckAndUpdateStepId.FirmwareCheck, + state: ECheckAndUpdateStepState.Error, + }, + ]; + + expect(isCheckAndUpdateRetryDisabled(inProgressSteps)).toBe(true); + expect(isCheckAndUpdateRetryDisabled(failedSteps)).toBe(false); + }); +}); diff --git a/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.ts b/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.ts new file mode 100644 index 000000000000..8e90d3091433 --- /dev/null +++ b/packages/kit/src/views/Onboardingv2/pages/checkAndUpdateStepState.ts @@ -0,0 +1,82 @@ +export enum ECheckAndUpdateStepState { + Idle = 'idle', + InProgress = 'inProgress', + Warning = 'warning', + Skipped = 'skipped', + Success = 'success', + Error = 'error', +} + +export enum ECheckAndUpdateStepId { + GenuineCheck = 'genuine-check', + FirmwareCheck = 'firmware-check', +} + +export type ICheckAndUpdateStep = { + id: ECheckAndUpdateStepId; + state?: ECheckAndUpdateStepState; + errorMessage?: string; +}; + +export function isCheckAndUpdateStepAccepted( + state: ECheckAndUpdateStepState | undefined, +) { + return ( + state === ECheckAndUpdateStepState.Success || + state === ECheckAndUpdateStepState.Skipped + ); +} + +export function isCheckAndUpdateReady(steps: ICheckAndUpdateStep[]) { + const genuineStep = steps.find( + (step) => step.id === ECheckAndUpdateStepId.GenuineCheck, + ); + const firmwareStep = steps.find( + (step) => step.id === ECheckAndUpdateStepId.FirmwareCheck, + ); + + return ( + isCheckAndUpdateStepAccepted(genuineStep?.state) && + isCheckAndUpdateStepAccepted(firmwareStep?.state) + ); +} + +export function isCheckAndUpdateRetryDisabled(steps: ICheckAndUpdateStep[]) { + return steps.some( + (step) => step.state === ECheckAndUpdateStepState.InProgress, + ); +} + +export function keepPostUpdateGenuineRecheckArmed( + wasArmed: boolean, + genuineStepState: ECheckAndUpdateStepState | undefined, +) { + return wasArmed || genuineStepState === ECheckAndUpdateStepState.Error; +} + +export function beginPostUpdateRecheck( + steps: T[], +) { + return steps.map((step) => { + if (step.id === ECheckAndUpdateStepId.FirmwareCheck) { + return { + ...step, + state: ECheckAndUpdateStepState.InProgress, + errorMessage: undefined, + }; + } + + if ( + step.id === ECheckAndUpdateStepId.GenuineCheck && + step.state === ECheckAndUpdateStepState.Error + ) { + return { + ...step, + state: ECheckAndUpdateStepState.Idle, + errorMessage: undefined, + }; + } + + return step; + }); +} diff --git a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx index 19541b2348b6..4ade73ce0f96 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx @@ -898,6 +898,18 @@ const BaseDevSettingsSection = () => { > + + + { > - - - { - if (!configFetcherAxios) { - const baseConfig = { - timeout: 7000, - }; - - let ipTableAdapter; - try { - const { isSupportIpTablePlatform } = - await import('../utils/ipTableUtils'); - if (isSupportIpTablePlatform()) { - const { createIpTableAdapter } = - await import('../request/helpers/ipTableAdapter'); - ipTableAdapter = createIpTableAdapter(baseConfig); - } - } catch (error) { - console.warn('[HardwareSDK] Failed to load IP Table adapter:', error); - } - - configFetcherAxios = axios.create({ - ...baseConfig, - adapter: ipTableAdapter, - }); - } - return configFetcherAxios; -} - -export async function createConfigFetcher(): Promise< - ((url: string) => Promise) | undefined -> { - // Only create configFetcher for platforms that support IP Table - // Otherwise return undefined to let SDK use its default fetching logic - try { - const { isSupportIpTablePlatform } = await import('../utils/ipTableUtils'); - if (!isSupportIpTablePlatform()) { - return undefined; - } - } catch { - return undefined; - } - - return async (url: string) => { - console.log('[HardwareSDK] configFetcher url:', url); - try { - const axiosInstance = await getConfigFetcherAxios(); - const response = await axiosInstance.get(url, { - timeout: 7000, - }); - console.log('[HardwareSDK] configFetcher success'); - return response.data; - } catch (error) { - console.warn('[HardwareSDK] configFetcher error:', error); - return null; - } - }; -} diff --git a/packages/shared/src/hardware/firmwareConfigProvider.test.ts b/packages/shared/src/hardware/firmwareConfigProvider.test.ts new file mode 100644 index 000000000000..d0a6e00ddb07 --- /dev/null +++ b/packages/shared/src/hardware/firmwareConfigProvider.test.ts @@ -0,0 +1,362 @@ +import axios from 'axios'; + +import { + getMappedDomainForIpLookup, + isIpTableTransportError, +} from '../request/helpers/ipTableAdapter'; +import { + isProxyActiveForUrl, + isSniSupported, + sniRequest, +} from '../request/helpers/sniRequest'; +import requestHelper from '../request/requestHelper'; + +import { fetchFirmwareConfig } from './firmwareConfigProvider'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn(), + }, +})); + +jest.mock('../platformEnv', () => ({ + __esModule: true, + default: { + isNative: true, + isDesktop: false, + }, +})); + +jest.mock('../logger/logger', () => ({ + defaultLogger: { + ipTable: { + request: { + info: jest.fn(), + }, + }, + }, +})); + +jest.mock('../request/requestHelper', () => ({ + __esModule: true, + default: { + getIpTableConfig: jest.fn(), + }, +})); + +jest.mock('../request/helpers/ipTableAdapter', () => ({ + getMappedDomainForIpLookup: jest.fn(), + isIpTableTransportError: jest.fn(), + reportIpTableRequestFailure: jest.fn(), + reportIpTableRequestSuccess: jest.fn(), +})); + +jest.mock('../request/helpers/sniRequest', () => ({ + isProxyActiveForUrl: jest.fn(), + isSniSupported: jest.fn(), + sniRequest: jest.fn(), +})); + +const mockedAxios = jest.mocked(axios); +const mockedRequestHelper = requestHelper as jest.Mocked; +const mockedGetMappedDomainForIpLookup = + getMappedDomainForIpLookup as jest.Mock; +const mockedIsIpTableTransportError = isIpTableTransportError as jest.Mock; +const mockedIsProxyActiveForUrl = isProxyActiveForUrl as jest.Mock; +const mockedIsSniSupported = isSniSupported as jest.Mock; +const mockedSniRequest = sniRequest as jest.Mock; + +const CONFIG_URL = 'https://data.onekey.so/config.json?noCache=1'; +const PRE_RELEASE_CONFIG_URL = + 'https://data.onekey.so/pre-config.json?noCache=1'; +const BUILTIN_FIRST_IP = '104.18.20.233'; + +function buildRemoteConfig(): RemoteConfigResponse { + const deviceConfig = { firmware: [], ble: [] }; + return { + bridge: {} as RemoteConfigResponse['bridge'], + classic: deviceConfig, + classic1s: deviceConfig, + classicpure: deviceConfig, + mini: deviceConfig, + touch: deviceConfig, + pro: deviceConfig, + pro2: deviceConfig, + }; +} + +function transportError(code = 'ECONNABORTED') { + return Object.assign(new Error(code), { code }); +} + +describe('firmware config provider', () => { + const directGet = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(1); + mockedAxios.create.mockReturnValue({ + get: directGet, + } as unknown as ReturnType); + mockedGetMappedDomainForIpLookup.mockResolvedValue('onekeycn.com'); + mockedIsIpTableTransportError.mockImplementation( + (error: { code?: string }) => + ['ECONNABORTED', 'SNI_TIMEOUT', 'SNI_CONNECTION_REFUSED'].includes( + error?.code ?? '', + ), + ); + mockedIsSniSupported.mockReturnValue(true); + mockedIsProxyActiveForUrl.mockResolvedValue(false); + mockedRequestHelper.getIpTableConfig.mockResolvedValue(null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns a valid direct-domain response without SNI', async () => { + const config = buildRemoteConfig(); + directGet.mockResolvedValue({ data: config }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toBe( + config, + ); + expect(directGet).toHaveBeenCalledWith(CONFIG_URL, { timeout: 7000 }); + expect(mockedSniRequest).not.toHaveBeenCalled(); + }); + + it('uses the pre-release URL selected by the App', async () => { + const config = buildRemoteConfig(); + directGet.mockResolvedValue({ data: config }); + + await expect(fetchFirmwareConfig({ preRelease: true })).resolves.toBe( + config, + ); + expect(directGet).toHaveBeenCalledWith(PRE_RELEASE_CONFIG_URL, { + timeout: 7000, + }); + }); + + it('normalizes a stable config published before the Pro2 manifest', async () => { + const { pro2: _pro2, ...stableConfig } = buildRemoteConfig(); + directGet.mockResolvedValue({ data: stableConfig }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual({ + ...stableConfig, + pro2: { + firmware: [], + ble: [], + }, + }); + expect(mockedSniRequest).not.toHaveBeenCalled(); + }); + + it('uses SNI fallback after confirming the system proxy is inactive', async () => { + const config = buildRemoteConfig(); + directGet.mockRejectedValue(transportError()); + mockedSniRequest.mockResolvedValue({ + statusCode: 200, + headers: {}, + body: JSON.stringify(config), + }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual( + config, + ); + expect(mockedIsProxyActiveForUrl).toHaveBeenCalledWith(CONFIG_URL); + expect(mockedSniRequest).toHaveBeenCalledWith({ + ip: BUILTIN_FIRST_IP, + hostname: 'data.onekey.so', + path: '/config.json?noCache=1', + method: 'GET', + headers: {}, + body: null, + timeout: 7000, + }); + }); + + it('keeps one SNI fallback when the system proxy is active', async () => { + const config = buildRemoteConfig(); + directGet.mockRejectedValue(transportError()); + mockedIsProxyActiveForUrl.mockResolvedValue(true); + mockedSniRequest.mockResolvedValue({ + statusCode: 200, + headers: {}, + body: JSON.stringify(config), + }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual( + config, + ); + expect(mockedIsProxyActiveForUrl).toHaveBeenCalledWith(CONFIG_URL); + expect(mockedSniRequest).toHaveBeenCalledTimes(1); + }); + + it('limits SNI fallback to one candidate when the proxy preflight fails', async () => { + directGet.mockRejectedValue(transportError()); + mockedIsProxyActiveForUrl.mockRejectedValue( + new Error('proxy preflight failed'), + ); + mockedSniRequest.mockRejectedValue(transportError('SNI_TIMEOUT')); + + await expect( + fetchFirmwareConfig({ preRelease: false }), + ).resolves.toBeNull(); + expect(mockedSniRequest).toHaveBeenCalledTimes(1); + }); + + it('keeps SNI fallback for legacy binaries without proxy preflight support', async () => { + const config = buildRemoteConfig(); + directGet.mockRejectedValue(transportError()); + mockedIsProxyActiveForUrl.mockResolvedValue(null); + mockedSniRequest.mockResolvedValue({ + statusCode: 200, + headers: {}, + body: JSON.stringify(config), + }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual( + config, + ); + expect(mockedSniRequest).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + 'HTTP response', + Object.assign(new Error('HTTP 500'), { response: { status: 500 } }), + ], + [ + 'cancellation', + Object.assign(new Error('cancelled'), { code: 'ERR_CANCELED' }), + ], + ])('does not start an SNI waterfall after a %s', async (_name, error) => { + directGet.mockRejectedValue(error); + + await expect( + fetchFirmwareConfig({ preRelease: false }), + ).resolves.toBeNull(); + expect(mockedSniRequest).not.toHaveBeenCalled(); + }); + + it('tries selection, last-best, then builtin without duplicates', async () => { + const config = buildRemoteConfig(); + directGet.mockRejectedValue(transportError()); + mockedRequestHelper.getIpTableConfig.mockResolvedValue({ + config: { + version: 2, + ttl_sec: 60, + generated_at: '2026-07-27T00:00:00.000Z', + signature: '', + domains: { + 'onekeycn.com': { + endpoints: [ + { + ip: '1.1.1.1', + provider: 'test', + region: 'ALL', + weight: 1, + }, + { + ip: '2.2.2.2', + provider: 'test', + region: 'ALL', + weight: 1, + }, + ], + }, + }, + }, + runtime: { + enabled: true, + lastUpdated: 0, + lastRegionCheck: 0, + selections: { 'onekeycn.com': '1.1.1.1' }, + lastBestIp: { 'onekeycn.com': '2.2.2.2' }, + }, + }); + mockedSniRequest + .mockRejectedValueOnce(transportError('SNI_TIMEOUT')) + .mockRejectedValueOnce(transportError('SNI_CONNECTION_REFUSED')) + .mockResolvedValueOnce({ + statusCode: 200, + headers: {}, + body: JSON.stringify(config), + }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual( + config, + ); + expect( + mockedSniRequest.mock.calls.map( + ([request]) => (request as { ip: string }).ip, + ), + ).toEqual(['1.1.1.1', '2.2.2.2', BUILTIN_FIRST_IP]); + }); + + it('continues after non-2xx and invalid SNI responses', async () => { + const config = buildRemoteConfig(); + directGet.mockRejectedValue(transportError()); + mockedRequestHelper.getIpTableConfig.mockResolvedValue({ + config: { + version: 2, + ttl_sec: 60, + generated_at: '2026-07-27T00:00:00.000Z', + signature: '', + domains: { + 'onekeycn.com': { + endpoints: [ + { + ip: '1.1.1.1', + provider: 'test', + region: 'ALL', + weight: 1, + }, + { + ip: '2.2.2.2', + provider: 'test', + region: 'ALL', + weight: 1, + }, + ], + }, + }, + }, + runtime: { + enabled: true, + lastUpdated: 0, + lastRegionCheck: 0, + selections: { 'onekeycn.com': '1.1.1.1' }, + lastBestIp: { 'onekeycn.com': '2.2.2.2' }, + }, + }); + mockedSniRequest + .mockResolvedValueOnce({ + statusCode: 404, + headers: {}, + body: 'not found', + }) + .mockResolvedValueOnce({ + statusCode: 200, + headers: {}, + body: '{invalid json', + }) + .mockResolvedValueOnce({ + statusCode: 200, + headers: {}, + body: JSON.stringify(config), + }); + + await expect(fetchFirmwareConfig({ preRelease: false })).resolves.toEqual( + config, + ); + expect( + mockedSniRequest.mock.calls.map( + ([request]) => (request as { ip: string }).ip, + ), + ).toEqual(['1.1.1.1', '2.2.2.2', BUILTIN_FIRST_IP]); + }); +}); diff --git a/packages/shared/src/hardware/firmwareConfigProvider.ts b/packages/shared/src/hardware/firmwareConfigProvider.ts new file mode 100644 index 000000000000..578bc4eb2d47 --- /dev/null +++ b/packages/shared/src/hardware/firmwareConfigProvider.ts @@ -0,0 +1,242 @@ +import axios from 'axios'; + +import { defaultLogger } from '../logger/logger'; +import { DEFAULT_IP_TABLE_CONFIG } from '../request/constants/ipTableDefaults'; +import { + getMappedDomainForIpLookup, + isIpTableTransportError, + reportIpTableRequestFailure, + reportIpTableRequestSuccess, +} from '../request/helpers/ipTableAdapter'; +import { nextIpTableRequestSequence } from '../request/helpers/ipTableRequestOutcome'; +import { + isProxyActiveForUrl, + isSniSupported, + sniRequest, +} from '../request/helpers/sniRequest'; +import requestHelper from '../request/requestHelper'; +import { getOrderedIpTableCandidates } from '../utils/ipTableUtils'; + +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + +const CONFIG_FETCH_TIMEOUT_MS = 7000; +const CONFIG_FETCH_MAX_SNI_CANDIDATES = 3; +const STABLE_CONFIG_URL = 'https://data.onekey.so/config.json'; +const PRE_RELEASE_CONFIG_URL = 'https://data.onekey.so/pre-config.json'; +const EMPTY_DEVICE_CONFIG: RemoteConfigResponse['pro2'] = { + firmware: [], + ble: [], +}; +const FIRMWARE_DEVICE_CONFIG_KEYS = [ + 'classic', + 'classic1s', + 'classicpure', + 'mini', + 'touch', + 'pro', + 'pro2', +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isFirmwareRemoteConfig( + value: unknown, +): value is RemoteConfigResponse { + return ( + isRecord(value) && + isRecord(value.bridge) && + FIRMWARE_DEVICE_CONFIG_KEYS.every((key) => isRecord(value[key])) + ); +} + +function parseFirmwareRemoteConfig( + value: unknown, +): RemoteConfigResponse | null { + let parsed = value; + if (typeof value === 'string') { + try { + parsed = JSON.parse(value) as unknown; + } catch { + return null; + } + } + const normalized = + isRecord(parsed) && parsed.pro2 === undefined + ? { ...parsed, pro2: EMPTY_DEVICE_CONFIG } + : parsed; + return isFirmwareRemoteConfig(normalized) ? normalized : null; +} + +async function getConfigSniCandidates(options: { + hostname: string; + configKey: string; +}): Promise { + const { hostname, configKey } = options; + let activeConfig = null; + try { + activeConfig = await requestHelper.getIpTableConfig(); + } catch { + activeConfig = null; + } + const activeEndpoints = + activeConfig?.config.domains[configKey]?.endpoints ?? []; + const endorsedIps = new Set(activeEndpoints.map((endpoint) => endpoint.ip)); + const preferredCandidates = [ + activeConfig?.runtime?.selections?.[configKey], + activeConfig?.runtime?.lastBestIp?.[configKey], + ].filter( + (candidate): candidate is string => + typeof candidate === 'string' && + candidate.length > 0 && + endorsedIps.has(candidate), + ); + const builtinCandidates = getOrderedIpTableCandidates({ + hostname, + configKey, + configWithRuntime: { + config: DEFAULT_IP_TABLE_CONFIG, + runtime: undefined, + }, + }); + return [...new Set([...preferredCandidates, ...builtinCandidates])].slice( + 0, + CONFIG_FETCH_MAX_SNI_CANDIDATES, + ); +} + +export async function fetchFirmwareConfig({ + preRelease, +}: { + preRelease: boolean; +}): Promise { + try { + const { isSupportIpTablePlatform } = await import('../utils/ipTableUtils'); + if (!isSupportIpTablePlatform()) { + return null; + } + } catch { + return null; + } + + const startedAt = Date.now(); + const configUrl = preRelease ? PRE_RELEASE_CONFIG_URL : STABLE_CONFIG_URL; + const parsedUrl = new URL(`${configUrl}?noCache=${Date.now().toString()}`); + const rootDomain = parsedUrl.hostname.split('.').slice(-2).join('.'); + const configKey = + (await getMappedDomainForIpLookup(rootDomain)) ?? rootDomain; + const plainAxios = axios.create(); + const domainRequestSequence = nextIpTableRequestSequence(); + try { + const response = await plainAxios.get(parsedUrl.toString(), { + timeout: CONFIG_FETCH_TIMEOUT_MS, + }); + reportIpTableRequestSuccess({ + domain: configKey, + requestType: 'domain', + target: parsedUrl.hostname, + requestSequence: domainRequestSequence, + }); + const config = parseFirmwareRemoteConfig(response.data); + defaultLogger.ipTable.request.info({ + info: `[FirmwareManifest] config_fetch route=domain outcome=${ + config ? 'success' : 'invalid_schema' + } durationMs=${Date.now() - startedAt}`, + }); + return config; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response + ) { + reportIpTableRequestSuccess({ + domain: configKey, + requestType: 'domain', + target: parsedUrl.hostname, + requestSequence: domainRequestSequence, + }); + } + if (!isIpTableTransportError(error)) { + return null; + } + reportIpTableRequestFailure({ + domain: configKey, + requestType: 'domain', + target: parsedUrl.hostname, + error: error instanceof Error ? error.message : 'Network error', + requestSequence: domainRequestSequence, + }); + } + + if (!isSniSupported()) { + return null; + } + + let sniCandidateLimit = CONFIG_FETCH_MAX_SNI_CANDIDATES; + try { + const proxyActive = await isProxyActiveForUrl(parsedUrl.toString()); + if (proxyActive === true) { + sniCandidateLimit = 1; + } + } catch { + // Firmware config keeps one direct fallback when proxy routing is unknown. + sniCandidateLimit = 1; + } + + const candidates = ( + await getConfigSniCandidates({ + hostname: parsedUrl.hostname, + configKey, + }) + ).slice(0, sniCandidateLimit); + for (const [candidateIndex, ip] of candidates.entries()) { + const requestSequence = nextIpTableRequestSequence(); + try { + const response = await sniRequest({ + ip, + hostname: parsedUrl.hostname, + path: `${parsedUrl.pathname}${parsedUrl.search}`, + method: 'GET', + headers: {}, + body: null, + timeout: CONFIG_FETCH_TIMEOUT_MS, + }); + if (response) { + reportIpTableRequestSuccess({ + domain: configKey, + requestType: 'ip', + target: ip, + requestSequence, + }); + } + if (response && response.statusCode >= 200 && response.statusCode < 300) { + const config = parseFirmwareRemoteConfig( + response.body ?? response.data, + ); + if (config) { + defaultLogger.ipTable.request.info({ + info: `[FirmwareManifest] config_fetch route=sni outcome=success candidateIndex=${candidateIndex} durationMs=${ + Date.now() - startedAt + }`, + }); + return config; + } + } + } catch (error) { + if (!isIpTableTransportError(error)) { + return null; + } + reportIpTableRequestFailure({ + domain: configKey, + requestType: 'ip', + target: ip, + error: error instanceof Error ? error.message : 'Network error', + requestSequence, + }); + } + } + return null; +} diff --git a/packages/shared/src/hardware/instance.test.ts b/packages/shared/src/hardware/instance.test.ts index df85c0110e93..90d63084a864 100644 --- a/packages/shared/src/hardware/instance.test.ts +++ b/packages/shared/src/hardware/instance.test.ts @@ -1,8 +1,16 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { cleanupHardwareSDKInstance, getHardwareSDKInstance } from './instance'; +import { EHardwareTransportType } from '../../types'; + +import { + getHardwareSDKInstance, + isDirectFirmwareHostBindingTransport, + resetHardwareSDKInstance, +} from './instance'; import { importHardwareSDK, importHardwareSDKLowLevel } from './sdk-loader'; +import type { RemoteConfigResponse } from '@onekeyfe/hd-core'; + jest.mock('@onekeyhq/shared/src/config/appConfig', () => ({ HARDWARE_SDK_IFRAME_SRC_ONEKEYSO: 'https://example.com', HARDWARE_SDK_VERSION: 'test', @@ -11,62 +19,194 @@ jest.mock('@onekeyhq/shared/src/config/appConfig', () => ({ jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ __esModule: true, default: { - isDesktop: true, + isDesktop: false, isNative: false, isExtensionBackgroundServiceWorker: false, }, })); - -jest.mock('./configFetcher', () => ({ - createConfigFetcher: jest.fn().mockResolvedValue(jest.fn()), -})); - jest.mock('./sdk-loader', () => ({ importHardwareSDK: jest.fn(), importHardwareSDKLowLevel: jest.fn(), })); -const mockedPlatformEnv = platformEnv as unknown as { - isDesktop: boolean; - isNative: boolean; - isExtensionBackgroundServiceWorker: boolean; -}; +describe('isDirectFirmwareHostBindingTransport', () => { + const mockedPlatformEnv = platformEnv as { + isDesktop: boolean; + isNative: boolean; + }; -describe('hardware SDK instance initialization', () => { beforeEach(() => { - jest.clearAllMocks(); - Object.assign(mockedPlatformEnv, { - isDesktop: true, - isNative: false, - isExtensionBackgroundServiceWorker: false, - }); + mockedPlatformEnv.isDesktop = false; + mockedPlatformEnv.isNative = false; }); - afterEach(async () => { - getHardwareSDKInstance.clear(); - await cleanupHardwareSDKInstance(); + it('uses the external host binding for native transports', () => { + mockedPlatformEnv.isNative = true; + + expect( + isDirectFirmwareHostBindingTransport(EHardwareTransportType.BLE), + ).toBe(true); }); - it('rejects instead of exposing an SDK instance when init returns false', async () => { - const sdk = { - init: jest.fn().mockResolvedValue(false), + it('uses the external host binding only for direct desktop transports', () => { + mockedPlatformEnv.isDesktop = true; + + expect( + isDirectFirmwareHostBindingTransport(EHardwareTransportType.WEBUSB), + ).toBe(true); + expect( + isDirectFirmwareHostBindingTransport( + EHardwareTransportType.DesktopWebBle, + ), + ).toBe(true); + expect( + isDirectFirmwareHostBindingTransport(EHardwareTransportType.Bridge), + ).toBe(false); + }); + + it('keeps browser transports on the SDK-managed path', () => { + expect( + isDirectFirmwareHostBindingTransport(EHardwareTransportType.WEBUSB), + ).toBe(false); + }); +}); + +describe('hardware SDK firmware config settings', () => { + const mockedPlatformEnv = platformEnv as { + isDesktop: boolean; + isNative: boolean; + isExtensionBackgroundServiceWorker?: boolean; + }; + const mockedImportHardwareSDK = importHardwareSDK as jest.Mock; + const mockedImportHardwareSDKLowLevel = + importHardwareSDKLowLevel as jest.Mock; + const firmwareConfig = { + bridge: {}, + classic: { firmware: [], ble: [] }, + classic1s: { firmware: [], ble: [] }, + classicpure: { firmware: [], ble: [] }, + mini: { firmware: [], ble: [] }, + touch: { firmware: [], ble: [] }, + pro: { firmware: [], ble: [] }, + pro2: { firmware: [], ble: [] }, + } as unknown as RemoteConfigResponse; + + const mockSdkLoaders = () => { + const init = jest.fn( + async ( + _settings: Record, + _lowLevel: unknown, + ): Promise => undefined, + ); + mockedImportHardwareSDK.mockResolvedValue({ + init, emit: jest.fn(), + dispose: jest.fn(async () => undefined), removeAllListeners: jest.fn(), - dispose: jest.fn().mockResolvedValue(undefined), - }; - const lowLevelSdk = { + }); + mockedImportHardwareSDKLowLevel.mockResolvedValue({ addHardwareGlobalEventListener: jest.fn(), + dispose: jest.fn(async () => undefined), removeAllListeners: jest.fn(), - dispose: jest.fn().mockResolvedValue(undefined), - }; - jest.mocked(importHardwareSDK).mockResolvedValue(sdk as never); - jest - .mocked(importHardwareSDKLowLevel) - .mockResolvedValue(lowLevelSdk as never); + }); + return init; + }; + + beforeEach(() => { + jest.clearAllMocks(); + getHardwareSDKInstance.clear(); + mockedPlatformEnv.isDesktop = true; + mockedPlatformEnv.isNative = false; + mockedPlatformEnv.isExtensionBackgroundServiceWorker = false; + }); + + afterEach(async () => { + await resetHardwareSDKInstance(); + }); + + it('initializes desktop with the App-managed manifest snapshot', async () => { + const init = mockSdkLoaders(); + const loadFirmwareConfig = jest.fn(async () => firmwareConfig); + await getHardwareSDKInstance({ + isPreRelease: false, + hardwareTransportType: EHardwareTransportType.WEBUSB, + loadFirmwareConfig, + }); + + const settings = init.mock.calls[0]?.[0]; + expect(loadFirmwareConfig).toHaveBeenCalledTimes(1); + expect(settings).toEqual( + expect.objectContaining({ + fetchConfig: false, + firmwareManifestMode: 'external-only', + preloadedConfig: firmwareConfig, + }), + ); + }); + + it('keeps external-only SDK initialization offline when the manifest is unavailable', async () => { + const init = mockSdkLoaders(); + const loadFirmwareConfig = jest.fn( + async (): Promise => undefined, + ); + + await getHardwareSDKInstance({ + isPreRelease: false, + hardwareTransportType: EHardwareTransportType.WEBUSB, + loadFirmwareConfig, + }); + + const settings = init.mock.calls[0]?.[0]; + expect(loadFirmwareConfig).toHaveBeenCalledTimes(1); + expect(settings).toEqual( + expect.objectContaining({ + fetchConfig: false, + firmwareManifestMode: 'external-only', + }), + ); + expect(settings).not.toHaveProperty('preloadedConfig'); + }); + + it('fails closed when the App-managed manifest loader is missing', async () => { + await expect( + getHardwareSDKInstance({ + isPreRelease: false, + hardwareTransportType: EHardwareTransportType.WEBUSB, + }), + ).rejects.toThrow('App-managed firmware config loader is required'); + expect(mockedImportHardwareSDK).not.toHaveBeenCalled(); + }); + + it('keeps web and extension platforms on the SDK-managed path', async () => { + mockedPlatformEnv.isDesktop = false; + const init = mockSdkLoaders(); + const loadFirmwareConfig = jest.fn(async () => firmwareConfig); + await getHardwareSDKInstance({ + isPreRelease: false, + hardwareTransportType: EHardwareTransportType.WEBUSB, + loadFirmwareConfig, + }); + + const settings = init.mock.calls[0]?.[0]; + expect(loadFirmwareConfig).not.toHaveBeenCalled(); + expect(settings).toEqual( + expect.objectContaining({ + fetchConfig: true, + firmwareManifestMode: 'sdk-managed', + }), + ); + expect(settings).not.toHaveProperty('preloadedConfig'); + }); + + it('rejects instead of exposing an SDK instance when init returns false', async () => { + const init = mockSdkLoaders(); + init.mockResolvedValue(false as never); await expect( getHardwareSDKInstance({ isPreRelease: false, + hardwareTransportType: EHardwareTransportType.WEBUSB, + loadFirmwareConfig: jest.fn(async () => firmwareConfig), }), ).rejects.toThrow('HardwareSDK initialization failed'); }); diff --git a/packages/shared/src/hardware/instance.ts b/packages/shared/src/hardware/instance.ts index 80e8b4835d51..3905f23dacde 100644 --- a/packages/shared/src/hardware/instance.ts +++ b/packages/shared/src/hardware/instance.ts @@ -8,7 +8,6 @@ import { memoizee } from '@onekeyhq/shared/src/utils/cacheUtils'; import { EHardwareTransportType } from '../../types'; import { OneKeyLocalError } from '../errors'; -import { createConfigFetcher } from './configFetcher'; import { importHardwareSDK, importHardwareSDKLowLevel } from './sdk-loader'; import type { EOnekeyDomain } from '../../types'; @@ -16,6 +15,7 @@ import type { ConnectSettings, CoreApi, LowLevelCoreApi, + RemoteConfigResponse, } from '@onekeyfe/hd-core'; // eslint-disable-next-line import/no-mutable-exports @@ -27,6 +27,16 @@ export const generateConnectSrc = () => { return connectSrc; }; +export const isDirectFirmwareHostBindingTransport = ( + hardwareTransportType?: EHardwareTransportType, +): boolean => + Boolean( + platformEnv.isNative || + (platformEnv.isDesktop && + (hardwareTransportType === EHardwareTransportType.WEBUSB || + hardwareTransportType === EHardwareTransportType.DesktopWebBle)), + ); + // Clean up current SDK instance and its event listeners export const cleanupHardwareSDKInstance = async (): Promise => { if (HardwareSDK) { @@ -68,36 +78,50 @@ const createHardwareSDKInstance = async (params: { hardwareConnectSrc?: EOnekeyDomain; debugMode?: boolean; hardwareTransportType?: EHardwareTransportType; -}) => - // eslint-disable-next-line no-async-promise-executor - new Promise(async (resolve, reject) => { - // Clean up previous instance if exists - if (HardwareSDK) { - await cleanupHardwareSDKInstance(); - } - - let env: undefined | ConnectSettings['env']; - if (params.hardwareTransportType === EHardwareTransportType.WEBUSB) { - // Desktop WebUSB doesn't need browser permission prompt - env = platformEnv.isDesktop ? 'desktop-webusb' : 'webusb'; - } else if ( - params.hardwareTransportType === EHardwareTransportType.DesktopWebBle - ) { - env = 'desktop-web-ble' as const; - } + loadFirmwareConfig?: () => Promise; +}): Promise => { + if (HardwareSDK) { + await cleanupHardwareSDKInstance(); + } - const configFetcher = await createConfigFetcher(); + let env: undefined | ConnectSettings['env']; + if (params.hardwareTransportType === EHardwareTransportType.WEBUSB) { + // Desktop WebUSB doesn't need browser permission prompt + env = platformEnv.isDesktop ? 'desktop-webusb' : 'webusb'; + } else if ( + params.hardwareTransportType === EHardwareTransportType.DesktopWebBle + ) { + env = 'desktop-web-ble' as const; + } - const settings: Partial & { - protocolV2DeviceInfoMockEnabled?: boolean; - } = { - debug: params.debugMode, - fetchConfig: true, - env, - configFetcher, - protocolV2DeviceInfoMockEnabled: false, - }; + const isAppManagedManifest = Boolean( + platformEnv.isNative || platformEnv.isDesktop, + ); + const settings: Partial & { + protocolV2DeviceInfoMockEnabled?: boolean; + } = { + debug: params.debugMode, + env, + protocolV2DeviceInfoMockEnabled: false, + }; + if (isAppManagedManifest) { + if (!params.loadFirmwareConfig) { + throw new OneKeyLocalError( + 'App-managed firmware config loader is required', + ); + } + settings.fetchConfig = false; + settings.firmwareManifestMode = 'external-only'; + const firmwareConfig = await params.loadFirmwareConfig(); + if (firmwareConfig) { + settings.preloadedConfig = firmwareConfig; + } + } else { + settings.fetchConfig = true; + settings.firmwareManifestMode = 'sdk-managed'; + } + try { HardwareSDK = await importHardwareSDK({ hardwareTransportType: params.hardwareTransportType, }); @@ -126,18 +150,17 @@ const createHardwareSDKInstance = async (params: { settings.preRelease = params.isPreRelease; - try { - const initialized = await HardwareSDK.init(settings, HardwareLowLevelSDK); - if (!initialized) { - throw new OneKeyLocalError('HardwareSDK initialization failed'); - } - // debugLogger.hardwareSDK.info('HardwareSDK initialized success'); - console.log('HardwareSDK initialized success'); - resolve(HardwareSDK); - } catch (e) { - reject(e); + const initialized = await HardwareSDK.init(settings, HardwareLowLevelSDK); + if (initialized === false) { + throw new OneKeyLocalError('HardwareSDK initialization failed'); } - }); + console.log('HardwareSDK initialized success'); + return HardwareSDK; + } catch (error) { + await cleanupHardwareSDKInstance(); + throw error; + } +}; export const getHardwareSDKInstance = memoizee(createHardwareSDKInstance, { promise: true, diff --git a/packages/shared/src/logger/scopes/update/scenes/firmware.ts b/packages/shared/src/logger/scopes/update/scenes/firmware.ts index 2532d7f59638..4ba211331d19 100644 --- a/packages/shared/src/logger/scopes/update/scenes/firmware.ts +++ b/packages/shared/src/logger/scopes/update/scenes/firmware.ts @@ -10,6 +10,40 @@ import type { EFirmwareType } from '@onekeyfe/hd-shared'; export { parseFirmwareVersions } from './firmwareVersions'; export class FirmwareScene extends BaseScene { + @LogToLocal({ level: 'info' }) + public firmwareArtifactSelfTest(params: { + runId: string; + runtime: 'bg'; + platform: 'ios' | 'android' | 'desktop'; + scenario: 'pro-firmware' | 'pro-resource' | 'pro-full-resource'; + phase: + | 'starting' + | 'preflight' + | 'reading' + | 'sdk-handoff' + | 'device-boundary' + | 'cache-stress' + | 'failure-cleanup' + | 'sweeping' + | 'completed' + | 'failed' + | 'cancelled'; + outcome: 'started' | 'progress' | 'success' | 'failure' | 'cancelled'; + durationMs: number; + bytes?: number; + chunkCount?: number; + materializedEntryCount?: number; + preflightCompletedIterations?: number; + preparedPlanValidated?: boolean; + sdkHandoffValidated?: boolean; + cleanupValidated?: boolean; + failureCleanupValidated?: boolean; + sdkBoundaryCode?: string; + errorCode?: string; + }) { + return params; + } + @LogToServer() @LogToLocal() public firmwareUpdateStarted(params: { diff --git a/packages/shared/src/request/helpers/ipTableAdapter.test.ts b/packages/shared/src/request/helpers/ipTableAdapter.test.ts index f79acdb1511e..e11d85bf096f 100644 --- a/packages/shared/src/request/helpers/ipTableAdapter.test.ts +++ b/packages/shared/src/request/helpers/ipTableAdapter.test.ts @@ -1,11 +1,13 @@ import axios from 'axios'; import { OneKeyLocalError } from '../../errors'; +import { EOneKeyErrorClassNames } from '../../errors/types/errorTypes'; import { getRequestHeaders } from '../Interceptor'; import requestHelper from '../requestHelper'; import { createIpTableAdapter, + isIpTableTransportError, resetAdapterFailoverStatesForTesting, setReportRequestFailureCallback, testIpSpeed, @@ -240,6 +242,27 @@ describe('ipTableAdapter SNI preflight and fail-closed behavior', () => { }); }); +describe('isIpTableTransportError', () => { + test('recognizes the normalized Axios network error used by the global interceptor', () => { + expect( + isIpTableTransportError({ + code: -99_999, + className: EOneKeyErrorClassNames.AxiosNetworkError, + message: 'Network error', + }), + ).toBe(true); + }); + + test('does not treat an HTTP response as a transport failure', () => { + expect( + isIpTableTransportError({ + className: EOneKeyErrorClassNames.AxiosNetworkError, + response: { status: 503 }, + }), + ).toBe(false); + }); +}); + describe('ipTableAdapter fail-open on domain network failures', () => { const BUILTIN_CN_IPS = [ '104.18.20.233', @@ -339,10 +362,42 @@ describe('ipTableAdapter fail-open on domain network failures', () => { ip: string; hostname: string; }; - expect(BUILTIN_CN_IPS).toContain(sniArgs.ip); + expect(sniArgs.ip).toBe(BUILTIN_CN_IPS[0]); expect(sniArgs.hostname).toBe('wallet.onekeycn.com'); }); + test('maps data.onekey.so fail-open to the first onekeycn.com builtin IP', async () => { + fallbackAdapter.mockImplementation(async () => { + throw networkError(); + }); + for (let i = 0; i < 3; i += 1) { + await expect( + createIpTableAdapter({})( + buildConfig('https://data.onekey.so/config.json'), + ), + ).rejects.toMatchObject({ code: 'ECONNABORTED' }); + } + + mockedSniRequest.mockResolvedValue({ + statusCode: 200, + statusText: 'OK', + headers: {}, + body: '{"ok":true}', + }); + + await expect( + createIpTableAdapter({})( + buildConfig('https://data.onekey.so/config.json'), + ), + ).resolves.toMatchObject({ status: 200, data: { ok: true } }); + expect(mockedSniRequest).toHaveBeenCalledWith( + expect.objectContaining({ + ip: BUILTIN_CN_IPS[0], + hostname: 'data.onekey.so', + }), + ); + }); + test('does not replay the failing request itself', async () => { await failNTimes(3); // Every one of the 3 failing requests must reject; no hidden retry via SNI. diff --git a/packages/shared/src/request/helpers/ipTableAdapter.ts b/packages/shared/src/request/helpers/ipTableAdapter.ts index f0de864dbc49..5124762d5051 100644 --- a/packages/shared/src/request/helpers/ipTableAdapter.ts +++ b/packages/shared/src/request/helpers/ipTableAdapter.ts @@ -1,6 +1,7 @@ import axios, { AxiosHeaders } from 'axios'; import { OneKeyLocalError } from '../../errors'; +import { EOneKeyErrorClassNames } from '../../errors/types/errorTypes'; import { defaultLogger } from '../../logger/logger'; import platformEnv from '../../platformEnv'; import { memoizee } from '../../utils/cacheUtils'; @@ -102,7 +103,7 @@ function logIpTableEvent( /** * Request failure callback parameters */ -interface IRequestFailureParams { +export interface IRequestFailureParams { /** Root domain (e.g., "onekey.so") */ domain: string; /** 'ip' for SNI request failure, 'domain' for direct domain request failure */ @@ -124,7 +125,7 @@ let reportRequestFailureCallback: * its consecutive-failure counters reset on real recovery instead of only * ever incrementing. */ -interface IRequestSuccessParams { +export interface IRequestSuccessParams { domain: string; requestType: 'ip' | 'domain'; target: string; @@ -142,6 +143,18 @@ export function setReportRequestSuccessCallback( reportRequestSuccessCallback = callback; } +export function reportIpTableRequestSuccess( + params: IRequestSuccessParams, +): void { + reportRequestSuccessCallback?.(params); +} + +export function reportIpTableRequestFailure( + params: IRequestFailureParams, +): void { + reportRequestFailureCallback?.(params); +} + /** * Once a request has been handed to the SNI transport it may have reached * the server even when we got no usable response back. Re-sending it over @@ -196,7 +209,7 @@ const SHARED_IP_DOMAINS = new Set(['onekey.so']); * Get the mapped domain for IP lookup * For domains in SHARED_IP_DOMAINS, use the current API environment's domain */ -async function getMappedDomainForIpLookup( +export async function getMappedDomainForIpLookup( rootDomain: string, ): Promise { if (!SHARED_IP_DOMAINS.has(rootDomain)) { @@ -294,9 +307,13 @@ const TRANSPORT_ERROR_CODES = new Set([ 'ENETUNREACH', 'EHOSTUNREACH', 'EAI_AGAIN', + 'SNI_TIMEOUT', + 'SNI_CONNECTION_REFUSED', + 'SNI_DNS_FAILED', + 'SNI_NETWORK_UNREACHABLE', ]); -function isTransportLevelError(error: unknown): boolean { +export function isIpTableTransportError(error: unknown): boolean { if (!error || typeof error !== 'object') { return false; } @@ -306,6 +323,13 @@ function isTransportLevelError(error: unknown): boolean { if ('response' in error && (error as { response?: unknown }).response) { return false; } + if ( + 'className' in error && + (error as { className?: unknown }).className === + EOneKeyErrorClassNames.AxiosNetworkError + ) { + return true; + } const code = getErrorCode(error); return TRANSPORT_ERROR_CODES.has(code); } @@ -327,7 +351,7 @@ function getFailoverState(lookupDomain: string): IAdapterFailoverState { hostFailures: new Map(), failOpenUntil: 0, activatedHostSequences: new Map(), - fallbackIpIndex: 0, + fallbackIpIndex: -1, }; adapterFailoverStates.set(lookupDomain, state); } @@ -422,7 +446,7 @@ async function recordDomainRequestOutcome(options: { return; } - if (!isTransportLevelError(error)) { + if (!isIpTableTransportError(error)) { return; } if (await isFailoverDisabledByDevSettings()) { @@ -982,7 +1006,7 @@ export function createIpTableAdapter( hostname && rootDomain && reportRequestFailureCallback && - isTransportLevelError(error) + isIpTableTransportError(error) ) { debugLog( `[IpTableAdapter] Domain request failed (not fallback): ${hostname}`, @@ -1423,8 +1447,8 @@ export function createAxiosWithIpTable(axiosConfig: AxiosRequestConfig = {}) { /** * Test endpoint speed using domain directly (no IP Table) */ -export async function testDomainSpeed( - domain: string, +export async function testHostSpeed( + hostname: string, path: string, timeout = 3000, ): Promise { @@ -1436,8 +1460,7 @@ export async function testDomainSpeed( const headers = await getRequestHeaders(); - // Build full URL: https://wallet.{domain}/wallet/v1/health - const fullUrl = `https://wallet.${domain}${path}`; + const fullUrl = `https://${hostname}${path}`; await plainAxios.get(fullUrl, { timeout, @@ -1453,10 +1476,10 @@ export async function testDomainSpeed( }); return latency; } catch (error) { - debugWarn(`[IpTableAdapter] Domain test failed for ${domain}:`, error); + debugWarn(`[IpTableAdapter] Domain test failed for ${hostname}:`, error); defaultLogger.ipTable.request.warn({ info: redactIpLiterals( - `[IpTable] Domain speed test failed for ${domain}: ${ + `[IpTable] Domain speed test failed for ${hostname}: ${ error instanceof Error ? error.message : 'Unknown error' }`, ), @@ -1465,12 +1488,20 @@ export async function testDomainSpeed( } } +export async function testDomainSpeed( + domain: string, + path: string, + timeout = 3000, +): Promise { + return testHostSpeed(`wallet.${domain}`, path, timeout); +} + /** * Test endpoint speed using IP with SNI */ -export async function testIpSpeed( +export async function testIpHostSpeed( ip: string, - domain: string, + hostname: string, path: string, timeout = 3000, ): Promise { @@ -1486,9 +1517,7 @@ export async function testIpSpeed( // Get OneKey request headers const headers = await getRequestHeaders(); - // SNI hostname should be: wallet.{domain} - const sniHostname = `wallet.${domain}`; - const targetUrl = `https://${sniHostname}${path}`; + const targetUrl = `https://${hostname}${path}`; let proxyActive: boolean | null; try { @@ -1496,7 +1525,7 @@ export async function testIpSpeed( } catch (error) { logIpTableEvent('warn', 'sni_speed_preflight_decision', { platform: getSniLogPlatform(), - hostname: sniHostname, + hostname, ipHash: hashForLog(ip), proxyActive: 'null', decision: 'skip_ip_speed', @@ -1510,7 +1539,7 @@ export async function testIpSpeed( if (proxyActive === true) { logIpTableEvent('warn', 'sni_speed_preflight_decision', { platform: getSniLogPlatform(), - hostname: sniHostname, + hostname, ipHash: hashForLog(ip), proxyActive, decision: 'skip_ip_speed', @@ -1521,7 +1550,7 @@ export async function testIpSpeed( logIpTableEvent('info', 'sni_speed_preflight_decision', { platform: getSniLogPlatform(), - hostname: sniHostname, + hostname, ipHash: hashForLog(ip), proxyActive: proxyActive === null ? 'null' : proxyActive, decision: 'test_ip_speed', @@ -1533,7 +1562,7 @@ export async function testIpSpeed( const response = await sniRequest({ ip, - hostname: sniHostname, + hostname, path, method: 'GET', timeout, @@ -1553,11 +1582,11 @@ export async function testIpSpeed( const latency = Date.now() - startTime; debugLog( - `[IpTableAdapter] IP test: ${ip} -> ${sniHostname}${path} -> ${latency}ms`, + `[IpTableAdapter] IP test: ${ip} -> ${hostname}${path} -> ${latency}ms`, ); defaultLogger.ipTable.request.info({ info: redactIpLiterals( - `[IpTable] IP speed test successful: ${ip} -> ${sniHostname}${path} : ${latency} ms`, + `[IpTable] IP speed test successful: ${ip} -> ${hostname}${path} : ${latency} ms`, ), }); return latency; @@ -1574,6 +1603,15 @@ export async function testIpSpeed( } } +export async function testIpSpeed( + ip: string, + domain: string, + path: string, + timeout = 3000, +): Promise { + return testIpHostSpeed(ip, `wallet.${domain}`, path, timeout); +} + // ========== Request Failure Reporting ========== export function setReportRequestFailureCallback( callback: (params: IRequestFailureParams) => void, diff --git a/packages/shared/src/routes/gallery.ts b/packages/shared/src/routes/gallery.ts index 5f2e1dbc2500..98df91c5cad8 100644 --- a/packages/shared/src/routes/gallery.ts +++ b/packages/shared/src/routes/gallery.ts @@ -26,6 +26,7 @@ export enum EGalleryRoutes { ComponentEmpty = 'component-Empty', ComponentErrorToast = 'component-ErrorToast', ComponentForm = 'component-Form', + ComponentFirmwareArtifact = 'component-FirmwareArtifact', ComponentHardware = 'component-Hardware', ComponentLedgerAppOps = 'component-LedgerAppOps', ComponentIcon = 'component-Icon', diff --git a/packages/shared/src/utils/avatarUtils.ts b/packages/shared/src/utils/avatarUtils.ts index 83e36b619a40..1b90332f025d 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/ProBlack.png'), + [EDeviceType.Pro2]: require('../assets/wallet/avatar/Pro2Black.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'), @@ -73,7 +73,6 @@ export const AllWalletAvatarImages = { ...ThirdPartyWalletAvatarImages, }; -export type IAllWalletAvatarImageNames = keyof typeof AllWalletAvatarImages; export type IHdWalletAvatarImageNames = keyof typeof HdWalletAvatarImages; export type IHwWalletAvatarImageNames = keyof typeof HwWalletAvatarImages; export type IOthersWalletAvatarImageNames = @@ -86,6 +85,9 @@ export type IAllWalletAvatarImageNamesWithoutDividers = | IHwWalletAvatarImageNames | IOthersWalletAvatarImageNames | IThirdPartyWalletAvatarImageNames; +export type IAllWalletAvatarImageNames = + | 'cardDividers' + | IAllWalletAvatarImageNamesWithoutDividers; export function getDeviceAvatarImage( deviceType: IDeviceType, diff --git a/packages/shared/src/utils/deviceUtils.test.ts b/packages/shared/src/utils/deviceUtils.test.ts index 483c2b1c1cb3..8e0ef6f8a76e 100644 --- a/packages/shared/src/utils/deviceUtils.test.ts +++ b/packages/shared/src/utils/deviceUtils.test.ts @@ -1,5 +1,7 @@ import { EDeviceType } from '@onekeyfe/hd-shared'; +import { EHardwareTransportType } from '../../types'; + import deviceUtils, { ESupportSettings } from './deviceUtils'; import { NEO_DEVICE_TYPE, @@ -12,6 +14,44 @@ const mockGetAutoLockOptions = jest.fn(); const mockGetAutoShutDownOptions = jest.fn(); const PROTOCOL_V2_NEVER_TIMEOUT_MS = 0x10_00_00_00; +describe('getFixedUpdatingConnectId', () => { + const device = { + connectId: 'USB_SERIAL', + usbConnectId: 'USB_SERIAL', + bleConnectId: 'BLE_PERIPHERAL_ID', + }; + + it('uses the persisted BLE peripheral for desktop BLE updates', () => { + expect( + deviceUtils.getFixedUpdatingConnectId({ + updatingConnectId: 'STALE_CONNECT_ID', + currentTransportType: EHardwareTransportType.DesktopWebBle, + device, + }), + ).toBe('BLE_PERIPHERAL_ID'); + }); + + it('keeps the transport-derived connect ID for non-BLE updates', () => { + expect( + deviceUtils.getFixedUpdatingConnectId({ + updatingConnectId: undefined, + currentTransportType: EHardwareTransportType.WEBUSB, + device, + }), + ).toBeUndefined(); + }); + + it('rejects a BLE value that only aliases the USB serial', () => { + expect( + deviceUtils.getFixedUpdatingConnectId({ + updatingConnectId: 'CURRENT_BLE_ID', + currentTransportType: EHardwareTransportType.DesktopWebBle, + device: { ...device, bleConnectId: 'USB_SERIAL' }, + }), + ).toBe('CURRENT_BLE_ID'); + }); +}); + jest.mock('../hardware/instance', () => ({ CoreSDKLoader: jest.fn(async () => ({ getDeviceBootloaderVersion: jest.fn(() => []), diff --git a/packages/shared/src/utils/deviceUtils.ts b/packages/shared/src/utils/deviceUtils.ts index d8eab53f46b0..d1e17a44caee 100644 --- a/packages/shared/src/utils/deviceUtils.ts +++ b/packages/shared/src/utils/deviceUtils.ts @@ -68,7 +68,7 @@ function getDefaultDeviceLabel(deviceType: IDeviceType): string { [EDeviceType.Touch]: 'OneKey Touch', [EDeviceType.Pro]: 'OneKey Pro', [EDeviceType.Pro2]: 'OneKey Pro 2', - [EDeviceType.Neo]: 'OneKey Neo', + [NEO_DEVICE_TYPE]: 'OneKey Neo', [EDeviceType.Unknown]: '', }; return defaultLabelsByDeviceType[deviceType] || ''; @@ -353,6 +353,31 @@ function getUpdatingConnectId({ return platformEnv.isNative ? connectId : undefined; } +function getFixedUpdatingConnectId({ + updatingConnectId, + currentTransportType, + device, +}: { + updatingConnectId: string | undefined; + currentTransportType: EHardwareTransportType; + device: + | Pick + | undefined; +}) { + if (currentTransportType !== EHardwareTransportType.DesktopWebBle) { + return updatingConnectId; + } + const bleConnectId = device?.bleConnectId?.trim(); + if (!bleConnectId) { + return updatingConnectId; + } + const normalizedBleConnectId = bleConnectId.toLowerCase(); + const aliasesUsbConnectId = [device?.connectId, device?.usbConnectId].some( + (candidate) => candidate?.trim().toLowerCase() === normalizedBleConnectId, + ); + return aliasesUsbConnectId ? updatingConnectId : bleConnectId; +} + function checkInputPinOnSoftwareSupport(deviceType: IDeviceType) { return [ EDeviceType.Classic, @@ -987,6 +1012,7 @@ export default { existsFirmwareFromSearchDevice, getDeviceScanner, getUpdatingConnectId, + getFixedUpdatingConnectId, isConfirmOnDeviceAction, buildDeviceLabel, buildDeviceName, diff --git a/packages/shared/src/utils/ipTableUtils.test.ts b/packages/shared/src/utils/ipTableUtils.test.ts index 4313044e45ef..db55312b2f2c 100644 --- a/packages/shared/src/utils/ipTableUtils.test.ts +++ b/packages/shared/src/utils/ipTableUtils.test.ts @@ -80,23 +80,23 @@ describe('verifyIpTableConfigSignature', () => { test('returns true for valid signature from correct signer', async () => { const validConfig: IIpTableRemoteConfig = { - 'domains': { + domains: { 'onekeycn.com': { - 'endpoints': [ + endpoints: [ { - 'ip': '104.18.20.233', - 'provider': 'cloudflare', - 'region': 'GLOBAL', - 'weight': 100, + ip: '104.18.20.233', + provider: 'cloudflare', + region: 'GLOBAL', + weight: 100, }, ], }, }, - 'generated_at': '2025-11-07T07:27:37.338Z', - 'signature': + generated_at: '2025-11-07T07:27:37.338Z', + signature: '0x66708c60c6a1aae2d34d75c6f42662208279f2dc13e1370a0b52fdfe783fc6d56369708a8a5a98f0e3eda900d537de0cbb7e808bbaf1bef267f350843a761b5d1c', - 'ttl_sec': 86_400, - 'version': 1, + ttl_sec: 86_400, + version: 1, }; await expect(verifyIpTableConfigSignature(validConfig)).resolves.toBe(true); }); diff --git a/packages/shared/src/utils/ipTableUtils.ts b/packages/shared/src/utils/ipTableUtils.ts index b15255cf9f7f..aa5f92415162 100644 --- a/packages/shared/src/utils/ipTableUtils.ts +++ b/packages/shared/src/utils/ipTableUtils.ts @@ -7,6 +7,7 @@ import platformEnv from '../platformEnv'; import { CDN_SIGNER_ADDRESS } from '../request/constants/ipTableDefaults'; import type { + IIpTableConfigWithRuntime, IIpTableRemoteConfig, IIpTableSignatureVerifyResult, } from '../request/types/ipTable'; @@ -87,6 +88,38 @@ export function isValidIpTableRemoteConfigShape( return true; } +export function getOrderedIpTableCandidates(options: { + hostname: string; + configWithRuntime: IIpTableConfigWithRuntime; + configKey?: string; + exactHostOnly?: boolean; + maxCandidates?: number; +}): string[] { + const hostname = options.hostname.toLowerCase().replace(/\.$/u, ''); + const configKey = + options.configKey ?? + (options.exactHostOnly + ? hostname + : hostname.split('.').slice(-2).join('.')); + const endpoints = + options.configWithRuntime.config.domains[configKey]?.endpoints ?? []; + const endorsedIps = new Set(endpoints.map((endpoint) => endpoint.ip)); + const candidates = [ + options.configWithRuntime.runtime?.selections?.[configKey], + options.configWithRuntime.runtime?.lastBestIp?.[configKey], + ...endpoints + .toSorted((left, right) => right.weight - left.weight) + .map((endpoint) => endpoint.ip), + ].filter( + (ip): ip is string => + typeof ip === 'string' && ip.length > 0 && endorsedIps.has(ip), + ); + return [...new Set(candidates)].slice( + 0, + options.maxCandidates ?? candidates.length, + ); +} + function logVerifyEvent( level: 'info' | 'error', fields: Record, diff --git a/packages/shared/types/device.ts b/packages/shared/types/device.ts index 28d0f2543f27..5e91683df8c3 100644 --- a/packages/shared/types/device.ts +++ b/packages/shared/types/device.ts @@ -124,6 +124,7 @@ export type ICheckAllFirmwareReleaseResult = { isBootloaderMode: boolean; deviceType: IDeviceType | undefined; deviceUUID: string; + firmwareUpdatePlanDigest?: string; deviceName: string | undefined; deviceBleName: string | undefined; updatingConnectId: string | undefined; diff --git a/yarn.lock b/yarn.lock index 568bfaae08ff..4e96076177f5 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.69" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.69" + "@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" "@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.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-ble-sdk@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-react-native": "npm:1.2.0-alpha.69" - checksum: 10/63de9355694551a1d1c0de1285c4c6a4d1f20931cd9451452ad21fcd7339786a8a19e126dc6057af6a1f6ff97871aa253c1ea8215c6c7036d11580407ba32753 + "@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 languageName: node linkType: hard -"@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-common-connect-sdk@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-emulator": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-lowlevel": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-usb": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.69" - checksum: 10/fbc4d4354c3c0bd16a4f7e47449d2962ceaa7a9489897594419553a4f67f7b0013ddbd96361eb655de5b2e6b8f53a707a191f5c414ef4f32c52be3f05a481f7b + "@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 languageName: node linkType: hard -"@onekeyfe/hd-core@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-core@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" 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/f058b7130028676afc60726f6810b7aa5bfc8f590deb2e81200b38cf3144a0e39dffcc31116fb422a6ee2c4d944d5780b469878e9759485ab250a2b6fde20ede + checksum: 10/b95d99f1fe89c03ddcd0cab4480bf8ad0383e4a113f3f311a2b62f0c36c39c770b7e317e1dc75e96e56a94a7dd0fca611907780e4cf29d64ed98090338891f35 languageName: node linkType: hard -"@onekeyfe/hd-shared@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-shared@npm:1.2.0-alpha.69" - checksum: 10/f9914e1ec6f0be1f0a942069fc89b8f0e3e02e5fd5ad535957ad2e853ed5fbb26cc074cc2d5cf26a7fcb80cf821eefd18d36d7a96315c221ad0c5f1531e618f0 +"@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 languageName: node linkType: hard -"@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-electron@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@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" "@stoprocent/noble": "npm:2.3.16" p-retry: "npm:^4.6.2" peerDependencies: electron: ">=25.0.0" electron-log: ">=4.0.0" - checksum: 10/13bfbaa88aa675ea838b856187047c7d58b5d5bb396dd87e51e009cc44f45ae5342c63b35c32f02fa9ae29d7d7a1042421bb910cc000dce60fe51f65751fdc32 + checksum: 10/47443ee470fe4b819f3159f8403641092c819b0a9a5e2763ac164c6bf6ac59fb06331a5919d26b65600278cf81ef2509f981c138ba4c9c41a9d1093e9c674382 languageName: node linkType: hard -"@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-emulator@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" axios: "npm:1.15.2" secure-json-parse: "npm:^4.0.0" - checksum: 10/b0b434da5169a422ecafb2a2e527c6043479a57a07c1d66393ec0bf2378cf5bd85dc39922b79dad6025cfea4aa637d4664b1889512e24ed4ee3d370ccb2c221f + checksum: 10/86cf081fdd030e5c3edfa9a1873a93d727d18a97850a3b387011c6c64878411f789c745c8d0b926657e907e5a3a6251ae95d60875f7bd061f0840fcd2696f3b4 languageName: node linkType: hard -"@onekeyfe/hd-transport-http@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-http@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" axios: "npm:1.15.2" secure-json-parse: "npm:^4.0.0" - checksum: 10/bed565082fc52213b3f3f92443c5cfc15c01d7f1e580de65f2cac50204ef846c36c7df2a67e1856e4de2e7cf7e59b09bc01eb95d5600fafb66ae4a24d843ea9a + checksum: 10/c3bd1a352302112306d385c788bba3162174009f77f8c4fa32c5cd5476be63bb26086ddd7dab061f7006ee3dc576f42da14cba8920def0c359f1ab8f33f6ee0a languageName: node linkType: hard -"@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-lowlevel@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" - checksum: 10/0d411f9ee6b53687531c72aaf2874053fa964e678046db6b27431b8fefa99112d2aa705192173883709ca59ab480f1b0fda97a550c864ffdf8b6c4b5305c473d + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + checksum: 10/b8376dd300a877da97c02237839b99f8b4049b861915606613cea7eef483deeafd1e8cebc3373198c54286f676cc2e9b83ad03eecba8cfc2fc4651da9805a3d5 languageName: node linkType: hard -"@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-react-native@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@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/react-native-ble-utils": "npm:^0.1.6" react-native-ble-plx: "npm:3.5.1" - checksum: 10/c6c01ab0c21724915fd0db6318756c921e6b1b3383f63561d36159ef4876a02a1b647152800403fa3e0b6919f8462b3f6398da706ca17f983e751c0588d8a926 + checksum: 10/bac614c958c56fe4e7acd1df93c90ab16d66be21299d067bf92cefb23e7dd311b35219b95087ec0640d19c40b23090c02aea5ef7f9f3703034789761ae7efa15 languageName: node linkType: hard -"@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-usb@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" bytebuffer: "npm:^5.0.1" usb: "npm:^2.14.0" - checksum: 10/5f04d416ebf50ee2eff54ada58f0c3862b70ef62b35f05638cd5aa17bebd7579e2bea7337cf221f8861d956cdee4396275062b18740ea1821f4c6a7f0430756b + checksum: 10/d08bb4a0464aa0b9300b19c4924d75a728ce668f4fb32fac35740e8fd9ff23fb656f586480f3d465b970580fa1c6839610e3650ba343110a21b0b769ed11d288 languageName: node linkType: hard -"@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport-web-device@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" - checksum: 10/edc78ba8279912ac82304119c68fd99181557b274c75d1d2e4b54afcc36509b2827899ea95ee5ba9ced6204a6277b98de6ac1f68dc1077e1204763ae84c3d918 + "@onekeyfe/hd-shared": "npm:1.2.0-alpha.77" + "@onekeyfe/hd-transport": "npm:1.2.0-alpha.77" + checksum: 10/fb9674b8507faa65ae7a579c4e01c1da0884a15cc6a344dc436ca54994050928d892635b92d80a9a9c94af6888d26d88b361375d1d2b5d1766d23505014c849d languageName: node linkType: hard -"@onekeyfe/hd-transport@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-transport@npm:1.2.0-alpha.69" +"@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" dependencies: bytebuffer: "npm:^5.0.1" long: "npm:^4.0.0" protobufjs: "npm:^6.11.2" - checksum: 10/426bee4d667536a3aa607dc54960e35c2425835ab9f937cba7b0dc1dc6d634d1a2f0e4380b4f23af9c595bb30595fcbf257ce138712330cb636bddfacc146f0e + checksum: 10/4cd9c6b2e3a99a9917bba2ee5fa7895041a356833923b7d0edfdcfc0749246031c4401184d72726545b8e65584b07cdac390c4b2fa5bbdcdadebe858004fe552 languageName: node linkType: hard -"@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hd-web-sdk@npm:1.2.0-alpha.69" +"@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" dependencies: "@onekeyfe/cross-inpage-provider-core": "npm:2.2.67" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-http": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-web-device": "npm:1.2.0-alpha.69" - checksum: 10/5f39b8e8c8d69caec544ffd4905d86cf48b80c027a81fdc9aad94c7cc015915773e368f49ed02ec7327af00f845b3e25d257a6e728b9e37d4aae9bdd7169249e + "@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 languageName: node linkType: hard -"@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-adapter-core@npm:1.2.0-alpha.69" +"@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" dependencies: "@noble/hashes": "npm:^1.7.1" - checksum: 10/7577d2d511fa2b5ea402471af5e44aa426757af078928b9cccdf88de585741032e704a24027e119778910f87c282ec4b8b352bb137153fbde60af738a829463e + checksum: 10/cd05170199a8406a5fb55c6828ee190a6301de160237eda63a0aa81ccde0939a6f1bfe1a7939b8cb21d2e5897c0902aebdd0db6c98f82c7087406c06d33545f7 languageName: node linkType: hard -"@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-ledger-adapter@npm:1.2.0-alpha.69" +"@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" 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.69" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" bitcoinjs-lib: "npm:@onekeyfe/bitcoinjs-lib@7.0.1" - checksum: 10/ffaa73e8529d15ddd29e9dd7215f765905b6f1c55d48ed61849f09d80225b87b2941abce3755bd0112630c09f244063df0a0e9c92ed85aef5d9eb04b79c2f909 + checksum: 10/ebcb653809447c33f536edd15e7251ed2cc329959361bacca20ce8de19686f9d7961ffefffd18c9415fde836a77a2a2249c928e66df4b9660721201f47b0ebe8 languageName: node linkType: hard -"@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-ledger-connector-ble@npm:1.2.0-alpha.69" +"@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" 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.69" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.69" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.77" peerDependencies: react-native: "*" - checksum: 10/65712d30297da847dc09661a73dfcc08b2c78a3ef6d611e0565d34cdf46edddddd78835d23f7ec68ade8426fa58ff41d52a08e699b1127a0f1ea105c74f5607e + checksum: 10/10ab209887dd42eef9727f7a46aba04c04ec4180ed2bb28101df44257f7b1001cee8482aa28598cd1ff1baf584b85321bd74d93ffc6702e1ba86ec684344ee77 languageName: node linkType: hard -"@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-ledger-connector-webhid@npm:1.2.0-alpha.69" +"@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" 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.69" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.69" - checksum: 10/12c343d44dc825556b6179c905cc33d1c447c8f72a43522c01c515f946d894b6e487af6f5928a4964837b716e63352f054e562600d9f993b7aa8e67c5e511683 + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.77" + checksum: 10/744ff2856a57a6d183806f1ad3c879645b5eac6bcfa1883f3b1c1a1647918f538a77bc94a6f95d583e341a375d3518ccdd8294e347cb561877313a2dc51f35f9 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-adapter@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.69" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.77" tslib: "npm:^2.6.2" - checksum: 10/7c3e97441a3856d41f3e604b4f4bc1d2a997bd1d5a5cd9fc87345a329bd3e884a5fc440c11db3e5502e168712a8444412f56535e59b970a895f6f60452936968 + checksum: 10/6c60741c95db3b06160632f2d76f127083bd96cd1f98e00c936f20bc3df24fb1e372c8f823aad55d8c30a4544036175471affd0d7e2844783a715f9e587e41dd languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-connector-electron-ble@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.69" + "@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" buffer: "npm:^6.0.3" peerDependencies: "@stoprocent/noble": "*" @@ -9053,48 +9053,48 @@ __metadata: optional: true electron: optional: true - checksum: 10/975496df4ab31b9b469151de6324a99e33ae6696cf87466c1ea2a1a256454a1e4f0921eb10016670d439069569756bb6dc687688e19644539c46a7e5821752d8 + checksum: 10/45a449563da974ac26dca70776bc88dabf2655b176ac9bb05d15fb6eef8e9cebcc656f6e565a37104557fa8e745befd86c99238040a735b55accefa1a1fa70fb languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-connector-rn-ble@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.69" + "@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" buffer: "npm:^6.0.3" react-native-ble-plx: "npm:^3.5.1" - checksum: 10/11b7ddd6e83117e0ccb17be529e7e3563b2a6ae6bc77268287755679da2b173fce3803f1cb03824424d266a29dd0bef6f6d2d48f606efec2fc0e9a15319ae3eb + checksum: 10/c2b82b7dc30659621cd736731d18a112aa177a5e5d47f29e84ac3f0a0d239c7abfd9f840401589539f7452d4258cd5cfc85cdee89a1a1a63c101d5224dc734b6 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-connector-webusb@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.69" + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-trezor-connector": "npm:1.2.0-alpha.77" buffer: "npm:^6.0.3" - checksum: 10/2ed969df4b644109911ba7e1e4363308a849b7b028bef1b01e3b648504e8f11add0a40a464cc6a94953ec8838e299caaef705f80daf4343e5873d5cf1ae76bf5 + checksum: 10/2b12d613135bb3ce9c4a37467c3f43dd512817125cb07d7d4016f4589779bdc127d8edbcb606a5d7333ab1d2e47313d97c3abf00fd4029bbfad245aadbeeff22 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-connector@npm:1.2.0-alpha.69" +"@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" dependencies: - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.69" - checksum: 10/210790eb4fe4b314c60fc3aa00125e76da5ef31838879a6064d0ed0cb9048404827a6034909b0ae1f67a6c4c20b9163b87d6a0a2f73a07fc51ed0a6ef7eea4dd + "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.77" + "@onekeyfe/hwk-trezor-core": "npm:1.2.0-alpha.77" + checksum: 10/848b83393e84c32e31652cd7e15d63de0aad9758a108e4e08790b13d6b6186b1585a0d678d3c848d02f045d69b55b12e93b50b8dde4d9d1d3cbcf9944501f3c0 languageName: node linkType: hard -"@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.69": - version: 1.2.0-alpha.69 - resolution: "@onekeyfe/hwk-trezor-core@npm:1.2.0-alpha.69" - checksum: 10/c2c9f23212f9cf64e41f0811a52e7b94ca3303cc3150d0c0917f47f90a80bf384f19c6cc79aaf932563d37af8dbb959c7b9a2e3ad8a3e2e8fa29f666915fa788 +"@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 languageName: node linkType: hard @@ -9658,14 +9658,14 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-range-downloader@npm:3.0.78": - version: 3.0.78 - resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.78" +"@onekeyfe/react-native-range-downloader@npm:3.0.81-alpha.11": + version: 3.0.81-alpha.11 + resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.81-alpha.11" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.33.2 - checksum: 10/7019df4b24e2783a054dc2b6cafa429f07a308b098603e6ef4a38fc0882541508438177be072f67879dfe960d2802d78b3c8d84607dc5e749655e66509d6433e + checksum: 10/df59b89e9bfbc4e4e37090504e7ba5a1328e19abce780b7345de3bdc19248fafceeee18b8784e8ee5f383df43d797750dc20131191d5e14c0ec26fc314de6adb languageName: node linkType: hard @@ -9702,13 +9702,13 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-sni-connect@npm:3.0.78": - version: 3.0.78 - resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.78" +"@onekeyfe/react-native-sni-connect@npm:3.0.81-alpha.9": + version: 3.0.81-alpha.9 + resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.81-alpha.9" peerDependencies: react: "*" react-native: "*" - checksum: 10/d528581aa38c25876bc30eba869174b0f8b1e570088bb00146cf97b4c1ca6f94f471447774067f2677ec6554f57fb3c5ed0a7ae8077ed0eaa4ce01263d416afe + checksum: 10/6e2a148c86a2d3f09d3d3db90645cd8aeb53d4adf202d2ec01b429e8eddf141f25fec92362c56fc1b5e7764bc7331489a29395d9ce9db22b6a85880b993c1001 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.69" - "@onekeyfe/hd-common-connect-sdk": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-shared": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-transport-electron": "npm:1.2.0-alpha.69" - "@onekeyfe/hd-web-sdk": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-adapter-core": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-ledger-adapter": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-ledger-connector-ble": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-ledger-connector-webhid": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-adapter": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector-electron-ble": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector-rn-ble": "npm:1.2.0-alpha.69" - "@onekeyfe/hwk-trezor-connector-webusb": "npm:1.2.0-alpha.69" + "@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/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" @@ -10153,12 +10153,14 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/kit-bg@workspace:packages/kit-bg" dependencies: + "@types/yauzl": "npm:2.10.3" eth-url-parser: "npm:^1.0.4" fflate: "npm:0.8.2" idb: "npm:^7.1.1" ipaddr.js: "npm:^2.3.0" jpeg-js: "npm:^0.4.4" miscreant: "npm:^0.3.2" + yauzl: "npm:2.10.0" languageName: unknown linkType: soft @@ -10216,11 +10218,11 @@ __metadata: "@onekeyfe/react-native-perf-memory": "npm:3.0.78" "@onekeyfe/react-native-perf-stats": "npm:3.0.78" "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.78" - "@onekeyfe/react-native-range-downloader": "npm:3.0.78" + "@onekeyfe/react-native-range-downloader": "npm:3.0.81-alpha.11" "@onekeyfe/react-native-scroll-guard": "npm:3.0.78" "@onekeyfe/react-native-segment-slider": "npm:3.0.78" "@onekeyfe/react-native-skeleton": "npm:3.0.78" - "@onekeyfe/react-native-sni-connect": "npm:3.0.78" + "@onekeyfe/react-native-sni-connect": "npm:3.0.81-alpha.9" "@onekeyfe/react-native-splash-screen": "npm:3.0.78" "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.78" "@onekeyfe/react-native-tab-view": "npm:3.0.78" @@ -19920,7 +19922,7 @@ __metadata: languageName: node linkType: hard -"@types/yauzl@npm:^2.9.1": +"@types/yauzl@npm:2.10.3, @types/yauzl@npm:^2.9.1": version: 2.10.3 resolution: "@types/yauzl@npm:2.10.3" dependencies: @@ -50268,7 +50270,7 @@ __metadata: languageName: node linkType: hard -"yauzl@npm:^2.10.0": +"yauzl@npm:2.10.0, yauzl@npm:^2.10.0": version: 2.10.0 resolution: "yauzl@npm:2.10.0" dependencies: