@onekeyfe/hd-core 1.2.0-alpha.53 → 1.2.0-alpha.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +0 -78
  2. package/__tests__/DeviceCommands.test.ts +0 -45
  3. package/__tests__/DeviceEventRegistration.test.ts +0 -6
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +3 -198
  5. package/__tests__/connectSettings.test.ts +47 -5
  6. package/__tests__/device-initialize-timeout.test.ts +56 -0
  7. package/__tests__/device-lifecycle-events.test.ts +3 -105
  8. package/__tests__/firmware-update/check-all-firmware-release.test.ts +175 -0
  9. package/__tests__/firmware-update/firmware-artifact-source.test.ts +119 -0
  10. package/__tests__/firmware-update/firmware-host-binding.test.ts +70 -0
  11. package/__tests__/firmware-update/firmware-preparation-error.test.ts +27 -0
  12. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +200 -0
  13. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +13 -0
  14. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +294 -0
  15. package/__tests__/firmware-update/firmware-update-plan.test.ts +593 -0
  16. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +213 -0
  17. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +59 -31
  18. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +58 -0
  19. package/__tests__/firmware-update/firmware-upload-source.test.ts +70 -0
  20. package/__tests__/get-device-state.test.ts +12 -132
  21. package/__tests__/homescreen.test.ts +1 -41
  22. package/__tests__/logBlockEvent.test.ts +1 -1
  23. package/__tests__/method-protocol-support.test.ts +0 -19
  24. package/__tests__/open-wallet-session.test.ts +33 -446
  25. package/__tests__/protocol-v2-unlock-policy.test.ts +35 -201
  26. package/__tests__/protocol-v2.test.ts +1034 -1378
  27. package/__tests__/protocolV2UiInteraction.test.ts +0 -63
  28. package/__tests__/public-pro2-api-boundary.test.ts +0 -1
  29. package/__tests__/search-devices.test.ts +3 -12
  30. package/dist/api/BaseMethod.d.ts +1 -3
  31. package/dist/api/BaseMethod.d.ts.map +1 -1
  32. package/dist/api/CheckAllFirmwareRelease.d.ts +1 -2
  33. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  34. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  35. package/dist/api/FirmwareUpdateV2.d.ts +11 -0
  36. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  37. package/dist/api/FirmwareUpdateV3.d.ts +7 -3
  38. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  39. package/dist/api/FirmwareUpdateV4.d.ts +25 -11
  40. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  41. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  42. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  43. package/dist/api/SearchDevices.d.ts.map +1 -1
  44. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  45. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  46. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  47. package/dist/api/device/DeviceUpdateBootloader.d.ts +5 -1
  48. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  49. package/dist/api/firmware/FirmwareArtifactSource.d.ts +25 -0
  50. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -0
  51. package/dist/api/firmware/FirmwareHostBinding.d.ts +6 -0
  52. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -0
  53. package/dist/api/firmware/FirmwarePreparationError.d.ts +3 -0
  54. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +1 -0
  55. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +9 -0
  56. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  57. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +3 -0
  58. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +1 -0
  59. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +25 -0
  60. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -0
  61. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +32 -0
  62. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -0
  63. package/dist/api/firmware/getBinary.d.ts +7 -0
  64. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  65. package/dist/api/firmware/progressThrottle.d.ts +3 -0
  66. package/dist/api/firmware/progressThrottle.d.ts.map +1 -0
  67. package/dist/api/firmware/updateBootloader.d.ts +2 -0
  68. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  69. package/dist/api/firmware/uploadFirmware.d.ts +9 -1
  70. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  71. package/dist/api/helpers/protocolV2FileWrite.d.ts +0 -2
  72. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  73. package/dist/api/index.d.ts +0 -2
  74. package/dist/api/index.d.ts.map +1 -1
  75. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  76. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  77. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  78. package/dist/core/deviceEventRegistration.d.ts +0 -2
  79. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  80. package/dist/core/index.d.ts.map +1 -1
  81. package/dist/data-manager/DataManager.d.ts +2 -4
  82. package/dist/data-manager/DataManager.d.ts.map +1 -1
  83. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  84. package/dist/device/Device.d.ts +6 -29
  85. package/dist/device/Device.d.ts.map +1 -1
  86. package/dist/device/DeviceCommands.d.ts +4 -4
  87. package/dist/device/DeviceCommands.d.ts.map +1 -1
  88. package/dist/device/DevicePool.d.ts.map +1 -1
  89. package/dist/events/device.d.ts +0 -4
  90. package/dist/events/device.d.ts.map +1 -1
  91. package/dist/events/logBlockEvent.d.ts.map +1 -1
  92. package/dist/events/ui-promise.d.ts +2 -4
  93. package/dist/events/ui-promise.d.ts.map +1 -1
  94. package/dist/events/ui-request.d.ts +2 -31
  95. package/dist/events/ui-request.d.ts.map +1 -1
  96. package/dist/events/ui-response.d.ts +2 -10
  97. package/dist/events/ui-response.d.ts.map +1 -1
  98. package/dist/index.d.ts +231 -214
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +3497 -2559
  101. package/dist/inject.d.ts +1 -6
  102. package/dist/inject.d.ts.map +1 -1
  103. package/dist/lowLevelInject.d.ts.map +1 -1
  104. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  105. package/dist/protocols/protocol-v2/lockedError.d.ts +2 -0
  106. package/dist/protocols/protocol-v2/lockedError.d.ts.map +1 -0
  107. package/dist/protocols/protocol-v2/uiInteraction.d.ts +4 -7
  108. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  109. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +3 -1
  110. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  111. package/dist/protocols/protocol-v2/unlockRetry.d.ts +10 -0
  112. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +1 -0
  113. package/dist/protocols/protocol-v2/walletSession.d.ts +1 -5
  114. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  115. package/dist/topLevelInject.d.ts.map +1 -1
  116. package/dist/types/api/checkAllFirmwareRelease.d.ts +4 -2
  117. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  118. package/dist/types/api/deviceUnlock.d.ts +1 -5
  119. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  120. package/dist/types/api/deviceUpdateBootloader.d.ts +6 -0
  121. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  122. package/dist/types/api/export.d.ts +4 -2
  123. package/dist/types/api/export.d.ts.map +1 -1
  124. package/dist/types/api/firmwareUpdate.d.ts +70 -2
  125. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  126. package/dist/types/api/firmwareUpdateCapabilities.d.ts +9 -0
  127. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +1 -0
  128. package/dist/types/api/firmwareUpdatePlan.d.ts +28 -0
  129. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -0
  130. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +42 -0
  131. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +1 -0
  132. package/dist/types/api/index.d.ts +10 -7
  133. package/dist/types/api/index.d.ts.map +1 -1
  134. package/dist/types/api/protocolV2.d.ts +0 -3
  135. package/dist/types/api/protocolV2.d.ts.map +1 -1
  136. package/dist/types/device.d.ts.map +1 -1
  137. package/dist/types/params.d.ts +0 -1
  138. package/dist/types/params.d.ts.map +1 -1
  139. package/dist/types/settings.d.ts +13 -22
  140. package/dist/types/settings.d.ts.map +1 -1
  141. package/dist/utils/deviceFeaturesCompat.d.ts +1 -0
  142. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  143. package/dist/utils/deviceFeaturesUtils.d.ts +0 -4
  144. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  145. package/dist/utils/homescreen.d.ts +0 -4
  146. package/dist/utils/homescreen.d.ts.map +1 -1
  147. package/dist/utils/index.d.ts +1 -1
  148. package/dist/utils/index.d.ts.map +1 -1
  149. package/dist/utils/patch.d.ts +1 -1
  150. package/dist/utils/patch.d.ts.map +1 -1
  151. package/dist/utils/pro2Wallpaper.d.ts +0 -10
  152. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  153. package/package.json +4 -4
  154. package/src/api/BaseMethod.ts +10 -8
  155. package/src/api/CheckAllFirmwareRelease.ts +57 -54
  156. package/src/api/FirmwareUpdate.ts +15 -7
  157. package/src/api/FirmwareUpdateV2.ts +316 -126
  158. package/src/api/FirmwareUpdateV3.ts +265 -63
  159. package/src/api/FirmwareUpdateV4.ts +965 -566
  160. package/src/api/GetPassphraseState.ts +1 -7
  161. package/src/api/OpenWalletSession.ts +17 -54
  162. package/src/api/SearchDevices.ts +1 -3
  163. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +11 -32
  164. package/src/api/device/DeviceUnlock.ts +3 -8
  165. package/src/api/device/DeviceUpdateBootloader.ts +122 -6
  166. package/src/api/firmware/FirmwareArtifactSource.ts +278 -0
  167. package/src/api/firmware/FirmwareHostBinding.ts +100 -0
  168. package/src/api/firmware/FirmwarePreparationError.ts +12 -0
  169. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +83 -16
  170. package/src/api/firmware/FirmwareUpdateCapabilities.ts +9 -0
  171. package/src/api/firmware/FirmwareUpdatePlan.ts +772 -0
  172. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +415 -0
  173. package/src/api/firmware/progressThrottle.ts +44 -0
  174. package/src/api/firmware/updateBootloader.ts +19 -4
  175. package/src/api/firmware/uploadFirmware.ts +144 -39
  176. package/src/api/helpers/protocolV2FileWrite.ts +4 -11
  177. package/src/api/index.ts +0 -2
  178. package/src/api/protocol-v2/ProtocolInfoRequest.ts +1 -3
  179. package/src/api/protocol-v2/helpers.ts +0 -1
  180. package/src/core/deviceEventRegistration.ts +0 -4
  181. package/src/core/index.ts +83 -167
  182. package/src/data/messages/messages-protocol-v2.json +0 -25
  183. package/src/data-manager/DataManager.ts +38 -67
  184. package/src/data-manager/connectSettings.ts +11 -0
  185. package/src/device/Device.ts +42 -258
  186. package/src/device/DeviceCommands.ts +1 -7
  187. package/src/device/DevicePool.ts +2 -7
  188. package/src/events/device.ts +0 -4
  189. package/src/events/logBlockEvent.ts +0 -1
  190. package/src/events/ui-promise.ts +2 -4
  191. package/src/events/ui-request.ts +2 -36
  192. package/src/events/ui-response.ts +2 -12
  193. package/src/index.ts +5 -0
  194. package/src/inject.ts +24 -60
  195. package/src/lowLevelInject.ts +8 -7
  196. package/src/protocols/protocol-v2/features.ts +3 -10
  197. package/src/protocols/protocol-v2/lockedError.ts +10 -0
  198. package/src/protocols/protocol-v2/uiInteraction.ts +6 -46
  199. package/src/protocols/protocol-v2/unlockPolicy.ts +105 -1
  200. package/src/protocols/protocol-v2/unlockRetry.ts +109 -0
  201. package/src/protocols/protocol-v2/walletSession.ts +30 -180
  202. package/src/topLevelInject.ts +8 -7
  203. package/src/types/api/checkAllFirmwareRelease.ts +4 -2
  204. package/src/types/api/deviceUnlock.ts +1 -12
  205. package/src/types/api/deviceUpdateBootloader.ts +6 -0
  206. package/src/types/api/export.ts +18 -1
  207. package/src/types/api/firmwareUpdate.ts +76 -3
  208. package/src/types/api/firmwareUpdateCapabilities.ts +9 -0
  209. package/src/types/api/firmwareUpdatePlan.ts +38 -0
  210. package/src/types/api/firmwareUpdatePreparedPlan.ts +53 -0
  211. package/src/types/api/index.ts +14 -13
  212. package/src/types/api/protocolV2.ts +0 -13
  213. package/src/types/device.ts +0 -3
  214. package/src/types/params.ts +1 -7
  215. package/src/types/settings.ts +13 -42
  216. package/src/utils/deviceFeaturesCompat.ts +6 -0
  217. package/src/utils/deviceFeaturesUtils.ts +0 -8
  218. package/src/utils/homescreen.ts +6 -32
  219. package/src/utils/index.ts +1 -6
  220. package/src/utils/pro2Wallpaper.ts +8 -35
  221. package/__tests__/deviceUploadNft.test.ts +0 -293
  222. package/__tests__/pro2Nft.test.ts +0 -108
  223. package/__tests__/protocol-binding.test.ts +0 -89
  224. package/__tests__/protocol-v2-resources.test.ts +0 -284
  225. package/__tests__/protocol-v2-ui-lifecycle.test.ts +0 -91
  226. package/__tests__/uiProgressThrottle.test.ts +0 -74
  227. package/__tests__/uiPromiseRegistry.test.ts +0 -86
  228. package/dist/api/DetectDeviceConnectProtocol.d.ts +0 -7
  229. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +0 -1
  230. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +0 -31
  231. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +0 -1
  232. package/dist/core/uiPromiseRegistry.d.ts +0 -4
  233. package/dist/core/uiPromiseRegistry.d.ts.map +0 -1
  234. package/dist/protocols/protocol-v2/resources.d.ts +0 -30
  235. package/dist/protocols/protocol-v2/resources.d.ts.map +0 -1
  236. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +0 -22
  237. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +0 -1
  238. package/dist/types/api/detectDeviceConnectProtocol.d.ts +0 -4
  239. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +0 -1
  240. package/dist/utils/pro2Nft.d.ts +0 -31
  241. package/dist/utils/pro2Nft.d.ts.map +0 -1
  242. package/dist/utils/uiProgressThrottle.d.ts +0 -3
  243. package/dist/utils/uiProgressThrottle.d.ts.map +0 -1
  244. package/src/api/DetectDeviceConnectProtocol.ts +0 -18
  245. package/src/api/protocol-v2/DeviceUploadNft.ts +0 -189
  246. package/src/core/uiPromiseRegistry.ts +0 -41
  247. package/src/protocols/protocol-v2/resources.ts +0 -359
  248. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +0 -118
  249. package/src/types/api/detectDeviceConnectProtocol.ts +0 -7
  250. package/src/utils/pro2Nft.ts +0 -142
  251. package/src/utils/uiProgressThrottle.ts +0 -63
@@ -5,7 +5,6 @@ import {
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
- import { sha256 } from '@noble/hashes/sha256';
9
8
 
10
9
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
11
10
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -62,7 +61,6 @@ import { createCoreApi } from '../src/inject';
62
61
  import { Device, preloadSessionCache } from '../src/device/Device';
63
62
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
64
63
  import { DevicePool } from '../src/device/DevicePool';
65
- import { DEVICE } from '../src/events';
66
64
  import { UI_REQUEST } from '../src/events/ui-request';
67
65
  import {
68
66
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -76,15 +74,11 @@ import {
76
74
  requestProtocolV2ProtocolInfo,
77
75
  supportsProtocolV2Message,
78
76
  } from '../src/protocols/protocol-v2/features';
79
- import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
80
77
  import {
81
78
  getProtocolV2WalletSession,
82
79
  refreshProtocolV2DeviceStatus,
83
80
  } from '../src/protocols/protocol-v2/walletSession';
84
- import {
85
- createProtocolV2UnlockContext,
86
- runMethodWithUnlockPolicy,
87
- } from '../src/protocols/protocol-v2/unlockPolicyRunner';
81
+ import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
88
82
  import { BaseMethod } from '../src/api/BaseMethod';
89
83
  import {
90
84
  buildProtocolV1FeaturesPayload,
@@ -98,6 +92,7 @@ import {
98
92
  getMethodVersionRange,
99
93
  } from '../src/utils';
100
94
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
+ import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
101
96
  import {
102
97
  getPassphraseState,
103
98
  getPassphraseStateWithRefreshDeviceInfo,
@@ -246,7 +241,7 @@ describe('UploadPortfolio', () => {
246
241
  (method as any).device = device;
247
242
 
248
243
  method.init();
249
- await runMethodWithUnlockPolicy(method, device as any);
244
+ await runMethodWithUnlockRetry(method, device as any);
250
245
 
251
246
  expect(unlockDevice).not.toHaveBeenCalled();
252
247
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -422,16 +417,14 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
422
417
  }
423
418
  return d.features;
424
419
  };
425
- d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
426
- requestProtocolV2ProtocolInfo({
427
- commands: d.getCommands?.() ?? d.commands,
428
- timeoutMs,
429
- });
430
420
  d.probeProtocolV2RuntimeState ??= async (
431
421
  deviceInfo: ProtocolV2DeviceInfo,
432
422
  timeoutMs?: number
433
423
  ) => {
434
- const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
424
+ const protocolInfo = await requestProtocolV2ProtocolInfo({
425
+ commands: d.getCommands(),
426
+ timeoutMs,
427
+ });
435
428
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
436
429
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
437
430
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -788,9 +781,6 @@ describe('Protocol V2 feature adapter', () => {
788
781
  if (request === 'DeviceStatusGet') {
789
782
  return { type: 'DeviceStatus', message: { unlocked: true } };
790
783
  }
791
- if (request === 'DeviceSessionAskPassphrase') {
792
- return { type: 'Success', message: {} };
793
- }
794
784
  if (request === 'DeviceSessionGet') {
795
785
  return {
796
786
  type: 'DeviceSession',
@@ -815,7 +805,7 @@ describe('Protocol V2 feature adapter', () => {
815
805
  ).resolves.toEqual({
816
806
  passphraseState: 'state-1',
817
807
  newSession: 'session-1',
818
- unlockedAttachPin: false,
808
+ unlockedAttachPin: undefined,
819
809
  resumed: false,
820
810
  });
821
811
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -824,13 +814,7 @@ describe('Protocol V2 feature adapter', () => {
824
814
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
825
815
  type: DeviceSessionPinType.Main,
826
816
  });
827
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
828
- passphrase: '',
829
- on_device: false,
830
- });
831
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
832
- seed_domains: [],
833
- });
817
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
834
818
  device.passphraseState = 'state-1';
835
819
  expect(device.getInternalState()).toBe('session-1');
836
820
  });
@@ -863,7 +847,6 @@ describe('Protocol V2 feature adapter', () => {
863
847
  },
864
848
  };
865
849
  }
866
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
867
850
  if (request === 'DeviceSessionGet') {
868
851
  return {
869
852
  message: {
@@ -880,16 +863,9 @@ describe('Protocol V2 feature adapter', () => {
880
863
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
881
864
 
882
865
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
883
- expect(
884
- typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
885
- ).toHaveLength(1);
886
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
887
- seed_domains: [],
888
- });
866
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
889
867
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
890
868
  session_id: 'standard-session',
891
- btc_test_address: 'standard-state',
892
- seed_domains: [],
893
869
  });
894
870
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
895
871
  passphraseState: 'standard-state',
@@ -931,7 +907,6 @@ describe('Protocol V2 feature adapter', () => {
931
907
  },
932
908
  };
933
909
  }
934
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
935
910
  if (request === 'DeviceSessionAskPin') {
936
911
  return { type: 'Success', message: { message: 'PIN verified' } };
937
912
  }
@@ -1026,7 +1001,6 @@ describe('Protocol V2 feature adapter', () => {
1026
1001
  if (request === 'DeviceStatusGet') {
1027
1002
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1028
1003
  }
1029
- if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1030
1004
  if (request === 'DeviceSessionGet') {
1031
1005
  sessionGetCalls += 1;
1032
1006
  return {
@@ -1253,9 +1227,7 @@ describe('Protocol V2 feature adapter', () => {
1253
1227
  passphrase: 'host hidden wallet',
1254
1228
  on_device: false,
1255
1229
  });
1256
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1257
- seed_domains: [],
1258
- });
1230
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1259
1231
  });
1260
1232
 
1261
1233
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1363,8 +1335,6 @@ describe('Protocol V2 feature adapter', () => {
1363
1335
 
1364
1336
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1365
1337
  session_id: 'session-a',
1366
- btc_test_address: 'state-a',
1367
- seed_domains: [],
1368
1338
  });
1369
1339
  expect(device.getInternalState()).toBe('session-b');
1370
1340
  });
@@ -1432,15 +1402,7 @@ describe('Protocol V2 feature adapter', () => {
1432
1402
  });
1433
1403
  expect(typedCall.mock.calls).toEqual([
1434
1404
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1435
- [
1436
- 'DeviceSessionGet',
1437
- 'DeviceSession',
1438
- {
1439
- session_id: 'session-a',
1440
- btc_test_address: 'state-a',
1441
- seed_domains: [],
1442
- },
1443
- ],
1405
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1444
1406
  ]);
1445
1407
  expect(promptPassphrase).not.toHaveBeenCalled();
1446
1408
  expect(device.getInternalState()).toBeUndefined();
@@ -1488,7 +1450,7 @@ describe('Protocol V2 feature adapter', () => {
1488
1450
  { passphrase: 'host hidden wallet', on_device: false },
1489
1451
  ],
1490
1452
  ['DeviceStatusGet', 'DeviceStatus', {}],
1491
- ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1453
+ ['DeviceSessionGet', 'DeviceSession', {}],
1492
1454
  ]);
1493
1455
  });
1494
1456
 
@@ -1591,73 +1553,6 @@ describe('Protocol V2 feature adapter', () => {
1591
1553
  expect(device.getInternalState()).toBeUndefined();
1592
1554
  });
1593
1555
 
1594
- test.each([false, true])(
1595
- 'rejects a different Attach PIN wallet when Pro2 lock failure is %s',
1596
- async lockFails => {
1597
- const deviceId = 'attach-mismatch-device';
1598
- const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1599
- (device as any).features = normalizeProtocolV2Features(
1600
- { ...descriptor, protocolType: 'V2' } as any,
1601
- {
1602
- status: {
1603
- device_id: deviceId,
1604
- unlocked: true,
1605
- passphrase_enabled: true,
1606
- attach_to_pin_enabled: true,
1607
- unlocked_by_attach_to_pin: false,
1608
- },
1609
- }
1610
- );
1611
- device.passphraseState = 'expected-state';
1612
-
1613
- const typedCall = jest.fn((request: string) => {
1614
- if (request === 'ProtocolInfoRequest') return { message: { version: 2 } };
1615
- if (request === 'DeviceSessionAskPin') return { message: { message: 'PIN verified' } };
1616
- if (request === 'DeviceStatusGet') {
1617
- return {
1618
- message: {
1619
- device_id: deviceId,
1620
- unlocked: true,
1621
- passphrase_enabled: true,
1622
- attach_to_pin_enabled: true,
1623
- unlocked_by_attach_to_pin: true,
1624
- },
1625
- };
1626
- }
1627
- if (request === 'DeviceSessionGet') {
1628
- return {
1629
- message: {
1630
- btc_test_address: 'wrong-attach-state',
1631
- session_id: 'wrong-attach-session',
1632
- },
1633
- };
1634
- }
1635
- if (request === 'LockDevice') {
1636
- if (lockFails) throw new Error('Lock unsupported');
1637
- return { message: { message: 'Device locked' } };
1638
- }
1639
- throw new Error(`Unexpected request: ${request}`);
1640
- });
1641
- const promptPassphrase = jest.fn().mockResolvedValue({ attachPinOnDevice: true });
1642
- (device as any).commands = { typedCall, promptPassphrase };
1643
-
1644
- await expect(
1645
- getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1646
- ).rejects.toMatchObject({
1647
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
1648
- });
1649
-
1650
- expect(promptPassphrase).not.toHaveBeenCalled();
1651
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1652
- btc_test_address: 'expected-state',
1653
- seed_domains: [],
1654
- });
1655
- expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
1656
- expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionGet')).toHaveLength(1);
1657
- expect(device.getInternalState()).toBeUndefined();
1658
- }
1659
- );
1660
-
1661
1556
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1662
1557
  const device = Device.fromDescriptor({
1663
1558
  id: 'cache-device-attach-recovery',
@@ -1714,17 +1609,7 @@ describe('Protocol V2 feature adapter', () => {
1714
1609
  }),
1715
1610
  { cancelDeviceOnReject: false }
1716
1611
  );
1717
- expect(unlockDevice).toHaveBeenCalledWith(
1718
- DeviceSessionPinType.AttachToPin,
1719
- expect.objectContaining({
1720
- emitUiEvent: false,
1721
- interaction: expect.objectContaining({
1722
- phase: 'pin',
1723
- transition: 'start',
1724
- protocol: 'V2',
1725
- }),
1726
- })
1727
- );
1612
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
1728
1613
  expect(device.getInternalState()).toBe('new-attach-session');
1729
1614
  });
1730
1615
 
@@ -2008,10 +1893,12 @@ describe('Protocol V2 feature adapter', () => {
2008
1893
  expect(getFeatures).not.toHaveBeenCalled();
2009
1894
  });
2010
1895
 
2011
- test('uses the cached wallet address when the local session is missing', async () => {
1896
+ test('marks an interactive cache miss as a wallet-session recovery', async () => {
2012
1897
  const typedCall = jest
2013
1898
  .fn()
2014
1899
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
+ .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
+ .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
2015
1902
  .mockResolvedValueOnce({
2016
1903
  message: {
2017
1904
  btc_test_address: 'expected-state',
@@ -2039,11 +1926,16 @@ describe('Protocol V2 feature adapter', () => {
2039
1926
  await expect(
2040
1927
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
2041
1928
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
2042
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2043
- btc_test_address: 'expected-state',
2044
- seed_domains: [],
2045
- });
2046
- expect(promptPassphrase).not.toHaveBeenCalled();
1929
+ expect(promptPassphrase).toHaveBeenCalledWith(
1930
+ {
1931
+ existsAttachPinUser: false,
1932
+ deviceOnly: false,
1933
+ source: 'wallet-session-coordinator',
1934
+ reason: 'session-recovery',
1935
+ expectedPassphraseState: 'expected-state',
1936
+ },
1937
+ { cancelDeviceOnReject: false }
1938
+ );
2047
1939
  });
2048
1940
 
2049
1941
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2109,15 +2001,8 @@ describe('Protocol V2 feature adapter', () => {
2109
2001
  true
2110
2002
  );
2111
2003
  expect(typedCall.mock.calls).toEqual([
2112
- [
2113
- 'DeviceSessionGet',
2114
- 'DeviceSession',
2115
- {
2116
- session_id: 'session-pro2-app',
2117
- btc_test_address: 'state-pro2-app',
2118
- seed_domains: [],
2119
- },
2120
- ],
2004
+ ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2121
2006
  ]);
2122
2007
  });
2123
2008
 
@@ -2306,9 +2191,7 @@ describe('Protocol V2 feature adapter', () => {
2306
2191
  passphrase: 'host hidden wallet',
2307
2192
  on_device: false,
2308
2193
  });
2309
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2310
- seed_domains: [],
2311
- });
2194
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2312
2195
  });
2313
2196
 
2314
2197
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2386,83 +2269,12 @@ describe('Protocol V2 feature adapter', () => {
2386
2269
  );
2387
2270
 
2388
2271
  expect(device.getInternalState()).toBeUndefined();
2389
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2390
- btc_test_address: 'expected-state',
2391
- seed_domains: [],
2392
- });
2393
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2272
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2394
2273
  passphrase: 'host hidden wallet',
2395
2274
  on_device: false,
2396
2275
  });
2397
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2398
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2399
- seed_domains: [],
2400
- });
2401
- });
2402
-
2403
- test('fails closed instead of switching to Main PIN during a standard-wallet safety check', async () => {
2404
- const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2405
- const typedCall = jest.fn((request: string) => {
2406
- if (request === 'ProtocolInfoRequest') {
2407
- return { message: { version: 2 } };
2408
- }
2409
- if (request === 'DeviceStatusGet') {
2410
- return {
2411
- message: {
2412
- device_id: 'attach-wallet-device',
2413
- unlocked: true,
2414
- passphrase_enabled: true,
2415
- attach_to_pin_enabled: true,
2416
- unlocked_by_attach_to_pin: true,
2417
- },
2418
- };
2419
- }
2420
- if (request === 'DeviceSessionAskPin' || request === 'DeviceSessionAskPassphrase') {
2421
- return { message: { message: 'Wallet selected' } };
2422
- }
2423
- if (request === 'DeviceSessionGet') {
2424
- return {
2425
- message: {
2426
- session_id: 'main-wallet-session',
2427
- btc_test_address: 'main-wallet-state',
2428
- },
2429
- };
2430
- }
2431
- if (request === 'LockDevice') {
2432
- return { message: { message: 'Device locked' } };
2433
- }
2434
- throw new Error(`Unexpected request: ${request}`);
2435
- });
2436
-
2437
- (device as any).features = normalizeProtocolV2Features(
2438
- { ...descriptor, protocolType: 'V2' } as any,
2439
- {
2440
- status: {
2441
- device_id: 'attach-wallet-device',
2442
- unlocked: true,
2443
- passphrase_enabled: true,
2444
- attach_to_pin_enabled: true,
2445
- // 模拟缓存滞后:设备状态刷新后才确认当前由 Attach PIN 解锁。
2446
- unlocked_by_attach_to_pin: false,
2447
- },
2448
- }
2449
- );
2450
- (device as any).commands = { typedCall };
2451
- const clearInternalState = jest.spyOn(device, 'clearInternalState');
2452
-
2453
- await expect(device.checkPassphraseStateSafety(undefined, true, false)).rejects.toMatchObject({
2454
- errorCode: HardwareErrorCode.DeviceCheckUnlockTypeError,
2455
- });
2456
-
2457
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2458
- expect(typedCall).toHaveBeenCalledWith('LockDevice', 'Success', {});
2459
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2460
- expect(typedCall).not.toHaveBeenCalledWith(
2461
- 'DeviceSessionAskPassphrase',
2462
- 'Success',
2463
- expect.anything()
2464
- );
2465
- expect(clearInternalState).toHaveBeenCalledTimes(1);
2276
+ expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
+ expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
2466
2278
  });
2467
2279
 
2468
2280
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2495,22 +2307,10 @@ describe('Protocol V2 feature adapter', () => {
2495
2307
  await expect(
2496
2308
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2497
2309
  ).resolves.toBe(true);
2498
- expect(typedCall).toHaveBeenCalledTimes(5);
2310
+ expect(typedCall).toHaveBeenCalledTimes(4);
2499
2311
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2500
2312
  eventless_wallet_session: true,
2501
2313
  });
2502
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2503
- seed_domains: [],
2504
- });
2505
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2506
- passphrase: '',
2507
- on_device: false,
2508
- });
2509
- expect(typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2510
- expect(typedCall.mock.calls.filter(([request]) => request === 'DeviceStatusGet')).toHaveLength(
2511
- 2
2512
- );
2513
- expect(typedCall).not.toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', expect.anything());
2514
2314
  });
2515
2315
 
2516
2316
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2881,9 +2681,7 @@ describe('Protocol V2 feature adapter', () => {
2881
2681
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2882
2682
  }
2883
2683
  );
2884
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2885
- eventless_wallet_session: true,
2886
- });
2684
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2887
2685
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2888
2686
  });
2889
2687
 
@@ -2921,9 +2719,7 @@ describe('Protocol V2 feature adapter', () => {
2921
2719
  }),
2922
2720
  expect.anything()
2923
2721
  );
2924
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2925
- eventless_wallet_session: true,
2926
- });
2722
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2927
2723
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2928
2724
  expect(device.features).toMatchObject({
2929
2725
  deviceId: null,
@@ -3189,6 +2985,10 @@ describe('Protocol V2 feature adapter', () => {
3189
2985
  fw: { application: { version: '1.2.4' } },
3190
2986
  },
3191
2987
  })
2988
+ .mockResolvedValueOnce({
2989
+ type: 'ProtocolInfo',
2990
+ message: protocolV2ApplicationInfo,
2991
+ })
3192
2992
  .mockResolvedValueOnce({
3193
2993
  type: 'DeviceStatus',
3194
2994
  message: { init_states: true, unlocked: true },
@@ -3210,7 +3010,7 @@ describe('Protocol V2 feature adapter', () => {
3210
3010
  firmwareVersion: '1.2.4',
3211
3011
  passphraseProtection: null,
3212
3012
  });
3213
- expect(typedCall).toHaveBeenCalledTimes(5);
3013
+ expect(typedCall).toHaveBeenCalledTimes(6);
3214
3014
  expect(typedCall).toHaveBeenNthCalledWith(
3215
3015
  4,
3216
3016
  'DeviceInfoGet',
@@ -3324,23 +3124,9 @@ describe('Protocol V2 feature adapter', () => {
3324
3124
  status: { init_states: true },
3325
3125
  }
3326
3126
  );
3327
- const pinOnDevice = jest.fn();
3328
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3329
3127
 
3330
3128
  const features = await device.unlockDevice();
3331
3129
 
3332
- expect(pinOnDevice).toHaveBeenCalledWith(
3333
- device,
3334
- DeviceSessionPinType.Main,
3335
- expect.objectContaining({
3336
- source: 'unlock-coordinator',
3337
- reason: 'device-unlock',
3338
- deviceOnly: true,
3339
- })
3340
- );
3341
- expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3342
- typedCall.mock.invocationCallOrder[0]
3343
- );
3344
3130
  expect(typedCall.mock.calls).toEqual([
3345
3131
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3346
3132
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3386,12 +3172,9 @@ describe('Protocol V2 feature adapter', () => {
3386
3172
  throw new Error(`Unexpected request: ${requestType}`);
3387
3173
  });
3388
3174
  (device as any).commands = { typedCall };
3389
- const pinOnDevice = jest.fn();
3390
- device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3391
3175
 
3392
- await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3176
+ await device.unlockDevice();
3393
3177
 
3394
- expect(pinOnDevice).not.toHaveBeenCalled();
3395
3178
  expect(typedCall.mock.calls).toEqual([
3396
3179
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3397
3180
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3794,15 +3577,6 @@ describe('API compatibility handling', () => {
3794
3577
 
3795
3578
  describe('Protocol V2 firmware update targets', () => {
3796
3579
  const OKPP_HEADER_SIZE = 0x52a0;
3797
- let forceReloadDataSpy: jest.SpyInstance<Promise<void>, []>;
3798
-
3799
- beforeEach(() => {
3800
- forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3801
- });
3802
-
3803
- afterEach(() => {
3804
- jest.restoreAllMocks();
3805
- });
3806
3580
 
3807
3581
  const buildOkppHeader = ({
3808
3582
  type = 'RESC',
@@ -3927,7 +3701,7 @@ describe('Protocol V2 firmware update targets', () => {
3927
3701
  specific: true,
3928
3702
  },
3929
3703
  },
3930
- { timeoutMs: 30000 }
3704
+ { timeoutMs: 5000 }
3931
3705
  );
3932
3706
  expect(typedCall).toHaveBeenCalledTimes(3);
3933
3707
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3943,7 +3717,7 @@ describe('Protocol V2 firmware update targets', () => {
3943
3717
  });
3944
3718
  });
3945
3719
 
3946
- test('enters Protocol V2 bootloader before upload', async () => {
3720
+ test('delegates Protocol V2 mode transition to the phase executor', async () => {
3947
3721
  const method = new FirmwareUpdateV4({
3948
3722
  id: 1,
3949
3723
  payload: {
@@ -3978,7 +3752,7 @@ describe('Protocol V2 firmware update targets', () => {
3978
3752
 
3979
3753
  await method.run();
3980
3754
 
3981
- expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3755
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3982
3756
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3983
3757
  fwBinaryMap: [
3984
3758
  {
@@ -3989,69 +3763,6 @@ describe('Protocol V2 firmware update targets', () => {
3989
3763
  ],
3990
3764
  bootloaderBinary: null,
3991
3765
  });
3992
- expect(method.postTipMessage).toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3993
- });
3994
-
3995
- test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3996
- const method = new FirmwareUpdateV4({
3997
- id: 1,
3998
- payload: {
3999
- method: 'firmwareUpdateV4',
4000
- platform: 'web',
4001
- targetsToUpdate: ['resource'],
4002
- },
4003
- });
4004
- method.init();
4005
-
4006
- (method as any).device = stubDevice({
4007
- originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4008
- features: {
4009
- deviceType: 'pro2',
4010
- firmwareVersion: '1.0.0',
4011
- mode: 'normal',
4012
- bootloaderMode: false,
4013
- capabilities: [],
4014
- },
4015
- isBootloader: () => false,
4016
- isRomloader: () => false,
4017
- });
4018
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4019
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4020
- bootloaderBinary: null,
4021
- fwBinaryMap: [],
4022
- installItems: [],
4023
- });
4024
-
4025
- const order: string[] = [];
4026
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4027
- order.push('enter-bootloader');
4028
- return Promise.resolve(true);
4029
- });
4030
- (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4031
- order.push('prepare-resources');
4032
- return Promise.resolve([
4033
- {
4034
- name: 'images.okpkg',
4035
- binary: new Uint8Array([1]).buffer,
4036
- devicePath: 'vol0:/bundles/images/images.okpkg',
4037
- },
4038
- ]);
4039
- });
4040
- (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4041
- order.push('execute-update');
4042
- return Promise.resolve();
4043
- });
4044
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4045
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4046
- bootloaderVersion: '1.0.0',
4047
- bleVersion: '1.0.0',
4048
- firmwareVersion: '1.0.0',
4049
- });
4050
- method.postTipMessage = jest.fn();
4051
-
4052
- await method.run();
4053
-
4054
- expect(order).toEqual(['enter-bootloader', 'prepare-resources', 'execute-update']);
4055
3766
  });
4056
3767
 
4057
3768
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -4103,33 +3814,6 @@ describe('Protocol V2 firmware update targets', () => {
4103
3814
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4104
3815
  });
4105
3816
 
4106
- test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4107
- const method = new FirmwareUpdateV4({
4108
- id: 1,
4109
- payload: {
4110
- method: 'firmwareUpdateV4',
4111
- },
4112
- });
4113
- (method as any).device = stubDevice({
4114
- originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4115
- features: {
4116
- deviceType: 'pro2',
4117
- mode: 'bootloader',
4118
- bootloaderMode: true,
4119
- capabilities: [],
4120
- },
4121
- isBootloader: () => true,
4122
- isRomloader: () => false,
4123
- });
4124
- (method as any).protocolV2Reboot = jest.fn();
4125
- method.postTipMessage = jest.fn();
4126
-
4127
- await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4128
-
4129
- expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4130
- expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4131
- });
4132
-
4133
3817
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
4134
3818
  const method = new FirmwareUpdateV4({
4135
3819
  id: 1,
@@ -4182,9 +3866,6 @@ describe('Protocol V2 firmware update targets', () => {
4182
3866
  },
4183
3867
  });
4184
3868
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4185
- const prepareResourceBundles = jest
4186
- .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4187
- .mockResolvedValue(undefined);
4188
3869
  (method as any).protocolV2Reboot = jest.fn();
4189
3870
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
4190
3871
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -4197,7 +3878,6 @@ describe('Protocol V2 firmware update targets', () => {
4197
3878
 
4198
3879
  await method.run();
4199
3880
 
4200
- expect(prepareResourceBundles).not.toHaveBeenCalled();
4201
3881
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4202
3882
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4203
3883
  bootloaderBinary: null,
@@ -4280,10 +3960,7 @@ describe('Protocol V2 firmware update targets', () => {
4280
3960
  getCommands: () => commands,
4281
3961
  mainId: 'usb-path',
4282
3962
  });
4283
- const cachedDevice = {
4284
- getConnectId: () => 'usb-path',
4285
- getCurrentSerialNo: () => '',
4286
- };
3963
+ const cachedDevice = { getConnectId: () => 'usb-path' };
4287
3964
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4288
3965
  devices: {},
4289
3966
  deviceList: [cachedDevice],
@@ -4300,207 +3977,31 @@ describe('Protocol V2 firmware update targets', () => {
4300
3977
  expect(initialize).not.toHaveBeenCalled();
4301
3978
  });
4302
3979
 
4303
- test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
3980
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4304
3981
  const method = new FirmwareUpdateV4({
4305
3982
  id: 1,
4306
3983
  payload: {
4307
3984
  method: 'firmwareUpdateV4',
4308
3985
  },
4309
3986
  });
4310
- const commands = { disposed: true, mainId: '' };
4311
- const updateFromCache = jest.fn();
3987
+ const commands = { disposed: false, mainId: 'usb-session' };
3988
+ const acquire = jest.fn().mockResolvedValue(undefined);
4312
3989
  const device = stubDevice({
4313
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
3990
+ originalDescriptor: {
3991
+ id: 'usb-id',
3992
+ path: 'usb-path',
3993
+ session: 'usb-session',
3994
+ protocolType: 'V2',
3995
+ },
4314
3996
  deviceConnector: {
4315
3997
  enumerate: jest.fn().mockResolvedValue({
4316
3998
  descriptors: [
4317
- { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4318
- { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4319
- ],
4320
- }),
4321
- },
4322
- updateFromCache,
4323
- hasDeviceAcquire: jest.fn(() => false),
4324
- acquire: jest.fn().mockResolvedValue(undefined),
4325
- commands,
4326
- getCommands: () => commands,
4327
- mainId: 'old-usb-path',
4328
- });
4329
- const otherDevice = {
4330
- getConnectId: () => 'usb-a',
4331
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4332
- };
4333
- const expectedDevice = {
4334
- getConnectId: () => 'usb-b',
4335
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4336
- };
4337
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4338
- devices: {},
4339
- deviceList: [otherDevice, expectedDevice],
4340
- } as any);
4341
- (method as any).device = device;
4342
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4343
-
4344
- try {
4345
- await (method as any).reconnectProtocolV2Device();
4346
- } finally {
4347
- getDevices.mockRestore();
4348
- }
4349
-
4350
- expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4351
- });
4352
-
4353
- test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4354
- const method = new FirmwareUpdateV4({
4355
- id: 1,
4356
- payload: {
4357
- method: 'firmwareUpdateV4',
4358
- },
4359
- });
4360
- const updateFromCache = jest.fn();
4361
- const device = stubDevice({
4362
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4363
- deviceConnector: {
4364
- enumerate: jest.fn().mockResolvedValue({
4365
- descriptors: [
4366
- { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4367
- { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4368
- ],
4369
- }),
4370
- },
4371
- updateFromCache,
4372
- });
4373
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4374
- devices: {},
4375
- deviceList: [
4376
- {
4377
- getConnectId: () => 'usb-a',
4378
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4379
- },
4380
- {
4381
- getConnectId: () => 'usb-b',
4382
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4383
- },
4384
- ],
4385
- } as any);
4386
- (method as any).device = device;
4387
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4388
-
4389
- try {
4390
- await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4391
- } finally {
4392
- getDevices.mockRestore();
4393
- }
4394
-
4395
- expect(updateFromCache).not.toHaveBeenCalled();
4396
- });
4397
-
4398
- test('allows a single Protocol V2 USB reconnect candidate when its physical serial is unavailable', async () => {
4399
- const method = new FirmwareUpdateV4({
4400
- id: 1,
4401
- payload: {
4402
- method: 'firmwareUpdateV4',
4403
- },
4404
- });
4405
- const commands = { disposed: true, mainId: '' };
4406
- const updateFromCache = jest.fn();
4407
- const device = stubDevice({
4408
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4409
- deviceConnector: {
4410
- enumerate: jest.fn().mockResolvedValue({
4411
- descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4412
- }),
4413
- },
4414
- updateFromCache,
4415
- hasDeviceAcquire: jest.fn(() => false),
4416
- acquire: jest.fn().mockResolvedValue(undefined),
4417
- commands,
4418
- getCommands: () => commands,
4419
- mainId: 'old-usb-path',
4420
- });
4421
- const candidate = {
4422
- getConnectId: () => 'usb-a',
4423
- getCurrentSerialNo: () => '',
4424
- };
4425
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4426
- devices: {},
4427
- deviceList: [candidate],
4428
- } as any);
4429
- (method as any).device = device;
4430
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4431
-
4432
- try {
4433
- await (method as any).reconnectProtocolV2Device();
4434
- } finally {
4435
- getDevices.mockRestore();
4436
- }
4437
-
4438
- expect(updateFromCache).toHaveBeenCalledWith(candidate);
4439
- });
4440
-
4441
- test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4442
- const method = new FirmwareUpdateV4({
4443
- id: 1,
4444
- payload: {
4445
- method: 'firmwareUpdateV4',
4446
- },
4447
- });
4448
- const updateFromCache = jest.fn();
4449
- const device = stubDevice({
4450
- originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4451
- deviceConnector: {
4452
- enumerate: jest.fn().mockResolvedValue({
4453
- descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4454
- }),
4455
- },
4456
- updateFromCache,
4457
- });
4458
- const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4459
- devices: {},
4460
- deviceList: [
4461
- {
4462
- getConnectId: () => 'usb-a',
4463
- getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4464
- },
4465
- ],
4466
- } as any);
4467
- (method as any).device = device;
4468
- (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4469
-
4470
- try {
4471
- await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4472
- } finally {
4473
- getDevices.mockRestore();
4474
- }
4475
-
4476
- expect(updateFromCache).not.toHaveBeenCalled();
4477
- });
4478
-
4479
- test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4480
- const method = new FirmwareUpdateV4({
4481
- id: 1,
4482
- payload: {
4483
- method: 'firmwareUpdateV4',
4484
- },
4485
- });
4486
- const commands = { disposed: false, mainId: 'usb-session' };
4487
- const acquire = jest.fn().mockResolvedValue(undefined);
4488
- const device = stubDevice({
4489
- originalDescriptor: {
4490
- id: 'usb-id',
4491
- path: 'usb-path',
4492
- session: 'usb-session',
4493
- protocolType: 'V2',
4494
- },
4495
- deviceConnector: {
4496
- enumerate: jest.fn().mockResolvedValue({
4497
- descriptors: [
4498
- {
4499
- id: 'usb-id',
4500
- path: 'usb-path',
4501
- session: 'usb-session',
4502
- protocolType: 'V2',
4503
- },
3999
+ {
4000
+ id: 'usb-id',
4001
+ path: 'usb-path',
4002
+ session: 'usb-session',
4003
+ protocolType: 'V2',
4004
+ },
4504
4005
  ],
4505
4006
  }),
4506
4007
  },
@@ -4570,9 +4071,7 @@ describe('Protocol V2 firmware update targets', () => {
4570
4071
  });
4571
4072
  method.postTipMessage = jest.fn();
4572
4073
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4573
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4574
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4575
- (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4074
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4576
4075
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4577
4076
  message: 'Device rebooted successfully',
4578
4077
  });
@@ -4581,29 +4080,9 @@ describe('Protocol V2 firmware update targets', () => {
4581
4080
 
4582
4081
  expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4583
4082
  expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4584
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4585
4083
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4586
4084
  });
4587
4085
 
4588
- test('skips a second Normal reboot after Pro2 already returned to App mode', async () => {
4589
- const method = new FirmwareUpdateV4({
4590
- id: 1,
4591
- payload: {
4592
- method: 'firmwareUpdateV4',
4593
- },
4594
- });
4595
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4596
- (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4597
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4598
- (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4599
- (method as any).protocolV2Reboot = jest.fn();
4600
-
4601
- await (method as any).exitProtocolV2BootloaderToNormal();
4602
-
4603
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4604
- expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4605
- });
4606
-
4607
4086
  test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4608
4087
  const method = new FirmwareUpdateV4({
4609
4088
  id: 1,
@@ -4646,27 +4125,6 @@ describe('Protocol V2 firmware update targets', () => {
4646
4125
  });
4647
4126
  });
4648
4127
 
4649
- test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
4650
- const method = new FirmwareUpdateV4({
4651
- id: 1,
4652
- payload: {
4653
- method: 'firmwareUpdateV4',
4654
- },
4655
- });
4656
- const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4657
-
4658
- (method as any).device = stubDevice({
4659
- getCommands: () => ({ typedCall }),
4660
- });
4661
-
4662
- await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
4663
- message: 'Device rebooted successfully',
4664
- });
4665
- expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4666
- DeviceRebootType.Bootloader
4667
- );
4668
- });
4669
-
4670
4128
  test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4671
4129
  const method = new FirmwareUpdateV4({
4672
4130
  id: 1,
@@ -4677,9 +4135,9 @@ describe('Protocol V2 firmware update targets', () => {
4677
4135
  const typedCall = jest
4678
4136
  .fn()
4679
4137
  .mockRejectedValue(
4680
- new DOMException(
4681
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4682
- 'NotFoundError'
4138
+ Object.assign(
4139
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
+ { name: 'NotFoundError' }
4683
4141
  )
4684
4142
  );
4685
4143
 
@@ -4757,7 +4215,7 @@ describe('Protocol V2 firmware update targets', () => {
4757
4215
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4758
4216
  ]);
4759
4217
 
4760
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4218
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4761
4219
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4762
4220
  'DeviceFirmwareUpdateStatusGet',
4763
4221
  'DeviceFirmwareUpdateStatusGet',
@@ -4795,46 +4253,6 @@ describe('Protocol V2 firmware update targets', () => {
4795
4253
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4796
4254
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4797
4255
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4798
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4799
- });
4800
-
4801
- test('accepts an empty firmware status dump only after confirming normal mode', async () => {
4802
- const method = new FirmwareUpdateV4({
4803
- id: 1,
4804
- payload: {
4805
- method: 'firmwareUpdateV4',
4806
- },
4807
- });
4808
- const typedCall = jest.fn().mockResolvedValue({
4809
- type: 'DeviceFirmwareUpdateStatus',
4810
- message: { records: [] },
4811
- });
4812
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4813
- let now = 0;
4814
- jest.spyOn(Date, 'now').mockImplementation(() => now);
4815
- jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4816
- now += 31 * 1000;
4817
- callback();
4818
- return 0 as any;
4819
- }) as typeof setTimeout);
4820
-
4821
- (method as any).device = stubDevice({
4822
- getCommands: () => ({ typedCall }),
4823
- });
4824
- (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4825
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4826
- (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4827
- method.postProgressMessage = jest.fn();
4828
-
4829
- await expect(
4830
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4831
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4832
- ])
4833
- ).resolves.toBeUndefined();
4834
-
4835
- expect(typedCall).toHaveBeenCalledTimes(1);
4836
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4837
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4838
4256
  });
4839
4257
 
4840
4258
  test.each([
@@ -4900,6 +4318,77 @@ describe('Protocol V2 firmware update targets', () => {
4900
4318
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4901
4319
  });
4902
4320
 
4321
+ test('rejects a finished target whose payload version conflicts with the plan', () => {
4322
+ const method = new FirmwareUpdateV4({
4323
+ id: 1,
4324
+ payload: {
4325
+ method: 'firmwareUpdateV4',
4326
+ platform: 'desktop',
4327
+ expectedTargetVersions: {
4328
+ app_v1: '2.0.0',
4329
+ },
4330
+ },
4331
+ });
4332
+ method.init();
4333
+ method.postProgressMessage = jest.fn();
4334
+
4335
+ expect(() =>
4336
+ (method as any).assertProtocolV2TargetStatus(
4337
+ [{ target_id: 4, status: 2, payload_version: 0x010000 }],
4338
+ new Set([4])
4339
+ )
4340
+ ).toThrow('expected 131072');
4341
+ });
4342
+
4343
+ test('does not fail a completed multi-app install when final app versions are unobservable', () => {
4344
+ const method = new FirmwareUpdateV4({
4345
+ id: 1,
4346
+ payload: {
4347
+ method: 'firmwareUpdateV4',
4348
+ platform: 'desktop',
4349
+ targetsToUpdate: ['app_v1', 'app_v2'],
4350
+ expectedTargetVersions: {
4351
+ app_v1: '1.0.0',
4352
+ app_v2: '2.0.0',
4353
+ },
4354
+ },
4355
+ });
4356
+ method.init();
4357
+ (method as any).protocolV2LatestFinalFeatures = {
4358
+ major_version: 3,
4359
+ minor_version: 0,
4360
+ patch_version: 0,
4361
+ };
4362
+ (method as any).protocolV2FinalStatusVerified = true;
4363
+
4364
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
4365
+ });
4366
+
4367
+ test('rejects unobservable final versions after the status endpoint fallback', () => {
4368
+ const method = new FirmwareUpdateV4({
4369
+ id: 1,
4370
+ payload: {
4371
+ method: 'firmwareUpdateV4',
4372
+ platform: 'desktop',
4373
+ targetsToUpdate: ['app_v1', 'app_v2'],
4374
+ expectedTargetVersions: {
4375
+ app_v1: '1.0.0',
4376
+ app_v2: '2.0.0',
4377
+ },
4378
+ },
4379
+ });
4380
+ method.init();
4381
+ (method as any).protocolV2LatestFinalFeatures = {
4382
+ major_version: 3,
4383
+ minor_version: 0,
4384
+ patch_version: 0,
4385
+ };
4386
+
4387
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
4388
+ 'has no observable final version after status fallback'
4389
+ );
4390
+ });
4391
+
4903
4392
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4904
4393
  const method = new FirmwareUpdateV4({
4905
4394
  id: 1,
@@ -4942,152 +4431,7 @@ describe('Protocol V2 firmware update targets', () => {
4942
4431
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4943
4432
  });
4944
4433
 
4945
- test('fails clearly when a requested target stays absent from the full status dump', async () => {
4946
- const method = new FirmwareUpdateV4({
4947
- id: 1,
4948
- payload: {
4949
- method: 'firmwareUpdateV4',
4950
- },
4951
- });
4952
- const typedCall = jest.fn().mockResolvedValue({
4953
- type: 'DeviceFirmwareUpdateStatus',
4954
- message: {
4955
- records: [{ target_id: 4, status: 2 }],
4956
- },
4957
- });
4958
- let now = 0;
4959
- jest.spyOn(Date, 'now').mockImplementation(() => now);
4960
- jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4961
- now += 31 * 1000;
4962
- callback();
4963
- return 0 as any;
4964
- }) as typeof setTimeout);
4965
-
4966
- (method as any).device = stubDevice({
4967
- getCommands: () => ({ typedCall }),
4968
- });
4969
- (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4970
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4971
- method.postProgressMessage = jest.fn();
4972
-
4973
- await expect(
4974
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4975
- { target_id: 3, path: 'vol0:/bootloader.bin' },
4976
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4977
- ])
4978
- ).rejects.toMatchObject({
4979
- errorCode: HardwareErrorCode.FirmwareError,
4980
- message: expect.stringContaining('targetIds=3'),
4981
- });
4982
- expect(typedCall).toHaveBeenCalledTimes(2);
4983
- });
4984
-
4985
- test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
4986
- const method = new FirmwareUpdateV4({
4987
- id: 1,
4988
- payload: {
4989
- method: 'firmwareUpdateV4',
4990
- },
4991
- });
4992
- const typedCall = jest
4993
- .fn()
4994
- .mockResolvedValueOnce({
4995
- type: 'DeviceFirmwareUpdateStatus',
4996
- message: { records: [{ target_id: 4, status: 2 }] },
4997
- })
4998
- .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
4999
- .mockResolvedValueOnce({
5000
- type: 'DeviceFirmwareUpdateStatus',
5001
- message: { records: [{ target_id: 4, status: 2 }] },
5002
- })
5003
- .mockResolvedValueOnce({
5004
- type: 'DeviceFirmwareUpdateStatus',
5005
- message: {
5006
- records: [
5007
- { target_id: 3, status: 2 },
5008
- { target_id: 4, status: 2 },
5009
- ],
5010
- },
5011
- });
5012
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5013
- let now = 0;
5014
- jest.spyOn(Date, 'now').mockImplementation(() => now);
5015
- jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5016
- now += 31 * 1000;
5017
- callback();
5018
- return 0 as any;
5019
- }) as typeof setTimeout);
5020
-
5021
- (method as any).device = stubDevice({
5022
- getCommands: () => ({ typedCall }),
5023
- });
5024
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5025
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5026
- method.postProgressMessage = jest.fn();
5027
-
5028
- await expect(
5029
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
5030
- { target_id: 3, path: 'vol0:/bootloader.bin' },
5031
- { target_id: 4, path: 'vol0:/application_p1.bin' },
5032
- ])
5033
- ).resolves.toBeUndefined();
5034
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5035
- expect(typedCall).toHaveBeenCalledTimes(4);
5036
- });
5037
-
5038
- test('uses an eight-minute Pro2 install status window', async () => {
5039
- const method = new FirmwareUpdateV4({
5040
- id: 1,
5041
- payload: {
5042
- method: 'firmwareUpdateV4',
5043
- },
5044
- });
5045
- jest
5046
- .spyOn(Date, 'now')
5047
- .mockReturnValueOnce(0)
5048
- .mockReturnValue(8 * 60 * 1000);
5049
-
5050
- await expect(
5051
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
5052
- { target_id: 4, path: 'vol0:/application_p1.bin' },
5053
- ])
5054
- ).rejects.toThrow('within 480s');
5055
- });
5056
-
5057
- test('uses a 90-second Pro2 bootloader reconnect window', async () => {
5058
- const method = new FirmwareUpdateV4({
5059
- id: 1,
5060
- payload: {
5061
- method: 'firmwareUpdateV4',
5062
- },
5063
- });
5064
- jest
5065
- .spyOn(Date, 'now')
5066
- .mockReturnValueOnce(0)
5067
- .mockReturnValue(90 * 1000);
5068
-
5069
- await expect((method as any).waitForProtocolV2BootloaderMode()).rejects.toThrow('within 90s');
5070
- });
5071
-
5072
- test('uses a three-minute Pro2 final reconnect window', async () => {
5073
- const method = new FirmwareUpdateV4({
5074
- id: 1,
5075
- payload: {
5076
- method: 'firmwareUpdateV4',
5077
- },
5078
- });
5079
- const waitForProtocolV2ReconnectAndFeatures = jest
5080
- .fn()
5081
- .mockRejectedValue(new Error('stop after capturing timeout'));
5082
- (method as any).waitForProtocolV2ReconnectAndFeatures = waitForProtocolV2ReconnectAndFeatures;
5083
-
5084
- await expect((method as any).waitForProtocolV2FinalFeatures()).rejects.toThrow(
5085
- 'stop after capturing timeout'
5086
- );
5087
- expect(waitForProtocolV2ReconnectAndFeatures).toHaveBeenCalledWith(3 * 60 * 1000);
5088
- });
5089
-
5090
- test('uses SDK decoded enum names for Protocol V2 install polling', () => {
4434
+ test('uses SDK decoded enum names for Protocol V2 install polling', () => {
5091
4435
  const method = new FirmwareUpdateV4({
5092
4436
  id: 1,
5093
4437
  payload: {
@@ -5121,7 +4465,7 @@ describe('Protocol V2 firmware update targets', () => {
5121
4465
  ).toBe(true);
5122
4466
 
5123
4467
  (method.postProgressMessage as jest.Mock).mockClear();
5124
- expect(
4468
+ expect(() =>
5125
4469
  (method as any).assertProtocolV2TargetStatus(
5126
4470
  [
5127
4471
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -5129,8 +4473,8 @@ describe('Protocol V2 firmware update targets', () => {
5129
4473
  ],
5130
4474
  new Set([4, 10])
5131
4475
  )
5132
- ).toBe(false);
5133
- expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4476
+ ).toThrow('Protocol V2 install status conflicts with target 4');
4477
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5134
4478
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5135
4479
 
5136
4480
  expect(
@@ -5219,7 +4563,7 @@ describe('Protocol V2 firmware update targets', () => {
5219
4563
  path: true,
5220
4564
  },
5221
4565
  },
5222
- { timeoutMs: 15000 }
4566
+ { timeoutMs: 5000 }
5223
4567
  );
5224
4568
  });
5225
4569
 
@@ -5234,10 +4578,14 @@ describe('Protocol V2 firmware update targets', () => {
5234
4578
  const writtenPaths: string[] = [];
5235
4579
  method.postTipMessage = jest.fn();
5236
4580
  method.postProgressMessage = jest.fn();
5237
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4581
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
5238
4582
  writtenPaths.push(params.filePath);
5239
- return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4583
+ return Number(params.processedSize ?? 0) + Number(params.source.size);
5240
4584
  });
4585
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4586
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4587
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({});
4588
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5241
4589
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5242
4590
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5243
4591
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5277,10 +4625,12 @@ describe('Protocol V2 firmware update targets', () => {
5277
4625
  'vol0:/coprocessor.bin',
5278
4626
  1
5279
4627
  );
5280
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
5281
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4628
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
4629
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
4630
+ targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
4631
+ });
4632
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
5282
4633
  targets: [
5283
- { target_id: 3, path: 'vol0:/bootloader.bin' },
5284
4634
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
5285
4635
  { target_id: 7, path: 'vol0:/se01.bin' },
5286
4636
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -5290,7 +4640,7 @@ describe('Protocol V2 firmware update targets', () => {
5290
4640
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
5291
4641
  });
5292
4642
 
5293
- test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4643
+ test('announces separate resource and component transfer phases', async () => {
5294
4644
  const method = new FirmwareUpdateV4({
5295
4645
  id: 1,
5296
4646
  payload: {
@@ -5300,11 +4650,14 @@ describe('Protocol V2 firmware update targets', () => {
5300
4650
 
5301
4651
  method.postTipMessage = jest.fn();
5302
4652
  method.postProgressMessage = jest.fn();
5303
- (method as any).protocolV2CommonUpdateProcess = jest
4653
+ (method as any).protocolV2SourceUpdateProcess = jest
5304
4654
  .fn()
5305
4655
  .mockImplementation(params =>
5306
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4656
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5307
4657
  );
4658
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4659
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4660
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5308
4661
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5309
4662
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5310
4663
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5324,21 +4677,22 @@ describe('Protocol V2 firmware update targets', () => {
5324
4677
  {
5325
4678
  fileName: 'application_p1.bin',
5326
4679
  binary: new Uint8Array([3]).buffer,
5327
- targetId: 3,
4680
+ targetId: 4,
5328
4681
  },
5329
4682
  ],
5330
4683
  });
5331
4684
 
5332
- expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4685
+ expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5333
4686
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5334
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5335
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4687
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5336
4690
  1,
5337
- expect.objectContaining({ processedSize: 0, totalSize: 3 })
4691
+ expect.objectContaining({ processedSize: 0, totalSize: 2 })
5338
4692
  );
5339
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4693
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5340
4694
  2,
5341
- expect.objectContaining({ processedSize: 2, totalSize: 3 })
4695
+ expect.objectContaining({ processedSize: 0, totalSize: 1 })
5342
4696
  );
5343
4697
  });
5344
4698
 
@@ -5352,7 +4706,8 @@ describe('Protocol V2 firmware update targets', () => {
5352
4706
 
5353
4707
  method.postTipMessage = jest.fn();
5354
4708
  method.postProgressMessage = jest.fn();
5355
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4709
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5356
4711
  (method as any).device = stubDevice({
5357
4712
  getCommands: () => ({
5358
4713
  typedCall: jest.fn().mockResolvedValue({
@@ -5398,11 +4753,14 @@ describe('Protocol V2 firmware update targets', () => {
5398
4753
 
5399
4754
  method.postTipMessage = jest.fn();
5400
4755
  method.postProgressMessage = jest.fn();
5401
- (method as any).protocolV2CommonUpdateProcess = jest
4756
+ (method as any).protocolV2SourceUpdateProcess = jest
5402
4757
  .fn()
5403
4758
  .mockImplementation(params =>
5404
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4759
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5405
4760
  );
4761
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
4762
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4763
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5406
4764
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5407
4765
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
5408
4766
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -5617,42 +4975,33 @@ describe('Protocol V2 firmware update targets', () => {
5617
4975
  getFirmwareLatestReleaseSpy.mockRestore();
5618
4976
  });
5619
4977
 
5620
- test('does not download Pro2 firmware components that App did not select', async () => {
4978
+ test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5621
4979
  const method = new FirmwareUpdateV4({
5622
4980
  id: 1,
5623
4981
  payload: {
5624
4982
  method: 'firmwareUpdateV4',
5625
4983
  platform: 'web',
4984
+ targetsToUpdate: ['resource'],
5626
4985
  },
5627
4986
  });
5628
4987
  method.init();
5629
4988
 
5630
- const getSysResourceBinarySpy = jest
5631
- .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5632
- .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5633
4989
  const getFirmwareLatestReleaseSpy = jest
5634
4990
  .spyOn(DataManager, 'getFirmwareLatestRelease')
5635
4991
  .mockReturnValue({
5636
4992
  required: false,
5637
4993
  version: [1, 0, 0],
5638
- url: 'https://example.com/applicationP1.pp.bin',
5639
- bootloaderVersion: [1, 0, 0],
5640
- upgradeType: 'payload-package-set',
5641
- installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5642
- components: {
5643
- bootloader: {
5644
- target: 'BOOTLOADER',
5645
- url: 'https://example.com/bootloader.pp.bin',
5646
- },
5647
- applicationP1: {
5648
- target: 'APPLICATION_P1',
5649
- url: 'https://example.com/applicationP1.pp.bin',
5650
- },
5651
- applicationP2: {
5652
- target: 'APPLICATION_P2',
5653
- url: 'https://example.com/applicationP2.pp.bin',
4994
+ url: '',
4995
+ resourceBundles: [
4996
+ {
4997
+ name: 'images',
4998
+ url: 'https://example.com/images.okpkg',
4999
+ devicePath: 'VOL0:/resource/images/images.okpkg',
5000
+ version: [2, 0, 0],
5001
+ payloadHash: 'ab'.repeat(64),
5002
+ headerHash: 'cd'.repeat(64),
5654
5003
  },
5655
- },
5004
+ ],
5656
5005
  fingerprint: '',
5657
5006
  changelog: {
5658
5007
  'zh-CN': '',
@@ -5660,122 +5009,427 @@ describe('Protocol V2 firmware update targets', () => {
5660
5009
  },
5661
5010
  });
5662
5011
 
5663
- const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5012
+ const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5664
5013
  deviceType: 'pro2',
5665
5014
  firmwareVersion: '1.0.0',
5666
- bootloaderVersion: '1.0.0',
5667
5015
  capabilities: [],
5668
5016
  });
5669
5017
 
5670
- expect(remoteBinaries).toEqual({
5671
- bootloaderBinary: null,
5672
- fwBinaryMap: [],
5673
- installItems: [],
5674
- });
5675
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5018
+ expect(bundles).toEqual([
5019
+ {
5020
+ name: 'images',
5021
+ binary: expect.any(ArrayBuffer),
5022
+ devicePath: 'vol0:/resource/images/images.okpkg',
5023
+ url: 'https://example.com/images.okpkg',
5024
+ version: [2, 0, 0],
5025
+ payloadHash: 'ab'.repeat(64),
5026
+ headerHash: 'cd'.repeat(64),
5027
+ },
5028
+ ]);
5029
+ expect(bundles[0].binary.byteLength).toBe(0);
5676
5030
 
5677
- getSysResourceBinarySpy.mockRestore();
5678
5031
  getFirmwareLatestReleaseSpy.mockRestore();
5679
5032
  });
5680
5033
 
5681
- test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5034
+ test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5682
5035
  const method = new FirmwareUpdateV4({
5683
5036
  id: 1,
5684
5037
  payload: {
5685
5038
  method: 'firmwareUpdateV4',
5686
5039
  platform: 'web',
5040
+ targetsToUpdate: ['resource'],
5687
5041
  },
5688
5042
  });
5689
5043
  method.init();
5690
5044
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5691
5045
 
5692
- const remoteBinaries = {
5693
- bootloaderBinary: new Uint8Array([1]).buffer,
5694
- fwBinaryMap: [
5695
- {
5696
- fileName: 'application_p1.bin',
5697
- binary: new Uint8Array([2]).buffer,
5698
- targetId: 3,
5046
+ const getSysResourceBinarySpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
5047
+ const getFirmwareLatestReleaseSpy = jest
5048
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5049
+ .mockReturnValue({
5050
+ required: false,
5051
+ version: [1, 0, 0],
5052
+ url: '',
5053
+ resourceBundles: [
5054
+ {
5055
+ name: 'images',
5056
+ url: 'https://example.com/images.okpkg',
5057
+ devicePath: 'vol0:/resource/../images.okpkg',
5058
+ },
5059
+ ],
5060
+ fingerprint: '',
5061
+ changelog: {
5062
+ 'zh-CN': '',
5063
+ 'en-US': '',
5699
5064
  },
5700
- ],
5701
- };
5065
+ });
5702
5066
 
5703
5067
  (method as any).device = stubDevice({
5704
5068
  originalDescriptor: { protocolType: 'V2' },
5705
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5069
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5706
5070
  });
5707
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5708
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5709
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5710
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5711
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5712
- bootloaderVersion: '1.0.0',
5713
- bleVersion: '0.0.0',
5714
- firmwareVersion: '1.0.0',
5071
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
+ bootloaderBinary: null,
5073
+ fwBinaryMap: [],
5715
5074
  });
5075
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5716
5076
  method.postTipMessage = jest.fn();
5717
5077
 
5718
- await method.run();
5078
+ await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5719
5081
 
5720
- expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5721
- expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5722
- expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5723
- bootloaderBinary: remoteBinaries.bootloaderBinary,
5724
- fwBinaryMap: remoteBinaries.fwBinaryMap,
5725
- });
5082
+ getSysResourceBinarySpy.mockRestore();
5083
+ getFirmwareLatestReleaseSpy.mockRestore();
5726
5084
  });
5727
5085
 
5728
- test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5086
+ test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5729
5087
  const method = new FirmwareUpdateV4({
5730
5088
  id: 1,
5731
5089
  payload: {
5732
5090
  method: 'firmwareUpdateV4',
5733
- platform: 'web',
5734
5091
  },
5735
5092
  });
5736
- method.init();
5737
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5738
- (method as any).device = stubDevice({
5739
- originalDescriptor: { protocolType: 'V2' },
5740
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5741
- });
5742
- forceReloadDataSpy.mockRejectedValue(new Error('latest config unavailable'));
5743
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5744
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5745
- method.postTipMessage = jest.fn();
5093
+ const openPreparedSource = jest.fn();
5094
+ (method as any).params = {
5095
+ targetsToUpdate: ['resource'],
5096
+ resourceBundleArtifacts: [
5097
+ {
5098
+ name: 'images',
5099
+ artifact: {
5100
+ artifactRef: `fw:${'a'.repeat(64)}`,
5101
+ size: 1,
5102
+ sha256: 'a'.repeat(64),
5103
+ },
5104
+ },
5105
+ ],
5106
+ };
5107
+ (method as any).openProtocolV2PreparedSource = openPreparedSource;
5108
+ const getFirmwareLatestReleaseSpy = jest
5109
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5110
+ .mockReturnValue({
5111
+ required: false,
5112
+ version: [1, 0, 0],
5113
+ url: '',
5114
+ resourceBundles: [
5115
+ {
5116
+ name: 'images',
5117
+ url: 'https://example.com/images.okpkg',
5118
+ devicePath: 'vol0:/resource/../images.okpkg',
5119
+ },
5120
+ ],
5121
+ fingerprint: '',
5122
+ changelog: {
5123
+ 'zh-CN': '',
5124
+ 'en-US': '',
5125
+ },
5126
+ });
5746
5127
 
5747
- await expect(method.run()).rejects.toThrow('latest config unavailable');
5128
+ await expect(
5129
+ (method as any).prepareProtocolV2ResourceSources('universal', {
5130
+ deviceType: 'pro2',
5131
+ })
5132
+ ).rejects.toThrow('resourceBundles[].devicePath');
5133
+ expect(openPreparedSource).not.toHaveBeenCalled();
5748
5134
 
5749
- expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5750
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5135
+ getFirmwareLatestReleaseSpy.mockRestore();
5751
5136
  });
5752
5137
 
5753
- test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5138
+ test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5754
5139
  const method = new FirmwareUpdateV4({
5755
5140
  id: 1,
5756
5141
  payload: {
5757
5142
  method: 'firmwareUpdateV4',
5758
5143
  platform: 'web',
5759
- coprocessorBinary: new Uint8Array([1]).buffer,
5144
+ targetsToUpdate: ['resource'],
5760
5145
  },
5761
5146
  });
5762
5147
  method.init();
5763
5148
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5764
5149
 
5765
- (method as any).device = stubDevice({
5766
- originalDescriptor: { protocolType: 'V2' },
5767
- features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5768
- });
5769
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5770
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5771
- (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5772
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5773
- (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5774
- bootloaderVersion: '1.0.0',
5775
- bleVersion: '0.0.0',
5776
- firmwareVersion: '1.0.0',
5777
- });
5778
- method.postTipMessage = jest.fn();
5150
+ const order: string[] = [];
5151
+ const resourceBinary = buildOkppHeader().buffer as ArrayBuffer;
5152
+ const getSysResourceBinarySpy = jest
5153
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5154
+ .mockImplementation(() => {
5155
+ order.push('download');
5156
+ return Promise.resolve({ binary: resourceBinary });
5157
+ });
5158
+ const getFirmwareLatestReleaseSpy = jest
5159
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5160
+ .mockReturnValue({
5161
+ required: false,
5162
+ version: [1, 0, 0],
5163
+ url: '',
5164
+ resourceBundles: [
5165
+ {
5166
+ name: 'images',
5167
+ url: 'https://example.com/images.okpkg',
5168
+ devicePath: ' VOL0:/resource/images/images.okpkg ',
5169
+ },
5170
+ ],
5171
+ fingerprint: '',
5172
+ changelog: {
5173
+ 'zh-CN': '',
5174
+ 'en-US': '',
5175
+ },
5176
+ });
5177
+
5178
+ (method as any).device = stubDevice({
5179
+ originalDescriptor: { protocolType: 'V2' },
5180
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5181
+ });
5182
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5183
+ bootloaderBinary: null,
5184
+ fwBinaryMap: [],
5185
+ });
5186
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
5187
+ order.push('bootloader');
5188
+ return Promise.resolve(true);
5189
+ });
5190
+ const executeProtocolV2UpdateSpy = jest.spyOn(method as any, 'executeProtocolV2Update');
5191
+ (method as any).executeProtocolV2TransferPhase = jest.fn().mockResolvedValue(undefined);
5192
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue(undefined);
5193
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5194
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5195
+ bootloaderVersion: '1.0.0',
5196
+ bleVersion: '0.0.0',
5197
+ firmwareVersion: '1.0.0',
5198
+ });
5199
+ method.postTipMessage = jest.fn();
5200
+
5201
+ await method.run();
5202
+
5203
+ expect(order).toEqual(['download', 'bootloader']);
5204
+ expect(executeProtocolV2UpdateSpy).toHaveBeenCalledWith({
5205
+ bootloaderBinary: null,
5206
+ fwBinaryMap: [],
5207
+ resourceBundles: [
5208
+ {
5209
+ name: 'images',
5210
+ binary: resourceBinary,
5211
+ devicePath: 'vol0:/resource/images/images.okpkg',
5212
+ url: 'https://example.com/images.okpkg',
5213
+ },
5214
+ ],
5215
+ });
5216
+
5217
+ executeProtocolV2UpdateSpy.mockRestore();
5218
+ getSysResourceBinarySpy.mockRestore();
5219
+ getFirmwareLatestReleaseSpy.mockRestore();
5220
+ });
5221
+
5222
+ test('rejects a remote RESC bundle without an OKPP header when metadata is absent', async () => {
5223
+ const method = new FirmwareUpdateV4({
5224
+ id: 1,
5225
+ payload: {
5226
+ method: 'firmwareUpdateV4',
5227
+ },
5228
+ });
5229
+ method.init();
5230
+ const getSysResourceBinarySpy = jest
5231
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5232
+ .mockResolvedValue({ binary: new Uint8Array([1, 2, 3]).buffer });
5233
+
5234
+ await expect(
5235
+ (method as any).materializeProtocolV2ResourceBundles([
5236
+ {
5237
+ name: 'images',
5238
+ binary: new ArrayBuffer(0),
5239
+ devicePath: 'vol0:/resource/images/images.okpkg',
5240
+ url: 'https://example.com/images.okpkg',
5241
+ },
5242
+ ])
5243
+ ).rejects.toThrow('Invalid Protocol V2 RESC bundle header: images');
5244
+
5245
+ getSysResourceBinarySpy.mockRestore();
5246
+ });
5247
+
5248
+ test('rejects an empty remote RESC bundle before entering bootloader', async () => {
5249
+ const method = new FirmwareUpdateV4({
5250
+ id: 1,
5251
+ payload: {
5252
+ method: 'firmwareUpdateV4',
5253
+ platform: 'web',
5254
+ targetsToUpdate: ['resource'],
5255
+ },
5256
+ });
5257
+ method.init();
5258
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5259
+
5260
+ const getSysResourceBinarySpy = jest
5261
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5262
+ .mockResolvedValue({ binary: new ArrayBuffer(0) });
5263
+ const getFirmwareLatestReleaseSpy = jest
5264
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5265
+ .mockReturnValue({
5266
+ required: false,
5267
+ version: [1, 0, 0],
5268
+ url: '',
5269
+ resourceBundles: [
5270
+ {
5271
+ name: 'images',
5272
+ url: 'https://example.com/images.okpkg',
5273
+ devicePath: 'vol0:/resource/images/images.okpkg',
5274
+ },
5275
+ ],
5276
+ fingerprint: '',
5277
+ changelog: {
5278
+ 'zh-CN': '',
5279
+ 'en-US': '',
5280
+ },
5281
+ });
5282
+ (method as any).device = stubDevice({
5283
+ originalDescriptor: { protocolType: 'V2' },
5284
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5285
+ });
5286
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5287
+ bootloaderBinary: null,
5288
+ fwBinaryMap: [],
5289
+ });
5290
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5291
+ method.postTipMessage = jest.fn();
5292
+
5293
+ await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
5294
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5295
+
5296
+ getSysResourceBinarySpy.mockRestore();
5297
+ getFirmwareLatestReleaseSpy.mockRestore();
5298
+ });
5299
+
5300
+ test('does not download Pro2 firmware components that App did not select', async () => {
5301
+ const method = new FirmwareUpdateV4({
5302
+ id: 1,
5303
+ payload: {
5304
+ method: 'firmwareUpdateV4',
5305
+ platform: 'web',
5306
+ },
5307
+ });
5308
+ method.init();
5309
+
5310
+ const getSysResourceBinarySpy = jest
5311
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5312
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
5313
+ const getFirmwareLatestReleaseSpy = jest
5314
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
5315
+ .mockReturnValue({
5316
+ required: false,
5317
+ version: [1, 0, 0],
5318
+ url: 'https://example.com/applicationP1.pp.bin',
5319
+ bootloaderVersion: [1, 0, 0],
5320
+ upgradeType: 'payload-package-set',
5321
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5322
+ components: {
5323
+ bootloader: {
5324
+ target: 'BOOTLOADER',
5325
+ url: 'https://example.com/bootloader.pp.bin',
5326
+ },
5327
+ applicationP1: {
5328
+ target: 'APPLICATION_P1',
5329
+ url: 'https://example.com/applicationP1.pp.bin',
5330
+ },
5331
+ applicationP2: {
5332
+ target: 'APPLICATION_P2',
5333
+ url: 'https://example.com/applicationP2.pp.bin',
5334
+ },
5335
+ },
5336
+ fingerprint: '',
5337
+ changelog: {
5338
+ 'zh-CN': '',
5339
+ 'en-US': '',
5340
+ },
5341
+ });
5342
+
5343
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5344
+ deviceType: 'pro2',
5345
+ firmwareVersion: '1.0.0',
5346
+ bootloaderVersion: '1.0.0',
5347
+ capabilities: [],
5348
+ });
5349
+
5350
+ expect(remoteBinaries).toEqual({
5351
+ bootloaderBinary: null,
5352
+ fwBinaryMap: [],
5353
+ installItems: [],
5354
+ });
5355
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5356
+
5357
+ getSysResourceBinarySpy.mockRestore();
5358
+ getFirmwareLatestReleaseSpy.mockRestore();
5359
+ });
5360
+
5361
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5362
+ const method = new FirmwareUpdateV4({
5363
+ id: 1,
5364
+ payload: {
5365
+ method: 'firmwareUpdateV4',
5366
+ platform: 'web',
5367
+ },
5368
+ });
5369
+ method.init();
5370
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5371
+
5372
+ const remoteBinaries = {
5373
+ bootloaderBinary: new Uint8Array([1]).buffer,
5374
+ fwBinaryMap: [
5375
+ {
5376
+ fileName: 'application_p1.bin',
5377
+ binary: new Uint8Array([2]).buffer,
5378
+ targetId: 3,
5379
+ },
5380
+ ],
5381
+ };
5382
+
5383
+ (method as any).device = stubDevice({
5384
+ originalDescriptor: { protocolType: 'V2' },
5385
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5386
+ });
5387
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5388
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5389
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5390
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5391
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5392
+ bootloaderVersion: '1.0.0',
5393
+ bleVersion: '0.0.0',
5394
+ firmwareVersion: '1.0.0',
5395
+ });
5396
+ method.postTipMessage = jest.fn();
5397
+
5398
+ await method.run();
5399
+
5400
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5401
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5402
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5403
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5404
+ });
5405
+ });
5406
+
5407
+ test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5408
+ const method = new FirmwareUpdateV4({
5409
+ id: 1,
5410
+ payload: {
5411
+ method: 'firmwareUpdateV4',
5412
+ platform: 'web',
5413
+ coprocessorBinary: new Uint8Array([1]).buffer,
5414
+ },
5415
+ });
5416
+ method.init();
5417
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5418
+
5419
+ (method as any).device = stubDevice({
5420
+ originalDescriptor: { protocolType: 'V2' },
5421
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5422
+ });
5423
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5424
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5425
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5426
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5427
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5428
+ bootloaderVersion: '1.0.0',
5429
+ bleVersion: '0.0.0',
5430
+ firmwareVersion: '1.0.0',
5431
+ });
5432
+ method.postTipMessage = jest.fn();
5779
5433
 
5780
5434
  await method.run();
5781
5435
 
@@ -5875,7 +5529,10 @@ describe('Protocol V2 firmware update targets', () => {
5875
5529
 
5876
5530
  method.postTipMessage = jest.fn();
5877
5531
  method.postProgressMessage = jest.fn();
5878
- (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5532
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
5533
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5534
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5535
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
5879
5536
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5880
5537
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5881
5538
 
@@ -5891,7 +5548,7 @@ describe('Protocol V2 firmware update targets', () => {
5891
5548
  fwBinaryMap: [],
5892
5549
  });
5893
5550
 
5894
- expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5551
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5895
5552
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5896
5553
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5897
5554
  });
@@ -5922,12 +5579,19 @@ describe('Protocol V2 firmware update targets', () => {
5922
5579
  });
5923
5580
  method.postProgressMessage = jest.fn();
5924
5581
 
5925
- await (method as any).protocolV2CommonUpdateProcess({
5926
- payload: new Uint8Array(4097).buffer,
5927
- filePath: 'vol1:firmware.bin',
5928
- processedSize: 0,
5929
- totalSize: 4097,
5582
+ const source = await openFirmwareByteSource({
5583
+ binary: new Uint8Array(4097).buffer,
5930
5584
  });
5585
+ try {
5586
+ await (method as any).protocolV2SourceUpdateProcess({
5587
+ source,
5588
+ filePath: 'vol1:firmware.bin',
5589
+ processedSize: 0,
5590
+ totalSize: 4097,
5591
+ });
5592
+ } finally {
5593
+ await source?.close();
5594
+ }
5931
5595
 
5932
5596
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5933
5597
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5984,12 +5648,16 @@ describe('Protocol V2 firmware update targets', () => {
5984
5648
  method.postProgressMessage = jest.fn();
5985
5649
 
5986
5650
  try {
5987
- await (method as any).protocolV2CommonUpdateProcess({
5988
- payload: new Uint8Array(8001).buffer,
5651
+ const source = await openFirmwareByteSource({
5652
+ binary: new Uint8Array(8001).buffer,
5653
+ });
5654
+ await (method as any).protocolV2SourceUpdateProcess({
5655
+ source,
5989
5656
  filePath: 'vol0:/firmware.bin',
5990
5657
  processedSize: 0,
5991
5658
  totalSize: 8001,
5992
5659
  });
5660
+ await source?.close();
5993
5661
  } finally {
5994
5662
  setTimeoutSpy.mockRestore();
5995
5663
  }
@@ -6024,16 +5692,21 @@ describe('Protocol V2 firmware update targets', () => {
6024
5692
  });
6025
5693
  method.postProgressMessage = jest.fn();
6026
5694
  method.postTipMessage = jest.fn();
5695
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
6027
5696
 
5697
+ const source = await openFirmwareByteSource({
5698
+ binary: new Uint8Array(4097).buffer,
5699
+ });
6028
5700
  await expect(
6029
- (method as any).protocolV2WriteWholeFile({
6030
- payload: new Uint8Array(4097).buffer,
5701
+ (method as any).protocolV2SourceUpdateProcess({
5702
+ source,
6031
5703
  filePath: 'vol0:/firmware.bin',
6032
5704
  processedSize: 0,
6033
5705
  totalSize: 4097,
6034
5706
  })
6035
5707
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
6036
- expect(typedCall).toHaveBeenCalledTimes(2);
5708
+ await source?.close();
5709
+ expect(typedCall).toHaveBeenCalledTimes(6);
6037
5710
  });
6038
5711
 
6039
5712
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -6066,12 +5739,19 @@ describe('Protocol V2 firmware update targets', () => {
6066
5739
  });
6067
5740
  method.postProgressMessage = jest.fn();
6068
5741
 
6069
- await (method as any).protocolV2CommonUpdateProcess({
6070
- payload: new Uint8Array(1801).buffer,
6071
- filePath: 'vol1:ble-firmware.bin',
6072
- processedSize: 0,
6073
- totalSize: 1801,
5742
+ const source = await openFirmwareByteSource({
5743
+ binary: new Uint8Array(1801).buffer,
6074
5744
  });
5745
+ try {
5746
+ await (method as any).protocolV2SourceUpdateProcess({
5747
+ source,
5748
+ filePath: 'vol1:ble-firmware.bin',
5749
+ processedSize: 0,
5750
+ totalSize: 1801,
5751
+ });
5752
+ } finally {
5753
+ await source?.close();
5754
+ }
6075
5755
 
6076
5756
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
6077
5757
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -6188,9 +5868,9 @@ describe('Protocol V2 firmware update method', () => {
6188
5868
  const typedCall = jest
6189
5869
  .fn()
6190
5870
  .mockRejectedValue(
6191
- new DOMException(
6192
- "Failed to execute 'open' on 'USBDevice': The device was disconnected",
6193
- 'NotFoundError'
5871
+ Object.assign(
5872
+ new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
+ { name: 'NotFoundError' }
6194
5874
  )
6195
5875
  );
6196
5876
 
@@ -6355,63 +6035,20 @@ describe('Protocol V2 firmware update method', () => {
6355
6035
  });
6356
6036
 
6357
6037
  describe('Protocol V2 firmware reconnect identity', () => {
6358
- const createResourceFilesystemTypedCall = (
6359
- stable: Array<{ type: string; size: number; headerHash: string }>,
6360
- missingTypes: string[] = []
6361
- ) => {
6362
- const missing = new Set(missingTypes);
6363
- const resourceByPath = new Map(
6364
- stable.map(resource => [
6365
- PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6366
- resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6367
- ],
6368
- resource,
6369
- ])
6370
- );
6371
- return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6372
- if (requestType === 'ResourceInventoryGet') {
6373
- throw new Error('ResourceInventoryGet is unavailable on released firmware');
6374
- }
6375
- if (requestType === 'FilesystemPathInfoQuery') {
6376
- const resource = resourceByPath.get(payload.path);
6377
- const exists = Boolean(resource && !missing.has(resource.type));
6378
- return {
6379
- message: {
6380
- exist: exists,
6381
- directory: false,
6382
- size: exists ? resource?.size : 0,
6383
- },
6384
- };
6385
- }
6386
- if (requestType === 'FilesystemFileRead') {
6387
- const resource = resourceByPath.get(payload.file.path);
6388
- if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6389
- const header = new Uint8Array(0x52a0);
6390
- const view = new DataView(header.buffer);
6391
- 'OKPP'.split('').forEach((char, index) => {
6392
- header[index] = char.charCodeAt(0);
6393
- });
6394
- 'RESC'.split('').forEach((char, index) => {
6395
- header[0x08 + index] = char.charCodeAt(0);
6396
- });
6397
- view.setUint32(0x0c, header.byteLength, true);
6398
- for (let index = 0; index < resource.headerHash.length / 2; index++) {
6399
- header[0x240 + index] = Number.parseInt(
6400
- resource.headerHash.slice(index * 2, index * 2 + 2),
6401
- 16
6402
- );
6403
- }
6404
- const offset = Number(payload.file.offset);
6405
- const chunkLength = Number(payload.chunk_len);
6406
- return {
6407
- message: {
6408
- data: header.slice(offset, offset + chunkLength),
6409
- },
6410
- };
6411
- }
6412
- throw new Error(`Unexpected request: ${requestType}`);
6038
+ test('keeps the host plan identity as the V4 reconnect anchor', () => {
6039
+ const method = new FirmwareUpdateV4({
6040
+ id: 1,
6041
+ payload: {
6042
+ method: 'firmwareUpdateV4',
6043
+ platform: 'native',
6044
+ expectedDeviceId: 'persisted-device-id',
6045
+ },
6413
6046
  });
6414
- };
6047
+
6048
+ method.init();
6049
+
6050
+ expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
+ });
6415
6052
 
6416
6053
  test('rejects a different physical serial before firmware transfer resumes', () => {
6417
6054
  expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
@@ -6422,10 +6059,16 @@ describe('Protocol V2 firmware reconnect identity', () => {
6422
6059
  ).not.toThrow();
6423
6060
  });
6424
6061
 
6425
- test('allows reconnect when either physical serial is unavailable', () => {
6426
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).not.toThrow();
6427
- expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).not.toThrow();
6428
- expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).not.toThrow();
6062
+ test('fails closed when the physical serial is unavailable', () => {
6063
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).toThrow(
6064
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6065
+ );
6066
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).toThrow(
6067
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6068
+ );
6069
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).toThrow(
6070
+ expect.objectContaining({ errorCode: HardwareErrorCode.DeviceNotFound })
6071
+ );
6429
6072
  });
6430
6073
 
6431
6074
  test('captures physical identity from active DeviceInfo instead of wallet deviceId', async () => {
@@ -6458,7 +6101,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6458
6101
  );
6459
6102
  });
6460
6103
 
6461
- test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6104
+ test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6462
6105
  const method = new FirmwareUpdateV4({
6463
6106
  id: 1,
6464
6107
  payload: { method: 'firmwareUpdateV4' },
@@ -6471,209 +6114,39 @@ describe('Protocol V2 firmware reconnect identity', () => {
6471
6114
  getCommands: () => ({ typedCall }),
6472
6115
  });
6473
6116
 
6474
- await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6475
- expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6476
- });
6477
-
6478
- test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6479
- const method = new FirmwareUpdateV4({
6480
- id: 1,
6481
- payload: {
6482
- method: 'firmwareUpdateV4',
6483
- platform: 'web',
6484
- targetsToUpdate: ['resource'],
6485
- },
6117
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6118
+ errorCode: HardwareErrorCode.DeviceNotFound,
6486
6119
  });
6487
- method.init();
6488
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6489
- (type, index) => ({
6490
- type,
6491
- url: `https://example.com/${type}.okpkg`,
6492
- size: 0x52a0 + index + 1,
6493
- fileHash: 'a'.repeat(64),
6494
- headerHash: index.toString(16).padStart(128, '0'),
6495
- })
6496
- );
6497
- const typedCall = createResourceFilesystemTypedCall(stable);
6498
- (method as any).device = stubDevice({
6499
- getCommands: () => ({ typedCall }),
6500
- });
6501
- const resourcesSpy = jest
6502
- .spyOn(DataManager, 'getProtocolV2Resources')
6503
- .mockReturnValue(stable as any);
6504
- (method as any).downloadProtocolV2Resource = jest.fn();
6505
-
6506
- await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6507
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6508
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6509
- expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6510
- resourcesSpy.mockRestore();
6511
- });
6512
-
6513
- test('downloads only changed resources from loader inventory', async () => {
6514
- const method = new FirmwareUpdateV4({
6515
- id: 1,
6516
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6517
- });
6518
- method.init();
6519
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6520
- (type, index) => ({
6521
- type,
6522
- url: `https://example.com/${type}.okpkg`,
6523
- size: 0x52a0 + index + 1,
6524
- fileHash: 'a'.repeat(64),
6525
- headerHash: index.toString(16).padStart(128, '0'),
6526
- })
6527
- );
6528
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6529
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6530
- const resourcesSpy = jest
6531
- .spyOn(DataManager, 'getProtocolV2Resources')
6532
- .mockReturnValue(stable as any);
6533
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6534
- Promise.resolve({
6535
- name: `${resource.type}.okpkg`,
6536
- binary: new ArrayBuffer(resource.size),
6537
- devicePath: `vol0:/${resource.type}.okpkg`,
6538
- })
6539
- );
6540
-
6541
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6542
-
6543
- expect(bundles).toHaveLength(1);
6544
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6545
- resourcesSpy.mockRestore();
6546
6120
  });
6547
6121
 
6548
- test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6549
- const method = new FirmwareUpdateV4({
6550
- id: 1,
6551
- payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6552
- });
6553
- method.init();
6554
- const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6555
- (type, index) => ({
6556
- type,
6557
- url: `https://example.com/${type}.okpkg`,
6558
- size: 0x52a0 + index + 1,
6559
- fileHash: 'a'.repeat(64),
6560
- headerHash: index.toString(16).padStart(128, '0'),
6561
- })
6562
- );
6563
- const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6564
- (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6565
- const resourcesSpy = jest
6566
- .spyOn(DataManager, 'getProtocolV2Resources')
6567
- .mockReturnValue(stable as any);
6568
- (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6569
- Promise.resolve({
6570
- name: `${resource.type}.okpkg`,
6571
- binary: new ArrayBuffer(resource.size),
6572
- devicePath: `vol0:/${resource.type}.okpkg`,
6573
- })
6574
- );
6575
-
6576
- const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6577
-
6578
- expect(bundles).toHaveLength(1);
6579
- expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6580
- expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6581
- expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6582
- resourcesSpy.mockRestore();
6583
- });
6584
-
6585
- const buildBootResourcesHeader = ({
6586
- type = 'CRAT',
6587
- payloadHash = 'ab'.repeat(64),
6588
- headerHash = 'cd'.repeat(64),
6589
- }: {
6590
- type?: string;
6591
- payloadHash?: string;
6592
- headerHash?: string;
6593
- } = {}) => {
6594
- const header = new Uint8Array(0x52a0);
6595
- const view = new DataView(header.buffer);
6596
- 'OKPP'.split('').forEach((char, index) => {
6597
- header[index] = char.charCodeAt(0);
6598
- });
6599
- type.split('').forEach((char, index) => {
6600
- header[0x08 + index] = char.charCodeAt(0);
6601
- });
6602
- view.setUint32(0x0c, header.byteLength, true);
6603
- const writeHex = (offset: number, hex: string) => {
6604
- for (let index = 0; index < hex.length / 2; index++) {
6605
- header[offset + index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
6606
- }
6607
- };
6608
- writeHex(0x200, payloadHash);
6609
- writeHex(0x240, headerHash);
6610
- return header;
6611
- };
6612
-
6613
- test('does not resolve boot resources unless the optional target is selected', async () => {
6614
- const method = new FirmwareUpdateV4({
6615
- id: 1,
6616
- payload: { method: 'firmwareUpdateV4', platform: 'web' },
6617
- });
6618
- method.init();
6619
- const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6620
- const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6621
-
6622
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6623
-
6624
- expect(configSpy).not.toHaveBeenCalled();
6625
- expect(downloadSpy).not.toHaveBeenCalled();
6626
- });
6627
-
6628
- test('downloads and maps the selected boot resources CRATE target', async () => {
6629
- const payloadHash = 'ab'.repeat(64);
6630
- const headerHash = 'cd'.repeat(64);
6631
- const bytes = buildBootResourcesHeader({ payloadHash, headerHash });
6632
- const binary = bytes.buffer as ArrayBuffer;
6633
- const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6634
- const resource = {
6635
- required: false as const,
6636
- target: 'CRATE' as const,
6637
- url: 'https://example.com/boot-resources.crate.okpkg',
6638
- size: bytes.byteLength,
6639
- fileHash,
6640
- payloadHash,
6641
- headerHash,
6642
- };
6122
+ test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6643
6123
  const method = new FirmwareUpdateV4({
6644
6124
  id: 1,
6645
6125
  payload: {
6646
6126
  method: 'firmwareUpdateV4',
6647
- platform: 'web',
6648
- targetsToUpdate: ['boot_resources'],
6127
+ platform: 'native',
6128
+ chunkSize: 1800,
6649
6129
  },
6650
6130
  });
6651
6131
  method.init();
6652
- jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6653
- jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6654
-
6655
- await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual({
6656
- fileName: 'boot_resources.crate.okpkg',
6657
- binary,
6658
- targetId: 1,
6659
- kind: 'boot_resources',
6132
+ const readChunkLengths: number[] = [];
6133
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6134
+ if (requestType === 'FilesystemPathInfoQuery') {
6135
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
6136
+ }
6137
+ if (requestType === 'FilesystemFileRead') {
6138
+ readChunkLengths.push(request.chunk_len);
6139
+ return Promise.resolve({ message: { data: new Uint8Array(request.chunk_len) } });
6140
+ }
6141
+ throw new Error(`Unexpected request: ${requestType}`);
6660
6142
  });
6661
- });
6662
-
6663
- test('rejects a non-CRATE manual boot resources package', async () => {
6664
- const method = new FirmwareUpdateV4({
6665
- id: 1,
6666
- payload: {
6667
- method: 'firmwareUpdateV4',
6668
- platform: 'web',
6669
- bootResourcesBinary: buildBootResourcesHeader({ type: 'RESC' }).buffer,
6670
- },
6143
+ (method as any).device = stubDevice({
6144
+ getCommands: () => ({ typedCall }),
6671
6145
  });
6672
- method.init();
6673
6146
 
6674
- await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
6675
- 'Invalid Pro2 boot resources CRATE header'
6676
- );
6147
+ await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
+ expect(readChunkLengths.length).toBeGreaterThan(1);
6149
+ expect(Math.max(...readChunkLengths)).toBe(900);
6677
6150
  });
6678
6151
  });
6679
6152
 
@@ -6800,245 +6273,326 @@ describe('Protocol V2 protected method execution', () => {
6800
6273
  });
6801
6274
  });
6802
6275
 
6803
- test.each([
6804
- ['deviceInfoGet', DeviceInfoGet],
6805
- ['deviceStatusGet', DeviceStatusGet],
6806
- ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6807
- const method = new MethodClass({
6808
- id: 1,
6809
- payload: { method: _name },
6810
- } as any);
6811
- method.init();
6276
+ test.each([
6277
+ ['deviceInfoGet', DeviceInfoGet],
6278
+ ['deviceStatusGet', DeviceStatusGet],
6279
+ ])('does not auto-unlock the read-only %s method', (_name, MethodClass) => {
6280
+ const method = new MethodClass({
6281
+ id: 1,
6282
+ payload: { method: _name },
6283
+ } as any);
6284
+ method.init();
6285
+
6286
+ expect(method.unlockPolicy).toBe('none');
6287
+ });
6288
+
6289
+ test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6290
+ const calls: string[] = [];
6291
+ const method = {
6292
+ name: 'testBusinessMethod',
6293
+ unlockPolicy: 'retry-on-locked',
6294
+ protocolV2UiInteraction: { reason: 'settings-page' },
6295
+ run: jest
6296
+ .fn()
6297
+ .mockImplementationOnce(() => {
6298
+ calls.push('run-1');
6299
+ return Promise.reject(deviceLockedError());
6300
+ })
6301
+ .mockImplementationOnce(() => {
6302
+ calls.push('run-2');
6303
+ return Promise.resolve({ message: 'ok' });
6304
+ }),
6305
+ };
6306
+ const device = {
6307
+ isProtocolV2: () => true,
6308
+ unlockDevice: jest.fn(() => {
6309
+ calls.push('unlock');
6310
+ return Promise.resolve();
6311
+ }),
6312
+ };
6313
+ const uiCoordinator = {
6314
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6317
+ };
6318
+
6319
+ await expect(
6320
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6321
+ ).resolves.toEqual({ message: 'ok' });
6322
+ expect(calls).toEqual([
6323
+ 'method-prompt',
6324
+ 'run-1',
6325
+ 'unlock-prompt',
6326
+ 'unlock',
6327
+ 'method-prompt',
6328
+ 'run-2',
6329
+ ]);
6330
+ });
6331
+
6332
+ test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6333
+ const calls: string[] = [];
6334
+ const method = {
6335
+ name: 'evmSignMessage',
6336
+ payload: { passphraseState: 'hidden-state' },
6337
+ useDevicePassphraseState: true,
6338
+ unlockPolicy: 'retry-on-locked',
6339
+ run: jest
6340
+ .fn()
6341
+ .mockImplementationOnce(() => {
6342
+ calls.push('run-1');
6343
+ return Promise.reject(deviceLockedError());
6344
+ })
6345
+ .mockImplementationOnce(() => {
6346
+ calls.push('run-2');
6347
+ return Promise.resolve({ message: 'ok' });
6348
+ }),
6349
+ };
6350
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6351
+ if (requestType === 'ProtocolInfoRequest') {
6352
+ calls.push('negotiate-session');
6353
+ return Promise.resolve({ message: { version: 2 } });
6354
+ }
6355
+ if (requestType === 'DeviceSessionGet') {
6356
+ calls.push('resume-hidden-session');
6357
+ expect(request).toEqual({ session_id: 'hidden-session' });
6358
+ return Promise.resolve({
6359
+ message: {
6360
+ session_id: 'hidden-session',
6361
+ btc_test_address: 'hidden-state',
6362
+ },
6363
+ });
6364
+ }
6365
+ throw new Error(`Unexpected request: ${requestType}`);
6366
+ });
6367
+ const device = {
6368
+ features: { unlocked: true, passphraseProtection: true },
6369
+ passphraseState: 'hidden-state',
6370
+ commands: { typedCall },
6371
+ isProtocolV2: () => true,
6372
+ unlockDevice: jest.fn(() => {
6373
+ calls.push('unlock');
6374
+ return Promise.resolve();
6375
+ }),
6376
+ getCurrentPassphraseProtection: () => true,
6377
+ getInternalState: () => 'hidden-session',
6378
+ clearInternalState: jest.fn(),
6379
+ getCurrentDeviceId: () => 'wallet-device-id',
6380
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6381
+ };
6382
+
6383
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6384
+ message: 'ok',
6385
+ });
6386
+ expect(calls).toEqual([
6387
+ 'run-1',
6388
+ 'unlock',
6389
+ 'negotiate-session',
6390
+ 'resume-hidden-session',
6391
+ 'validate-hidden-session',
6392
+ 'run-2',
6393
+ ]);
6394
+ });
6395
+
6396
+ test('does not replay a business method when the hidden-wallet session cannot be restored after unlock', async () => {
6397
+ const initialError = deviceLockedError();
6398
+ const restoreError = new Error('Invalid wallet session');
6399
+ const method = {
6400
+ name: 'evmSignMessage',
6401
+ payload: { passphraseState: 'hidden-state' },
6402
+ useDevicePassphraseState: true,
6403
+ unlockPolicy: 'retry-on-locked',
6404
+ run: jest.fn().mockRejectedValueOnce(initialError),
6405
+ };
6406
+ const typedCall = jest.fn((requestType: string) => {
6407
+ if (requestType === 'ProtocolInfoRequest') {
6408
+ return Promise.resolve({ message: { version: 2 } });
6409
+ }
6410
+ if (requestType === 'DeviceSessionGet') {
6411
+ return Promise.reject(restoreError);
6412
+ }
6413
+ throw new Error(`Unexpected request: ${requestType}`);
6414
+ });
6415
+ const device = {
6416
+ features: { unlocked: true, passphraseProtection: true },
6417
+ passphraseState: 'hidden-state',
6418
+ commands: { typedCall },
6419
+ isProtocolV2: () => true,
6420
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6421
+ getCurrentPassphraseProtection: () => true,
6422
+ getInternalState: () => 'hidden-session',
6423
+ clearInternalState: jest.fn(),
6424
+ getCurrentDeviceId: () => 'wallet-device-id',
6425
+ updateInternalState: jest.fn(),
6426
+ };
6812
6427
 
6813
- expect(method.unlockPolicy).toBe('none');
6428
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(restoreError);
6429
+ expect(method.run).toHaveBeenCalledTimes(1);
6430
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6431
+ expect(device.clearInternalState).toHaveBeenCalledTimes(1);
6432
+ expect(device.updateInternalState).not.toHaveBeenCalled();
6814
6433
  });
6815
6434
 
6816
- test('checks fresh status for a wallet business method without a name allowlist', async () => {
6435
+ test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6817
6436
  const calls: string[] = [];
6818
- const features = { unlocked: true };
6819
6437
  const method = {
6820
- name: 'confluxSignMessageCIP23',
6821
- unlockPolicy: 'none',
6438
+ name: 'evmSignMessage',
6439
+ payload: { passphraseState: 'hidden-state' },
6822
6440
  useDevicePassphraseState: true,
6823
- protocolV2UiInteraction: { reason: 'address-confirmation' },
6441
+ unlockPolicy: 'retry-on-locked',
6824
6442
  run: jest.fn(() => {
6825
6443
  calls.push('run');
6826
- return Promise.resolve({ address: '0x1' });
6444
+ return Promise.resolve({ message: 'ok' });
6827
6445
  }),
6828
6446
  };
6447
+ const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
6448
+ if (requestType === 'ProtocolInfoRequest') {
6449
+ calls.push('negotiate-session');
6450
+ return Promise.resolve({ message: { version: 2 } });
6451
+ }
6452
+ if (requestType === 'DeviceSessionGet') {
6453
+ calls.push('resume-hidden-session');
6454
+ expect(request).toEqual({ session_id: 'hidden-session' });
6455
+ return Promise.resolve({
6456
+ message: {
6457
+ session_id: 'hidden-session',
6458
+ btc_test_address: 'hidden-state',
6459
+ },
6460
+ });
6461
+ }
6462
+ throw new Error(`Unexpected request: ${requestType}`);
6463
+ });
6464
+ const features = {
6465
+ unlocked: false,
6466
+ unlockedAttachPin: true,
6467
+ passphraseProtection: true,
6468
+ };
6829
6469
  const device = {
6830
6470
  features,
6831
- commands: {
6832
- typedCall: jest.fn(() => {
6833
- calls.push('status');
6834
- return Promise.resolve({ message: { unlocked: true } });
6835
- }),
6836
- },
6471
+ passphraseState: 'hidden-state',
6472
+ commands: { typedCall },
6837
6473
  isProtocolV2: () => true,
6838
- isBootloader: () => false,
6839
- isRomloader: () => false,
6840
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6841
- features.unlocked = status.unlocked === true;
6842
- return features;
6474
+ unlockDevice: jest.fn(() => {
6475
+ calls.push('unlock-main');
6476
+ features.unlocked = true;
6477
+ features.unlockedAttachPin = false;
6478
+ return Promise.resolve();
6843
6479
  }),
6844
- unlockDevice: jest.fn(),
6845
- };
6846
- const uiCoordinator = {
6847
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6848
- enterUnlockInteraction: jest.fn(),
6480
+ getCurrentPassphraseProtection: () => true,
6481
+ getInternalState: () => 'hidden-session',
6482
+ clearInternalState: jest.fn(),
6483
+ getCurrentDeviceId: () => 'wallet-device-id',
6484
+ updateInternalState: jest.fn(() => calls.push('validate-hidden-session')),
6849
6485
  };
6850
6486
 
6851
- await expect(
6852
- runMethodWithUnlockPolicy(method as any, device as any, {
6853
- uiCoordinator: uiCoordinator as any,
6854
- })
6855
- ).resolves.toEqual({ address: '0x1' });
6856
-
6857
- expect(calls).toEqual(['status', 'method-prompt', 'run']);
6858
- expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6859
- expect(device.unlockDevice).not.toHaveBeenCalled();
6860
- expect(method.run).toHaveBeenCalledTimes(1);
6487
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
+ message: 'ok',
6489
+ });
6490
+ expect(device.unlockDevice).toHaveBeenCalledWith();
6491
+ expect(calls).toEqual([
6492
+ 'unlock-main',
6493
+ 'negotiate-session',
6494
+ 'resume-hidden-session',
6495
+ 'validate-hidden-session',
6496
+ 'run',
6497
+ ]);
6861
6498
  });
6862
6499
 
6863
- test('validates the fresh device identity before starting unlock', async () => {
6864
- const calls: string[] = [];
6865
- const identityError = new Error('Unexpected device');
6500
+ test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6866
6501
  const method = {
6867
- name: 'evmSignMessage',
6868
- unlockPolicy: 'none',
6502
+ name: 'evmGetAddress',
6503
+ payload: { useEmptyPassphrase: true },
6869
6504
  useDevicePassphraseState: true,
6870
- run: jest.fn(),
6505
+ unlockPolicy: 'retry-on-locked',
6506
+ run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6871
6507
  };
6872
- const features = { unlocked: false };
6873
6508
  const device = {
6874
- features,
6875
- commands: {
6876
- typedCall: jest.fn(() => {
6877
- calls.push('status');
6878
- return Promise.resolve({ message: { unlocked: false } });
6879
- }),
6880
- },
6509
+ features: { unlocked: false },
6510
+ passphraseState: 'stale-hidden-state',
6881
6511
  isProtocolV2: () => true,
6882
- isBootloader: () => false,
6883
- isRomloader: () => false,
6884
- updateProtocolV2Status: jest.fn(() => features),
6885
- unlockDevice: jest.fn(),
6512
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6886
6513
  };
6887
6514
 
6888
- await expect(
6889
- runMethodWithUnlockPolicy(method as any, device as any, {
6890
- afterStatusBeforeUnlock: () => {
6891
- calls.push('identity');
6892
- throw identityError;
6893
- },
6894
- })
6895
- ).rejects.toBe(identityError);
6896
-
6897
- expect(calls).toEqual(['status', 'identity']);
6898
- expect(device.unlockDevice).not.toHaveBeenCalled();
6899
- expect(method.run).not.toHaveBeenCalled();
6515
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
+ address: 'standard-wallet-address',
6517
+ });
6518
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
+ expect(method.run).toHaveBeenCalledTimes(1);
6900
6520
  });
6901
6521
 
6902
- test('unlocks before business and never replays the callback', async () => {
6522
+ test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6903
6523
  const calls: string[] = [];
6904
- const features = { unlocked: true };
6905
6524
  const method = {
6906
- name: 'evmSignMessage',
6525
+ name: 'deviceSettingsPageShow',
6907
6526
  unlockPolicy: 'unlock-before-run',
6908
- protocolV2UiInteraction: { reason: 'signing-confirmation' },
6527
+ protocolV2UiInteraction: { reason: 'settings-page' },
6909
6528
  run: jest.fn(() => {
6910
6529
  calls.push('run');
6911
- return Promise.resolve({ signature: 'signed' });
6530
+ return Promise.resolve({ message: 'ok' });
6912
6531
  }),
6913
6532
  };
6533
+ const features = { unlocked: true };
6914
6534
  const device = {
6915
6535
  features,
6916
6536
  commands: {
6917
6537
  typedCall: jest.fn(() => {
6918
- calls.push('status');
6538
+ calls.push('refresh-status');
6919
6539
  return Promise.resolve({ message: { unlocked: false } });
6920
6540
  }),
6921
6541
  },
6922
6542
  isProtocolV2: () => true,
6923
- isBootloader: () => false,
6924
- isRomloader: () => false,
6925
6543
  updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6926
- features.unlocked = status.unlocked === true;
6544
+ features.unlocked = status.unlocked ?? features.unlocked;
6927
6545
  return features;
6928
6546
  }),
6929
6547
  unlockDevice: jest.fn(() => {
6930
6548
  calls.push('unlock');
6931
6549
  features.unlocked = true;
6932
- return Promise.resolve(features);
6550
+ return Promise.resolve();
6933
6551
  }),
6934
6552
  };
6935
6553
  const uiCoordinator = {
6936
6554
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6937
- enterUnlockInteraction: jest.fn(() => {
6938
- calls.push('unlock-prompt');
6939
- return undefined;
6940
- }),
6555
+ enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
+ resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6941
6557
  };
6942
6558
 
6943
6559
  await expect(
6944
- runMethodWithUnlockPolicy(method as any, device as any, {
6945
- uiCoordinator: uiCoordinator as any,
6946
- prepare: () => {
6947
- calls.push('wallet-session');
6948
- return Promise.resolve();
6949
- },
6950
- })
6951
- ).resolves.toEqual({ signature: 'signed' });
6952
-
6953
- expect(calls).toEqual([
6954
- 'status',
6955
- 'unlock-prompt',
6956
- 'unlock',
6957
- 'wallet-session',
6958
- 'method-prompt',
6959
- 'run',
6960
- ]);
6961
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6560
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6561
+ ).resolves.toEqual({ message: 'ok' });
6562
+ expect(calls).toEqual(['refresh-status', 'unlock-prompt', 'unlock', 'method-prompt', 'run']);
6563
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6962
6564
  expect(method.run).toHaveBeenCalledTimes(1);
6565
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6963
6567
  });
6964
6568
 
6965
- test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6966
- const method = {
6967
- name: 'evmGetAddress',
6968
- unlockPolicy: 'unlock-before-run',
6969
- run: jest.fn(),
6970
- };
6971
- const device = {
6972
- features: { unlocked: undefined },
6973
- commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6974
- isProtocolV2: () => true,
6975
- isBootloader: () => false,
6976
- isRomloader: () => false,
6977
- updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6978
- unlockDevice: jest.fn(),
6979
- };
6980
-
6981
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6982
- errorCode: HardwareErrorCode.RuntimeError,
6983
- });
6984
- expect(device.unlockDevice).not.toHaveBeenCalled();
6985
- expect(method.run).not.toHaveBeenCalled();
6986
- });
6987
-
6988
- test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6989
- const error = deviceLockedError();
6569
+ test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
+ const features = { unlocked: false };
6990
6571
  const method = {
6991
- name: 'evmSignMessage',
6572
+ name: 'deviceSettings',
6992
6573
  unlockPolicy: 'unlock-before-run',
6993
- run: jest.fn().mockRejectedValue(error),
6574
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6994
6575
  };
6995
- const features = { unlocked: true };
6996
6576
  const device = {
6997
6577
  features,
6998
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6578
+ commands: {
6579
+ typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
+ },
6999
6581
  isProtocolV2: () => true,
7000
- isBootloader: () => false,
7001
- isRomloader: () => false,
7002
- updateProtocolV2Status: jest.fn(() => features),
6582
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
+ features.unlocked = status.unlocked ?? features.unlocked;
6584
+ return features;
6585
+ }),
7003
6586
  unlockDevice: jest.fn(),
7004
6587
  };
7005
6588
 
7006
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
6589
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
+ message: 'ok',
6591
+ });
7007
6592
  expect(device.unlockDevice).not.toHaveBeenCalled();
7008
6593
  expect(method.run).toHaveBeenCalledTimes(1);
7009
6594
  });
7010
6595
 
7011
- test('reuses one lightweight preflight context across nested protected methods', async () => {
7012
- const features = { unlocked: true };
7013
- const context = createProtocolV2UnlockContext();
7014
- const firstMethod = {
7015
- name: 'allNetworkGetAddress',
7016
- unlockPolicy: 'unlock-before-run',
7017
- run: jest.fn().mockResolvedValue({ root: true }),
7018
- };
7019
- const nestedMethod = {
7020
- name: 'evmGetAddress',
7021
- unlockPolicy: 'unlock-before-run',
7022
- run: jest.fn().mockResolvedValue({ address: '0x1' }),
7023
- };
7024
- const device = {
7025
- features,
7026
- commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
7027
- isProtocolV2: () => true,
7028
- isBootloader: () => false,
7029
- isRomloader: () => false,
7030
- updateProtocolV2Status: jest.fn(() => features),
7031
- unlockDevice: jest.fn(),
7032
- };
7033
-
7034
- await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7035
- await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7036
-
7037
- expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7038
- expect(firstMethod.run).toHaveBeenCalledTimes(1);
7039
- expect(nestedMethod.run).toHaveBeenCalledTimes(1);
7040
- });
7041
-
7042
6596
  test.each(['bootloader', 'romloader'] as const)(
7043
6597
  'skips DeviceStatus and unlock in Protocol V2 %s mode',
7044
6598
  async mode => {
@@ -7056,7 +6610,7 @@ describe('Protocol V2 protected method execution', () => {
7056
6610
  unlockDevice: jest.fn(),
7057
6611
  };
7058
6612
 
7059
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6613
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7060
6614
  message: 'updating',
7061
6615
  });
7062
6616
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -7065,30 +6619,157 @@ describe('Protocol V2 protected method execution', () => {
7065
6619
  }
7066
6620
  );
7067
6621
 
7068
- test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6622
+ test('infers a signing interaction before running a Protocol V2 business method', async () => {
6623
+ const method = {
6624
+ name: 'evmSignMessage',
6625
+ params: { message: 'not-exposed-in-event' },
6626
+ payload: {},
6627
+ unlockPolicy: 'retry-on-locked',
6628
+ run: jest.fn().mockResolvedValue({ signature: 'test' }),
6629
+ };
6630
+ const device = {
6631
+ isProtocolV2: () => true,
6632
+ unlockDevice: jest.fn(),
6633
+ };
6634
+ const uiCoordinator = {
6635
+ enterMethodInteraction: jest.fn(),
6636
+ enterUnlockInteraction: jest.fn(),
6637
+ resumeMethodInteraction: jest.fn(),
6638
+ };
6639
+
6640
+ await runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any);
6641
+
6642
+ expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledWith({
6643
+ request: 'button',
6644
+ source: 'method-lifecycle',
6645
+ reason: 'signing-confirmation',
6646
+ completion: 'operation-completed',
6647
+ deviceOnly: true,
6648
+ operation: 'evmSignMessage',
6649
+ });
6650
+ });
6651
+
6652
+ test('does not unlock a Protocol V1 device or a method without the policy', async () => {
7069
6653
  for (const [isProtocolV2, unlockPolicy] of [
7070
- [false, 'unlock-before-run'],
6654
+ [false, 'retry-on-locked'],
7071
6655
  [true, 'none'],
7072
6656
  ] as const) {
6657
+ const error = deviceLockedError();
7073
6658
  const method = {
7074
6659
  unlockPolicy,
7075
- useDevicePassphraseState: false,
7076
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
6660
+ run: jest.fn().mockRejectedValue(error),
7077
6661
  };
7078
6662
  const device = {
7079
- commands: { typedCall: jest.fn() },
7080
6663
  isProtocolV2: () => isProtocolV2,
7081
6664
  unlockDevice: jest.fn(),
7082
6665
  };
7083
6666
 
7084
- await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7085
- message: 'ok',
7086
- });
7087
- expect(device.commands.typedCall).not.toHaveBeenCalled();
6667
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7088
6668
  expect(device.unlockDevice).not.toHaveBeenCalled();
7089
6669
  expect(method.run).toHaveBeenCalledTimes(1);
7090
6670
  }
7091
6671
  });
6672
+
6673
+ test('runs lock-free eventless methods without unlocking or synthesized UI', async () => {
6674
+ const method = {
6675
+ name: 'uploadPortfolio',
6676
+ unlockPolicy: 'none',
6677
+ protocolV2UiMode: 'none',
6678
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6679
+ };
6680
+ const device = {
6681
+ features: { unlocked: false },
6682
+ isProtocolV2: () => true,
6683
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6684
+ };
6685
+ const uiCoordinator = {
6686
+ enterMethodInteraction: jest.fn(),
6687
+ enterUnlockInteraction: jest.fn(),
6688
+ resumeMethodInteraction: jest.fn(),
6689
+ };
6690
+
6691
+ await expect(
6692
+ runMethodWithUnlockRetry(method as any, device as any, uiCoordinator as any)
6693
+ ).resolves.toEqual({ message: 'ok' });
6694
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6695
+ expect(uiCoordinator.enterMethodInteraction).not.toHaveBeenCalled();
6696
+ expect(uiCoordinator.enterUnlockInteraction).not.toHaveBeenCalled();
6697
+ expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6698
+ });
6699
+
6700
+ test('does not unlock or replay a lock-free state-changing method after a locked response', async () => {
6701
+ const error = deviceLockedError();
6702
+ const method = {
6703
+ name: 'uploadPortfolio',
6704
+ unlockPolicy: 'none',
6705
+ run: jest.fn().mockRejectedValue(error),
6706
+ };
6707
+ const device = {
6708
+ features: { unlocked: true },
6709
+ isProtocolV2: () => true,
6710
+ unlockDevice: jest.fn(),
6711
+ };
6712
+
6713
+ await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
6714
+ expect(method.run).toHaveBeenCalledTimes(1);
6715
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6716
+ });
6717
+
6718
+ test('does not retry when unlock fails or when the retry is still locked', async () => {
6719
+ const initialError = deviceLockedError();
6720
+ const unlockError = new Error('PIN cancelled');
6721
+ const unlockFailMethod = {
6722
+ unlockPolicy: 'retry-on-locked',
6723
+ run: jest.fn().mockRejectedValue(initialError),
6724
+ };
6725
+ const unlockFailDevice = {
6726
+ isProtocolV2: () => true,
6727
+ unlockDevice: jest.fn().mockRejectedValue(unlockError),
6728
+ };
6729
+ const unlockFailCoordinator = {
6730
+ enterMethodInteraction: jest.fn(),
6731
+ enterUnlockInteraction: jest.fn(),
6732
+ resumeMethodInteraction: jest.fn(),
6733
+ };
6734
+
6735
+ await expect(
6736
+ runMethodWithUnlockRetry(
6737
+ unlockFailMethod as any,
6738
+ unlockFailDevice as any,
6739
+ unlockFailCoordinator as any
6740
+ )
6741
+ ).rejects.toBe(unlockError);
6742
+ expect(unlockFailMethod.run).toHaveBeenCalledTimes(1);
6743
+ expect(unlockFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6744
+ expect(unlockFailCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6745
+
6746
+ const retryError = deviceLockedError();
6747
+ const retryFailMethod = {
6748
+ unlockPolicy: 'retry-on-locked',
6749
+ run: jest.fn().mockRejectedValueOnce(initialError).mockRejectedValueOnce(retryError),
6750
+ };
6751
+ const retryFailDevice = {
6752
+ isProtocolV2: () => true,
6753
+ unlockDevice: jest.fn().mockResolvedValue(undefined),
6754
+ };
6755
+ const retryFailCoordinator = {
6756
+ enterMethodInteraction: jest.fn(),
6757
+ enterUnlockInteraction: jest.fn(),
6758
+ resumeMethodInteraction: jest.fn(),
6759
+ };
6760
+
6761
+ await expect(
6762
+ runMethodWithUnlockRetry(
6763
+ retryFailMethod as any,
6764
+ retryFailDevice as any,
6765
+ retryFailCoordinator as any
6766
+ )
6767
+ ).rejects.toBe(retryError);
6768
+ expect(retryFailMethod.run).toHaveBeenCalledTimes(2);
6769
+ expect(retryFailDevice.unlockDevice).toHaveBeenCalledTimes(1);
6770
+ expect(retryFailCoordinator.enterUnlockInteraction).toHaveBeenCalledTimes(1);
6771
+ expect(retryFailCoordinator.resumeMethodInteraction).toHaveBeenCalledTimes(1);
6772
+ });
7092
6773
  });
7093
6774
 
7094
6775
  describe('Protocol V2 current low-level methods', () => {
@@ -7220,9 +6901,7 @@ describe('Protocol V2 current low-level methods', () => {
7220
6901
 
7221
6902
  await method.run();
7222
6903
 
7223
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7224
- eventless_wallet_session: true,
7225
- });
6904
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7226
6905
  });
7227
6906
 
7228
6907
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -7298,29 +6977,6 @@ describe('Protocol V2 current low-level methods', () => {
7298
6977
 
7299
6978
  await expect(method.run()).resolves.toBe(features);
7300
6979
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7301
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7302
- emitUiEvent: false,
7303
- });
7304
- });
7305
-
7306
- test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7307
- const method = new DeviceUnlock({
7308
- id: 1,
7309
- payload: {
7310
- method: 'deviceUnlock',
7311
- pinType: DeviceSessionPinType.Any,
7312
- },
7313
- });
7314
- method.init();
7315
-
7316
- const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7317
- (method as any).device = { unlockDevice };
7318
-
7319
- await method.run();
7320
-
7321
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7322
- emitUiEvent: false,
7323
- });
7324
6980
  });
7325
6981
 
7326
6982
  test.each([