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

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 (256) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +231 -0
  2. package/__tests__/DeviceCommands.test.ts +45 -0
  3. package/__tests__/DeviceEventRegistration.test.ts +6 -0
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +198 -3
  5. package/__tests__/connectSettings.test.ts +5 -47
  6. package/__tests__/device-lifecycle-events.test.ts +105 -3
  7. package/__tests__/deviceUploadNft.test.ts +293 -0
  8. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +31 -59
  9. package/__tests__/get-device-state.test.ts +132 -12
  10. package/__tests__/homescreen.test.ts +41 -1
  11. package/__tests__/kaspaSignTransaction.test.ts +3 -26
  12. package/__tests__/logBlockEvent.test.ts +1 -1
  13. package/__tests__/method-protocol-support.test.ts +19 -0
  14. package/__tests__/open-wallet-session.test.ts +568 -37
  15. package/__tests__/pro2Nft.test.ts +108 -0
  16. package/__tests__/protocol-binding.test.ts +89 -0
  17. package/__tests__/protocol-v2-resources.test.ts +286 -0
  18. package/__tests__/protocol-v2-ui-lifecycle.test.ts +91 -0
  19. package/__tests__/protocol-v2-unlock-policy.test.ts +201 -35
  20. package/__tests__/protocol-v2.test.ts +1350 -1012
  21. package/__tests__/protocolV2UiInteraction.test.ts +63 -0
  22. package/__tests__/public-pro2-api-boundary.test.ts +1 -0
  23. package/__tests__/search-devices.test.ts +12 -3
  24. package/__tests__/uiProgressThrottle.test.ts +74 -0
  25. package/__tests__/uiPromiseRegistry.test.ts +86 -0
  26. package/dist/api/BaseMethod.d.ts +3 -1
  27. package/dist/api/BaseMethod.d.ts.map +1 -1
  28. package/dist/api/CheckAllFirmwareRelease.d.ts +2 -1
  29. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  30. package/dist/api/DetectDeviceConnectProtocol.d.ts +7 -0
  31. package/dist/api/DetectDeviceConnectProtocol.d.ts.map +1 -0
  32. package/dist/api/FirmwareUpdate.d.ts.map +1 -1
  33. package/dist/api/FirmwareUpdateV2.d.ts +0 -11
  34. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  35. package/dist/api/FirmwareUpdateV3.d.ts +3 -7
  36. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  37. package/dist/api/FirmwareUpdateV4.d.ts +11 -25
  38. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  39. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  40. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  41. package/dist/api/SearchDevices.d.ts.map +1 -1
  42. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  43. package/dist/api/device/DeviceUnlock.d.ts +2 -2
  44. package/dist/api/device/DeviceUnlock.d.ts.map +1 -1
  45. package/dist/api/device/DeviceUpdateBootloader.d.ts +1 -5
  46. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  47. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +0 -9
  48. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  49. package/dist/api/firmware/getBinary.d.ts +0 -7
  50. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  51. package/dist/api/firmware/updateBootloader.d.ts +0 -2
  52. package/dist/api/firmware/updateBootloader.d.ts.map +1 -1
  53. package/dist/api/firmware/uploadFirmware.d.ts +1 -9
  54. package/dist/api/firmware/uploadFirmware.d.ts.map +1 -1
  55. package/dist/api/helpers/protocolV2FileWrite.d.ts +2 -0
  56. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  57. package/dist/api/index.d.ts +2 -0
  58. package/dist/api/index.d.ts.map +1 -1
  59. package/dist/api/kaspa/KaspaSignTransaction.d.ts.map +1 -1
  60. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +31 -0
  61. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -0
  62. package/dist/api/protocol-v2/ProtocolInfoRequest.d.ts.map +1 -1
  63. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  64. package/dist/api/stellar/StellarSignTransaction.d.ts +1 -1
  65. package/dist/core/deviceEventRegistration.d.ts +2 -0
  66. package/dist/core/deviceEventRegistration.d.ts.map +1 -1
  67. package/dist/core/index.d.ts.map +1 -1
  68. package/dist/core/uiPromiseRegistry.d.ts +4 -0
  69. package/dist/core/uiPromiseRegistry.d.ts.map +1 -0
  70. package/dist/data-manager/DataManager.d.ts +4 -2
  71. package/dist/data-manager/DataManager.d.ts.map +1 -1
  72. package/dist/data-manager/connectSettings.d.ts.map +1 -1
  73. package/dist/device/Device.d.ts +29 -6
  74. package/dist/device/Device.d.ts.map +1 -1
  75. package/dist/device/DeviceCommands.d.ts +4 -4
  76. package/dist/device/DeviceCommands.d.ts.map +1 -1
  77. package/dist/device/DevicePool.d.ts.map +1 -1
  78. package/dist/events/device.d.ts +4 -0
  79. package/dist/events/device.d.ts.map +1 -1
  80. package/dist/events/logBlockEvent.d.ts.map +1 -1
  81. package/dist/events/ui-promise.d.ts +4 -2
  82. package/dist/events/ui-promise.d.ts.map +1 -1
  83. package/dist/events/ui-request.d.ts +31 -2
  84. package/dist/events/ui-request.d.ts.map +1 -1
  85. package/dist/events/ui-response.d.ts +10 -2
  86. package/dist/events/ui-response.d.ts.map +1 -1
  87. package/dist/index.d.ts +214 -231
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +2616 -3527
  90. package/dist/inject.d.ts +6 -1
  91. package/dist/inject.d.ts.map +1 -1
  92. package/dist/lowLevelInject.d.ts.map +1 -1
  93. package/dist/protocols/protocol-v2/features.d.ts +0 -4
  94. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  95. package/dist/protocols/protocol-v2/resources.d.ts +30 -0
  96. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -0
  97. package/dist/protocols/protocol-v2/uiInteraction.d.ts +7 -4
  98. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  99. package/dist/protocols/protocol-v2/unlockPolicy.d.ts +1 -3
  100. package/dist/protocols/protocol-v2/unlockPolicy.d.ts.map +1 -1
  101. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +22 -0
  102. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -0
  103. package/dist/protocols/protocol-v2/walletSession.d.ts +6 -1
  104. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  105. package/dist/topLevelInject.d.ts.map +1 -1
  106. package/dist/types/api/checkAllFirmwareRelease.d.ts +2 -4
  107. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  108. package/dist/types/api/detectDeviceConnectProtocol.d.ts +4 -0
  109. package/dist/types/api/detectDeviceConnectProtocol.d.ts.map +1 -0
  110. package/dist/types/api/deviceUnlock.d.ts +5 -1
  111. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  112. package/dist/types/api/deviceUpdateBootloader.d.ts +0 -6
  113. package/dist/types/api/deviceUpdateBootloader.d.ts.map +1 -1
  114. package/dist/types/api/export.d.ts +2 -4
  115. package/dist/types/api/export.d.ts.map +1 -1
  116. package/dist/types/api/firmwareUpdate.d.ts +2 -70
  117. package/dist/types/api/firmwareUpdate.d.ts.map +1 -1
  118. package/dist/types/api/index.d.ts +7 -10
  119. package/dist/types/api/index.d.ts.map +1 -1
  120. package/dist/types/api/protocolV2.d.ts +3 -0
  121. package/dist/types/api/protocolV2.d.ts.map +1 -1
  122. package/dist/types/device.d.ts.map +1 -1
  123. package/dist/types/params.d.ts +1 -0
  124. package/dist/types/params.d.ts.map +1 -1
  125. package/dist/types/settings.d.ts +22 -13
  126. package/dist/types/settings.d.ts.map +1 -1
  127. package/dist/utils/deviceFeaturesCompat.d.ts +0 -1
  128. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  129. package/dist/utils/deviceFeaturesUtils.d.ts +4 -0
  130. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  131. package/dist/utils/homescreen.d.ts +4 -0
  132. package/dist/utils/homescreen.d.ts.map +1 -1
  133. package/dist/utils/index.d.ts +1 -1
  134. package/dist/utils/index.d.ts.map +1 -1
  135. package/dist/utils/patch.d.ts +1 -1
  136. package/dist/utils/patch.d.ts.map +1 -1
  137. package/dist/utils/pro2Nft.d.ts +31 -0
  138. package/dist/utils/pro2Nft.d.ts.map +1 -0
  139. package/dist/utils/pro2Wallpaper.d.ts +10 -0
  140. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  141. package/dist/utils/uiProgressThrottle.d.ts +3 -0
  142. package/dist/utils/uiProgressThrottle.d.ts.map +1 -0
  143. package/package.json +4 -4
  144. package/src/api/BaseMethod.ts +8 -10
  145. package/src/api/CheckAllFirmwareRelease.ts +54 -57
  146. package/src/api/DetectDeviceConnectProtocol.ts +18 -0
  147. package/src/api/FirmwareUpdate.ts +7 -15
  148. package/src/api/FirmwareUpdateV2.ts +126 -316
  149. package/src/api/FirmwareUpdateV3.ts +63 -265
  150. package/src/api/FirmwareUpdateV4.ts +569 -965
  151. package/src/api/GetPassphraseState.ts +7 -1
  152. package/src/api/OpenWalletSession.ts +70 -17
  153. package/src/api/SearchDevices.ts +3 -1
  154. package/src/api/allnetwork/AllNetworkGetAddress.ts +21 -21
  155. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +34 -11
  156. package/src/api/device/DeviceUnlock.ts +8 -3
  157. package/src/api/device/DeviceUpdateBootloader.ts +6 -122
  158. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +16 -83
  159. package/src/api/firmware/updateBootloader.ts +4 -19
  160. package/src/api/firmware/uploadFirmware.ts +39 -144
  161. package/src/api/helpers/protocolV2FileWrite.ts +11 -4
  162. package/src/api/index.ts +2 -0
  163. package/src/api/kaspa/KaspaSignTransaction.ts +1 -12
  164. package/src/api/protocol-v2/DeviceUploadNft.ts +189 -0
  165. package/src/api/protocol-v2/ProtocolInfoRequest.ts +3 -1
  166. package/src/api/protocol-v2/helpers.ts +1 -0
  167. package/src/core/deviceEventRegistration.ts +4 -0
  168. package/src/core/index.ts +167 -83
  169. package/src/core/uiPromiseRegistry.ts +41 -0
  170. package/src/data/messages/messages-protocol-v2.json +25 -0
  171. package/src/data-manager/DataManager.ts +71 -39
  172. package/src/data-manager/connectSettings.ts +0 -11
  173. package/src/device/Device.ts +259 -43
  174. package/src/device/DeviceCommands.ts +7 -1
  175. package/src/device/DevicePool.ts +7 -2
  176. package/src/events/device.ts +4 -0
  177. package/src/events/logBlockEvent.ts +1 -0
  178. package/src/events/ui-promise.ts +4 -2
  179. package/src/events/ui-request.ts +36 -2
  180. package/src/events/ui-response.ts +12 -2
  181. package/src/index.ts +0 -5
  182. package/src/inject.ts +60 -24
  183. package/src/lowLevelInject.ts +7 -8
  184. package/src/protocols/protocol-v2/features.ts +10 -7
  185. package/src/protocols/protocol-v2/resources.ts +359 -0
  186. package/src/protocols/protocol-v2/uiInteraction.ts +46 -6
  187. package/src/protocols/protocol-v2/unlockPolicy.ts +1 -105
  188. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +118 -0
  189. package/src/protocols/protocol-v2/walletSession.ts +214 -36
  190. package/src/topLevelInject.ts +7 -8
  191. package/src/types/api/checkAllFirmwareRelease.ts +2 -4
  192. package/src/types/api/detectDeviceConnectProtocol.ts +7 -0
  193. package/src/types/api/deviceUnlock.ts +12 -1
  194. package/src/types/api/deviceUpdateBootloader.ts +0 -6
  195. package/src/types/api/export.ts +1 -18
  196. package/src/types/api/firmwareUpdate.ts +3 -76
  197. package/src/types/api/index.ts +13 -14
  198. package/src/types/api/protocolV2.ts +13 -0
  199. package/src/types/device.ts +3 -0
  200. package/src/types/params.ts +7 -1
  201. package/src/types/settings.ts +42 -13
  202. package/src/utils/deviceFeaturesCompat.ts +0 -6
  203. package/src/utils/deviceFeaturesUtils.ts +8 -0
  204. package/src/utils/homescreen.ts +32 -6
  205. package/src/utils/index.ts +6 -1
  206. package/src/utils/pro2Nft.ts +142 -0
  207. package/src/utils/pro2Wallpaper.ts +35 -8
  208. package/src/utils/uiProgressThrottle.ts +63 -0
  209. package/__tests__/device-initialize-timeout.test.ts +0 -56
  210. package/__tests__/firmware-update/check-all-firmware-release.test.ts +0 -175
  211. package/__tests__/firmware-update/firmware-artifact-source.test.ts +0 -119
  212. package/__tests__/firmware-update/firmware-host-binding.test.ts +0 -70
  213. package/__tests__/firmware-update/firmware-preparation-error.test.ts +0 -27
  214. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +0 -200
  215. package/__tests__/firmware-update/firmware-update-capabilities.test.ts +0 -13
  216. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +0 -294
  217. package/__tests__/firmware-update/firmware-update-plan.test.ts +0 -593
  218. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +0 -213
  219. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +0 -58
  220. package/__tests__/firmware-update/firmware-upload-source.test.ts +0 -70
  221. package/dist/api/firmware/FirmwareArtifactSource.d.ts +0 -25
  222. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +0 -1
  223. package/dist/api/firmware/FirmwareHostBinding.d.ts +0 -6
  224. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +0 -1
  225. package/dist/api/firmware/FirmwarePreparationError.d.ts +0 -3
  226. package/dist/api/firmware/FirmwarePreparationError.d.ts.map +0 -1
  227. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts +0 -3
  228. package/dist/api/firmware/FirmwareUpdateCapabilities.d.ts.map +0 -1
  229. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -25
  230. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +0 -1
  231. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +0 -32
  232. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +0 -1
  233. package/dist/api/firmware/progressThrottle.d.ts +0 -3
  234. package/dist/api/firmware/progressThrottle.d.ts.map +0 -1
  235. package/dist/protocols/protocol-v2/lockedError.d.ts +0 -2
  236. package/dist/protocols/protocol-v2/lockedError.d.ts.map +0 -1
  237. package/dist/protocols/protocol-v2/unlockRetry.d.ts +0 -10
  238. package/dist/protocols/protocol-v2/unlockRetry.d.ts.map +0 -1
  239. package/dist/types/api/firmwareUpdateCapabilities.d.ts +0 -9
  240. package/dist/types/api/firmwareUpdateCapabilities.d.ts.map +0 -1
  241. package/dist/types/api/firmwareUpdatePlan.d.ts +0 -28
  242. package/dist/types/api/firmwareUpdatePlan.d.ts.map +0 -1
  243. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts +0 -42
  244. package/dist/types/api/firmwareUpdatePreparedPlan.d.ts.map +0 -1
  245. package/src/api/firmware/FirmwareArtifactSource.ts +0 -278
  246. package/src/api/firmware/FirmwareHostBinding.ts +0 -100
  247. package/src/api/firmware/FirmwarePreparationError.ts +0 -12
  248. package/src/api/firmware/FirmwareUpdateCapabilities.ts +0 -9
  249. package/src/api/firmware/FirmwareUpdatePlan.ts +0 -772
  250. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +0 -415
  251. package/src/api/firmware/progressThrottle.ts +0 -44
  252. package/src/protocols/protocol-v2/lockedError.ts +0 -10
  253. package/src/protocols/protocol-v2/unlockRetry.ts +0 -109
  254. package/src/types/api/firmwareUpdateCapabilities.ts +0 -9
  255. package/src/types/api/firmwareUpdatePlan.ts +0 -38
  256. package/src/types/api/firmwareUpdatePreparedPlan.ts +0 -53
@@ -1,10 +1,11 @@
1
- import { EFirmwareType, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
2
  import {
3
3
  DeviceRebootType,
4
4
  DeviceSessionPinType,
5
5
  DeviceSettingsPage,
6
6
  DeviceType,
7
7
  } from '@onekeyfe/hd-transport';
8
+ import { sha256 } from '@noble/hashes/sha256';
8
9
 
9
10
  import * as firmwareBinaryApi from '../src/api/firmware/getBinary';
10
11
  import DnxGetAddress from '../src/api/dynex/DnxGetAddress';
@@ -61,6 +62,7 @@ import { createCoreApi } from '../src/inject';
61
62
  import { Device, preloadSessionCache } from '../src/device/Device';
62
63
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
63
64
  import { DevicePool } from '../src/device/DevicePool';
65
+ import { DEVICE } from '../src/events';
64
66
  import { UI_REQUEST } from '../src/events/ui-request';
65
67
  import {
66
68
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
@@ -74,11 +76,15 @@ import {
74
76
  requestProtocolV2ProtocolInfo,
75
77
  supportsProtocolV2Message,
76
78
  } from '../src/protocols/protocol-v2/features';
79
+ import { PROTOCOL_V2_RESOURCE_DEVICE_PATHS } from '../src/protocols/protocol-v2/resources';
77
80
  import {
78
81
  getProtocolV2WalletSession,
79
82
  refreshProtocolV2DeviceStatus,
80
83
  } from '../src/protocols/protocol-v2/walletSession';
81
- import { runMethodWithUnlockRetry } from '../src/protocols/protocol-v2/unlockRetry';
84
+ import {
85
+ createProtocolV2UnlockContext,
86
+ runMethodWithUnlockPolicy,
87
+ } from '../src/protocols/protocol-v2/unlockPolicyRunner';
82
88
  import { BaseMethod } from '../src/api/BaseMethod';
83
89
  import {
84
90
  buildProtocolV1FeaturesPayload,
@@ -92,7 +98,6 @@ import {
92
98
  getMethodVersionRange,
93
99
  } from '../src/utils';
94
100
  import { getDeviceFirmwareVersion } from '../src/utils/deviceVersionUtils';
95
- import { openFirmwareByteSource } from '../src/api/firmware/FirmwareArtifactSource';
96
101
  import {
97
102
  getPassphraseState,
98
103
  getPassphraseStateWithRefreshDeviceInfo,
@@ -241,7 +246,7 @@ describe('UploadPortfolio', () => {
241
246
  (method as any).device = device;
242
247
 
243
248
  method.init();
244
- await runMethodWithUnlockRetry(method, device as any);
249
+ await runMethodWithUnlockPolicy(method, device as any);
245
250
 
246
251
  expect(unlockDevice).not.toHaveBeenCalled();
247
252
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -417,14 +422,16 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
417
422
  }
418
423
  return d.features;
419
424
  };
425
+ d.ensureProtocolV2RuntimeContext ??= (timeoutMs?: number) =>
426
+ requestProtocolV2ProtocolInfo({
427
+ commands: d.getCommands?.() ?? d.commands,
428
+ timeoutMs,
429
+ });
420
430
  d.probeProtocolV2RuntimeState ??= async (
421
431
  deviceInfo: ProtocolV2DeviceInfo,
422
432
  timeoutMs?: number
423
433
  ) => {
424
- const protocolInfo = await requestProtocolV2ProtocolInfo({
425
- commands: d.getCommands(),
426
- timeoutMs,
427
- });
434
+ const protocolInfo = await d.ensureProtocolV2RuntimeContext(timeoutMs);
428
435
  const runtimeMode = getProtocolV2RuntimeMode(protocolInfo);
429
436
  if (runtimeMode === 'bootloader' || runtimeMode === 'romloader') {
430
437
  return d.updateProtocolV2Features(deviceInfo, null, runtimeMode, protocolInfo);
@@ -781,6 +788,9 @@ describe('Protocol V2 feature adapter', () => {
781
788
  if (request === 'DeviceStatusGet') {
782
789
  return { type: 'DeviceStatus', message: { unlocked: true } };
783
790
  }
791
+ if (request === 'DeviceSessionAskPassphrase') {
792
+ return { type: 'Success', message: {} };
793
+ }
784
794
  if (request === 'DeviceSessionGet') {
785
795
  return {
786
796
  type: 'DeviceSession',
@@ -805,7 +815,7 @@ describe('Protocol V2 feature adapter', () => {
805
815
  ).resolves.toEqual({
806
816
  passphraseState: 'state-1',
807
817
  newSession: 'session-1',
808
- unlockedAttachPin: undefined,
818
+ unlockedAttachPin: false,
809
819
  resumed: false,
810
820
  });
811
821
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
@@ -814,7 +824,13 @@ describe('Protocol V2 feature adapter', () => {
814
824
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPin', 'Success', {
815
825
  type: DeviceSessionPinType.Main,
816
826
  });
817
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
827
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
828
+ passphrase: '',
829
+ on_device: false,
830
+ });
831
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
832
+ seed_domains: [],
833
+ });
818
834
  device.passphraseState = 'state-1';
819
835
  expect(device.getInternalState()).toBe('session-1');
820
836
  });
@@ -847,6 +863,7 @@ describe('Protocol V2 feature adapter', () => {
847
863
  },
848
864
  };
849
865
  }
866
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
850
867
  if (request === 'DeviceSessionGet') {
851
868
  return {
852
869
  message: {
@@ -863,9 +880,16 @@ describe('Protocol V2 feature adapter', () => {
863
880
  await getProtocolV2WalletSession(device, { onlyMainPin: true });
864
881
 
865
882
  expect(typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPin')).toHaveLength(1);
866
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
883
+ expect(
884
+ typedCall.mock.calls.filter(call => call[0] === 'DeviceSessionAskPassphrase')
885
+ ).toHaveLength(1);
886
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
887
+ seed_domains: [],
888
+ });
867
889
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
868
890
  session_id: 'standard-session',
891
+ btc_test_address: 'standard-state',
892
+ seed_domains: [],
869
893
  });
870
894
  expect(deviceWalletSessionStore.getStandard(deviceId)).toEqual({
871
895
  passphraseState: 'standard-state',
@@ -907,6 +931,7 @@ describe('Protocol V2 feature adapter', () => {
907
931
  },
908
932
  };
909
933
  }
934
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
910
935
  if (request === 'DeviceSessionAskPin') {
911
936
  return { type: 'Success', message: { message: 'PIN verified' } };
912
937
  }
@@ -1001,6 +1026,7 @@ describe('Protocol V2 feature adapter', () => {
1001
1026
  if (request === 'DeviceStatusGet') {
1002
1027
  return { message: { device_id: deviceId, unlocked: true, passphrase_enabled: true } };
1003
1028
  }
1029
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1004
1030
  if (request === 'DeviceSessionGet') {
1005
1031
  sessionGetCalls += 1;
1006
1032
  return {
@@ -1227,7 +1253,9 @@ describe('Protocol V2 feature adapter', () => {
1227
1253
  passphrase: 'host hidden wallet',
1228
1254
  on_device: false,
1229
1255
  });
1230
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
1256
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1257
+ seed_domains: [],
1258
+ });
1231
1259
  });
1232
1260
 
1233
1261
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -1335,6 +1363,8 @@ describe('Protocol V2 feature adapter', () => {
1335
1363
 
1336
1364
  expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1337
1365
  session_id: 'session-a',
1366
+ btc_test_address: 'state-a',
1367
+ seed_domains: [],
1338
1368
  });
1339
1369
  expect(device.getInternalState()).toBe('session-b');
1340
1370
  });
@@ -1402,7 +1432,15 @@ describe('Protocol V2 feature adapter', () => {
1402
1432
  });
1403
1433
  expect(typedCall.mock.calls).toEqual([
1404
1434
  ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
1405
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1435
+ [
1436
+ 'DeviceSessionGet',
1437
+ 'DeviceSession',
1438
+ {
1439
+ session_id: 'session-a',
1440
+ btc_test_address: 'state-a',
1441
+ seed_domains: [],
1442
+ },
1443
+ ],
1406
1444
  ]);
1407
1445
  expect(promptPassphrase).not.toHaveBeenCalled();
1408
1446
  expect(device.getInternalState()).toBeUndefined();
@@ -1450,7 +1488,7 @@ describe('Protocol V2 feature adapter', () => {
1450
1488
  { passphrase: 'host hidden wallet', on_device: false },
1451
1489
  ],
1452
1490
  ['DeviceStatusGet', 'DeviceStatus', {}],
1453
- ['DeviceSessionGet', 'DeviceSession', {}],
1491
+ ['DeviceSessionGet', 'DeviceSession', { seed_domains: [] }],
1454
1492
  ]);
1455
1493
  });
1456
1494
 
@@ -1553,6 +1591,73 @@ describe('Protocol V2 feature adapter', () => {
1553
1591
  expect(device.getInternalState()).toBeUndefined();
1554
1592
  });
1555
1593
 
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
+
1556
1661
  test('recovers an expired Attach PIN wallet through the unified wallet selector', async () => {
1557
1662
  const device = Device.fromDescriptor({
1558
1663
  id: 'cache-device-attach-recovery',
@@ -1609,7 +1714,17 @@ describe('Protocol V2 feature adapter', () => {
1609
1714
  }),
1610
1715
  { cancelDeviceOnReject: false }
1611
1716
  );
1612
- expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.AttachToPin);
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
+ );
1613
1728
  expect(device.getInternalState()).toBe('new-attach-session');
1614
1729
  });
1615
1730
 
@@ -1893,12 +2008,10 @@ describe('Protocol V2 feature adapter', () => {
1893
2008
  expect(getFeatures).not.toHaveBeenCalled();
1894
2009
  });
1895
2010
 
1896
- test('marks an interactive cache miss as a wallet-session recovery', async () => {
2011
+ test('uses the cached wallet address when the local session is missing', async () => {
1897
2012
  const typedCall = jest
1898
2013
  .fn()
1899
2014
  .mockResolvedValueOnce({ message: { version: 2 } })
1900
- .mockResolvedValueOnce({ message: { message: 'Passphrase prepared' } })
1901
- .mockResolvedValueOnce({ message: { unlocked: true, unlocked_attach_pin: false } })
1902
2015
  .mockResolvedValueOnce({
1903
2016
  message: {
1904
2017
  btc_test_address: 'expected-state',
@@ -1926,16 +2039,11 @@ describe('Protocol V2 feature adapter', () => {
1926
2039
  await expect(
1927
2040
  getProtocolV2WalletSession(device, { expectedPassphraseState: 'expected-state' })
1928
2041
  ).resolves.toMatchObject({ passphraseState: 'expected-state' });
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
- );
2042
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2043
+ btc_test_address: 'expected-state',
2044
+ seed_domains: [],
2045
+ });
2046
+ expect(promptPassphrase).not.toHaveBeenCalled();
1939
2047
  });
1940
2048
 
1941
2049
  test('reuses the Pro2 session opened by legacy getPassphraseState on the next App call', async () => {
@@ -2001,8 +2109,15 @@ describe('Protocol V2 feature adapter', () => {
2001
2109
  true
2002
2110
  );
2003
2111
  expect(typedCall.mock.calls).toEqual([
2004
- ['ProtocolInfoRequest', 'ProtocolInfo', { eventless_wallet_session: true }],
2005
- ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-pro2-app' }],
2112
+ [
2113
+ 'DeviceSessionGet',
2114
+ 'DeviceSession',
2115
+ {
2116
+ session_id: 'session-pro2-app',
2117
+ btc_test_address: 'state-pro2-app',
2118
+ seed_domains: [],
2119
+ },
2120
+ ],
2006
2121
  ]);
2007
2122
  });
2008
2123
 
@@ -2191,7 +2306,9 @@ describe('Protocol V2 feature adapter', () => {
2191
2306
  passphrase: 'host hidden wallet',
2192
2307
  on_device: false,
2193
2308
  });
2194
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
2309
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {
2310
+ seed_domains: [],
2311
+ });
2195
2312
  });
2196
2313
 
2197
2314
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -2269,12 +2386,83 @@ describe('Protocol V2 feature adapter', () => {
2269
2386
  );
2270
2387
 
2271
2388
  expect(device.getInternalState()).toBeUndefined();
2272
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceSessionAskPassphrase', 'Success', {
2389
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2390
+ btc_test_address: 'expected-state',
2391
+ seed_domains: [],
2392
+ });
2393
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionAskPassphrase', 'Success', {
2273
2394
  passphrase: 'host hidden wallet',
2274
2395
  on_device: false,
2275
2396
  });
2276
- expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2277
- expect(typedCall).toHaveBeenNthCalledWith(4, 'DeviceSessionGet', 'DeviceSession', {});
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);
2278
2466
  });
2279
2467
 
2280
2468
  test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
@@ -2307,10 +2495,22 @@ describe('Protocol V2 feature adapter', () => {
2307
2495
  await expect(
2308
2496
  device.checkPassphraseStateSafety('stale-hidden-state', true, false)
2309
2497
  ).resolves.toBe(true);
2310
- expect(typedCall).toHaveBeenCalledTimes(4);
2498
+ expect(typedCall).toHaveBeenCalledTimes(5);
2311
2499
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2312
2500
  eventless_wallet_session: true,
2313
2501
  });
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());
2314
2514
  });
2315
2515
 
2316
2516
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -2381,7 +2581,7 @@ describe('Protocol V2 feature adapter', () => {
2381
2581
  uuid: 'CACHED-SERIAL',
2382
2582
  deviceId: null,
2383
2583
  bleName: 'Cached BLE',
2384
- name: 'Cached Label',
2584
+ name: 'Cached BLE',
2385
2585
  label: 'Cached Label',
2386
2586
  deviceType: 'pro2',
2387
2587
  });
@@ -2408,7 +2608,7 @@ describe('Protocol V2 feature adapter', () => {
2408
2608
  expect(device.features?.label).toBeNull();
2409
2609
  });
2410
2610
 
2411
- test('prefers the real Protocol V2 label over the BLE name for display', () => {
2611
+ test('uses the BLE name for discovery while preserving the Protocol V2 label', () => {
2412
2612
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2413
2613
  (device as any).features = {
2414
2614
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
@@ -2416,9 +2616,10 @@ describe('Protocol V2 feature adapter', () => {
2416
2616
  bleName: 'Pro2 6136',
2417
2617
  };
2418
2618
 
2619
+ expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2419
2620
  expect(device.toMessageObject()).toMatchObject({
2420
2621
  bleName: 'Pro2 6136',
2421
- name: 'My Pro 2',
2622
+ name: 'Pro2 6136',
2422
2623
  label: 'My Pro 2',
2423
2624
  });
2424
2625
  });
@@ -2472,10 +2673,6 @@ describe('Protocol V2 feature adapter', () => {
2472
2673
  hw: true,
2473
2674
  fw: true,
2474
2675
  coprocessor: true,
2475
- se1: true,
2476
- se2: true,
2477
- se3: true,
2478
- se4: true,
2479
2676
  },
2480
2677
  types: {
2481
2678
  version: true,
@@ -2667,10 +2864,6 @@ describe('Protocol V2 feature adapter', () => {
2667
2864
  hw: true,
2668
2865
  fw: true,
2669
2866
  coprocessor: true,
2670
- se1: true,
2671
- se2: true,
2672
- se3: true,
2673
- se4: true,
2674
2867
  },
2675
2868
  types: {
2676
2869
  version: true,
@@ -2681,7 +2874,9 @@ describe('Protocol V2 feature adapter', () => {
2681
2874
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
2682
2875
  }
2683
2876
  );
2684
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2877
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2878
+ eventless_wallet_session: true,
2879
+ });
2685
2880
  expect(typedCall).toHaveBeenNthCalledWith(3, 'DeviceStatusGet', 'DeviceStatus', {});
2686
2881
  });
2687
2882
 
@@ -2719,7 +2914,9 @@ describe('Protocol V2 feature adapter', () => {
2719
2914
  }),
2720
2915
  expect.anything()
2721
2916
  );
2722
- expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {});
2917
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'ProtocolInfoRequest', 'ProtocolInfo', {
2918
+ eventless_wallet_session: true,
2919
+ });
2723
2920
  expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
2724
2921
  expect(device.features).toMatchObject({
2725
2922
  deviceId: null,
@@ -2985,10 +3182,6 @@ describe('Protocol V2 feature adapter', () => {
2985
3182
  fw: { application: { version: '1.2.4' } },
2986
3183
  },
2987
3184
  })
2988
- .mockResolvedValueOnce({
2989
- type: 'ProtocolInfo',
2990
- message: protocolV2ApplicationInfo,
2991
- })
2992
3185
  .mockResolvedValueOnce({
2993
3186
  type: 'DeviceStatus',
2994
3187
  message: { init_states: true, unlocked: true },
@@ -3010,7 +3203,7 @@ describe('Protocol V2 feature adapter', () => {
3010
3203
  firmwareVersion: '1.2.4',
3011
3204
  passphraseProtection: null,
3012
3205
  });
3013
- expect(typedCall).toHaveBeenCalledTimes(6);
3206
+ expect(typedCall).toHaveBeenCalledTimes(5);
3014
3207
  expect(typedCall).toHaveBeenNthCalledWith(
3015
3208
  4,
3016
3209
  'DeviceInfoGet',
@@ -3020,10 +3213,6 @@ describe('Protocol V2 feature adapter', () => {
3020
3213
  hw: true,
3021
3214
  fw: true,
3022
3215
  coprocessor: true,
3023
- se1: true,
3024
- se2: true,
3025
- se3: true,
3026
- se4: true,
3027
3216
  },
3028
3217
  types: {
3029
3218
  version: true,
@@ -3124,9 +3313,23 @@ describe('Protocol V2 feature adapter', () => {
3124
3313
  status: { init_states: true },
3125
3314
  }
3126
3315
  );
3316
+ const pinOnDevice = jest.fn();
3317
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3127
3318
 
3128
3319
  const features = await device.unlockDevice();
3129
3320
 
3321
+ expect(pinOnDevice).toHaveBeenCalledWith(
3322
+ device,
3323
+ DeviceSessionPinType.Main,
3324
+ expect.objectContaining({
3325
+ source: 'unlock-coordinator',
3326
+ reason: 'device-unlock',
3327
+ deviceOnly: true,
3328
+ })
3329
+ );
3330
+ expect(pinOnDevice.mock.invocationCallOrder[0]).toBeLessThan(
3331
+ typedCall.mock.invocationCallOrder[0]
3332
+ );
3130
3333
  expect(typedCall.mock.calls).toEqual([
3131
3334
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3132
3335
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3172,9 +3375,12 @@ describe('Protocol V2 feature adapter', () => {
3172
3375
  throw new Error(`Unexpected request: ${requestType}`);
3173
3376
  });
3174
3377
  (device as any).commands = { typedCall };
3378
+ const pinOnDevice = jest.fn();
3379
+ device.on(DEVICE.PIN_ON_DEVICE, pinOnDevice);
3175
3380
 
3176
- await device.unlockDevice();
3381
+ await device.unlockDevice(DeviceSessionPinType.Main, { emitUiEvent: false });
3177
3382
 
3383
+ expect(pinOnDevice).not.toHaveBeenCalled();
3178
3384
  expect(typedCall.mock.calls).toEqual([
3179
3385
  ['DeviceSessionAskPin', 'Success', { type: DeviceSessionPinType.Main }],
3180
3386
  ['DeviceStatusGet', 'DeviceStatus', {}],
@@ -3577,6 +3783,15 @@ describe('API compatibility handling', () => {
3577
3783
 
3578
3784
  describe('Protocol V2 firmware update targets', () => {
3579
3785
  const OKPP_HEADER_SIZE = 0x52a0;
3786
+ let forceReloadDataSpy: jest.SpyInstance<Promise<void>, []>;
3787
+
3788
+ beforeEach(() => {
3789
+ forceReloadDataSpy = jest.spyOn(DataManager, 'forceReloadData').mockResolvedValue(undefined);
3790
+ });
3791
+
3792
+ afterEach(() => {
3793
+ jest.restoreAllMocks();
3794
+ });
3580
3795
 
3581
3796
  const buildOkppHeader = ({
3582
3797
  type = 'RESC',
@@ -3701,7 +3916,7 @@ describe('Protocol V2 firmware update targets', () => {
3701
3916
  specific: true,
3702
3917
  },
3703
3918
  },
3704
- { timeoutMs: 5000 }
3919
+ { timeoutMs: 30000 }
3705
3920
  );
3706
3921
  expect(typedCall).toHaveBeenCalledTimes(3);
3707
3922
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
@@ -3717,7 +3932,7 @@ describe('Protocol V2 firmware update targets', () => {
3717
3932
  });
3718
3933
  });
3719
3934
 
3720
- test('delegates Protocol V2 mode transition to the phase executor', async () => {
3935
+ test('enters Protocol V2 bootloader before upload', async () => {
3721
3936
  const method = new FirmwareUpdateV4({
3722
3937
  id: 1,
3723
3938
  payload: {
@@ -3752,7 +3967,7 @@ describe('Protocol V2 firmware update targets', () => {
3752
3967
 
3753
3968
  await method.run();
3754
3969
 
3755
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3970
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
3756
3971
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3757
3972
  fwBinaryMap: [
3758
3973
  {
@@ -3763,6 +3978,69 @@ describe('Protocol V2 firmware update targets', () => {
3763
3978
  ],
3764
3979
  bootloaderBinary: null,
3765
3980
  });
3981
+ expect(method.postTipMessage).toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3982
+ });
3983
+
3984
+ test('enters Protocol V2 bootloader before reading and downloading remote resources', async () => {
3985
+ const method = new FirmwareUpdateV4({
3986
+ id: 1,
3987
+ payload: {
3988
+ method: 'firmwareUpdateV4',
3989
+ platform: 'web',
3990
+ targetsToUpdate: ['resource'],
3991
+ },
3992
+ });
3993
+ method.init();
3994
+
3995
+ (method as any).device = stubDevice({
3996
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
3997
+ features: {
3998
+ deviceType: 'pro2',
3999
+ firmwareVersion: '1.0.0',
4000
+ mode: 'normal',
4001
+ bootloaderMode: false,
4002
+ capabilities: [],
4003
+ },
4004
+ isBootloader: () => false,
4005
+ isRomloader: () => false,
4006
+ });
4007
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4008
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4009
+ bootloaderBinary: null,
4010
+ fwBinaryMap: [],
4011
+ installItems: [],
4012
+ });
4013
+
4014
+ const order: string[] = [];
4015
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
4016
+ order.push('enter-bootloader');
4017
+ return Promise.resolve(true);
4018
+ });
4019
+ (method as any).prepareProtocolV2ResourceBundles = jest.fn().mockImplementation(() => {
4020
+ order.push('prepare-resources');
4021
+ return Promise.resolve([
4022
+ {
4023
+ name: 'images.okpkg',
4024
+ binary: new Uint8Array([1]).buffer,
4025
+ devicePath: 'vol0:/bundles/images/images.okpkg',
4026
+ },
4027
+ ]);
4028
+ });
4029
+ (method as any).executeProtocolV2Update = jest.fn().mockImplementation(() => {
4030
+ order.push('execute-update');
4031
+ return Promise.resolve();
4032
+ });
4033
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
4034
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
4035
+ bootloaderVersion: '1.0.0',
4036
+ bleVersion: '1.0.0',
4037
+ firmwareVersion: '1.0.0',
4038
+ });
4039
+ method.postTipMessage = jest.fn();
4040
+
4041
+ await method.run();
4042
+
4043
+ expect(order).toEqual(['enter-bootloader', 'prepare-resources', 'execute-update']);
3766
4044
  });
3767
4045
 
3768
4046
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
@@ -3814,6 +4092,33 @@ describe('Protocol V2 firmware update targets', () => {
3814
4092
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3815
4093
  });
3816
4094
 
4095
+ test('keeps Protocol V2 bootloader active before firmware transfer', async () => {
4096
+ const method = new FirmwareUpdateV4({
4097
+ id: 1,
4098
+ payload: {
4099
+ method: 'firmwareUpdateV4',
4100
+ },
4101
+ });
4102
+ (method as any).device = stubDevice({
4103
+ originalDescriptor: { id: 'usb-id', path: 'bootloader-path', protocolType: 'V2' },
4104
+ features: {
4105
+ deviceType: 'pro2',
4106
+ mode: 'bootloader',
4107
+ bootloaderMode: true,
4108
+ capabilities: [],
4109
+ },
4110
+ isBootloader: () => true,
4111
+ isRomloader: () => false,
4112
+ });
4113
+ (method as any).protocolV2Reboot = jest.fn();
4114
+ method.postTipMessage = jest.fn();
4115
+
4116
+ await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4117
+
4118
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4119
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('AutoRebootToBootloader');
4120
+ });
4121
+
3817
4122
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
3818
4123
  const method = new FirmwareUpdateV4({
3819
4124
  id: 1,
@@ -3866,6 +4171,9 @@ describe('Protocol V2 firmware update targets', () => {
3866
4171
  },
3867
4172
  });
3868
4173
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4174
+ const prepareResourceBundles = jest
4175
+ .spyOn(method as any, 'prepareProtocolV2ResourceBundles')
4176
+ .mockResolvedValue(undefined);
3869
4177
  (method as any).protocolV2Reboot = jest.fn();
3870
4178
  (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3871
4179
  (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
@@ -3878,6 +4186,7 @@ describe('Protocol V2 firmware update targets', () => {
3878
4186
 
3879
4187
  await method.run();
3880
4188
 
4189
+ expect(prepareResourceBundles).not.toHaveBeenCalled();
3881
4190
  expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
3882
4191
  expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3883
4192
  bootloaderBinary: null,
@@ -3960,7 +4269,10 @@ describe('Protocol V2 firmware update targets', () => {
3960
4269
  getCommands: () => commands,
3961
4270
  mainId: 'usb-path',
3962
4271
  });
3963
- const cachedDevice = { getConnectId: () => 'usb-path' };
4272
+ const cachedDevice = {
4273
+ getConnectId: () => 'usb-path',
4274
+ getCurrentSerialNo: () => '',
4275
+ };
3964
4276
  const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
3965
4277
  devices: {},
3966
4278
  deviceList: [cachedDevice],
@@ -3977,17 +4289,193 @@ describe('Protocol V2 firmware update targets', () => {
3977
4289
  expect(initialize).not.toHaveBeenCalled();
3978
4290
  });
3979
4291
 
3980
- test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4292
+ test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
3981
4293
  const method = new FirmwareUpdateV4({
3982
4294
  id: 1,
3983
4295
  payload: {
3984
4296
  method: 'firmwareUpdateV4',
3985
4297
  },
3986
4298
  });
3987
- const commands = { disposed: false, mainId: 'usb-session' };
3988
- const acquire = jest.fn().mockResolvedValue(undefined);
4299
+ const commands = { disposed: true, mainId: '' };
4300
+ const updateFromCache = jest.fn();
3989
4301
  const device = stubDevice({
3990
- originalDescriptor: {
4302
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4303
+ deviceConnector: {
4304
+ enumerate: jest.fn().mockResolvedValue({
4305
+ descriptors: [
4306
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4307
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4308
+ ],
4309
+ }),
4310
+ },
4311
+ updateFromCache,
4312
+ hasDeviceAcquire: jest.fn(() => false),
4313
+ acquire: jest.fn().mockResolvedValue(undefined),
4314
+ commands,
4315
+ getCommands: () => commands,
4316
+ mainId: 'old-usb-path',
4317
+ });
4318
+ const otherDevice = {
4319
+ getConnectId: () => 'usb-a',
4320
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4321
+ };
4322
+ const expectedDevice = {
4323
+ getConnectId: () => 'usb-b',
4324
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4325
+ };
4326
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4327
+ devices: {},
4328
+ deviceList: [otherDevice, expectedDevice],
4329
+ } as any);
4330
+ (method as any).device = device;
4331
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4332
+
4333
+ try {
4334
+ await (method as any).reconnectProtocolV2Device();
4335
+ } finally {
4336
+ getDevices.mockRestore();
4337
+ }
4338
+
4339
+ expect(updateFromCache).toHaveBeenCalledWith(expectedDevice);
4340
+ });
4341
+
4342
+ test('rejects ambiguous Protocol V2 USB reconnect candidates without an identity match', async () => {
4343
+ const method = new FirmwareUpdateV4({
4344
+ id: 1,
4345
+ payload: {
4346
+ method: 'firmwareUpdateV4',
4347
+ },
4348
+ });
4349
+ const updateFromCache = jest.fn();
4350
+ const device = stubDevice({
4351
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4352
+ deviceConnector: {
4353
+ enumerate: jest.fn().mockResolvedValue({
4354
+ descriptors: [
4355
+ { id: 'usb-a', path: 'usb-a', protocolType: 'V2' },
4356
+ { id: 'usb-b', path: 'usb-b', protocolType: 'V2' },
4357
+ ],
4358
+ }),
4359
+ },
4360
+ updateFromCache,
4361
+ });
4362
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4363
+ devices: {},
4364
+ deviceList: [
4365
+ {
4366
+ getConnectId: () => 'usb-a',
4367
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4368
+ },
4369
+ {
4370
+ getConnectId: () => 'usb-b',
4371
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-B',
4372
+ },
4373
+ ],
4374
+ } as any);
4375
+ (method as any).device = device;
4376
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-C';
4377
+
4378
+ try {
4379
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4380
+ } finally {
4381
+ getDevices.mockRestore();
4382
+ }
4383
+
4384
+ expect(updateFromCache).not.toHaveBeenCalled();
4385
+ });
4386
+
4387
+ test('allows a single Protocol V2 USB reconnect candidate when its physical serial is unavailable', async () => {
4388
+ const method = new FirmwareUpdateV4({
4389
+ id: 1,
4390
+ payload: {
4391
+ method: 'firmwareUpdateV4',
4392
+ },
4393
+ });
4394
+ const commands = { disposed: true, mainId: '' };
4395
+ const updateFromCache = jest.fn();
4396
+ const device = stubDevice({
4397
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4398
+ deviceConnector: {
4399
+ enumerate: jest.fn().mockResolvedValue({
4400
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4401
+ }),
4402
+ },
4403
+ updateFromCache,
4404
+ hasDeviceAcquire: jest.fn(() => false),
4405
+ acquire: jest.fn().mockResolvedValue(undefined),
4406
+ commands,
4407
+ getCommands: () => commands,
4408
+ mainId: 'old-usb-path',
4409
+ });
4410
+ const candidate = {
4411
+ getConnectId: () => 'usb-a',
4412
+ getCurrentSerialNo: () => '',
4413
+ };
4414
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4415
+ devices: {},
4416
+ deviceList: [candidate],
4417
+ } as any);
4418
+ (method as any).device = device;
4419
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-A';
4420
+
4421
+ try {
4422
+ await (method as any).reconnectProtocolV2Device();
4423
+ } finally {
4424
+ getDevices.mockRestore();
4425
+ }
4426
+
4427
+ expect(updateFromCache).toHaveBeenCalledWith(candidate);
4428
+ });
4429
+
4430
+ test('rejects a single Protocol V2 USB reconnect candidate with a different physical serial', async () => {
4431
+ const method = new FirmwareUpdateV4({
4432
+ id: 1,
4433
+ payload: {
4434
+ method: 'firmwareUpdateV4',
4435
+ },
4436
+ });
4437
+ const updateFromCache = jest.fn();
4438
+ const device = stubDevice({
4439
+ originalDescriptor: { id: 'old-usb-id', path: 'old-usb-path', protocolType: 'V2' },
4440
+ deviceConnector: {
4441
+ enumerate: jest.fn().mockResolvedValue({
4442
+ descriptors: [{ id: 'usb-a', path: 'usb-a', protocolType: 'V2' }],
4443
+ }),
4444
+ },
4445
+ updateFromCache,
4446
+ });
4447
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
4448
+ devices: {},
4449
+ deviceList: [
4450
+ {
4451
+ getConnectId: () => 'usb-a',
4452
+ getCurrentSerialNo: () => 'PRO2-PHYSICAL-A',
4453
+ },
4454
+ ],
4455
+ } as any);
4456
+ (method as any).device = device;
4457
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-B';
4458
+
4459
+ try {
4460
+ await expect((method as any).reconnectProtocolV2Device()).rejects.toBeDefined();
4461
+ } finally {
4462
+ getDevices.mockRestore();
4463
+ }
4464
+
4465
+ expect(updateFromCache).not.toHaveBeenCalled();
4466
+ });
4467
+
4468
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
4469
+ const method = new FirmwareUpdateV4({
4470
+ id: 1,
4471
+ payload: {
4472
+ method: 'firmwareUpdateV4',
4473
+ },
4474
+ });
4475
+ const commands = { disposed: false, mainId: 'usb-session' };
4476
+ const acquire = jest.fn().mockResolvedValue(undefined);
4477
+ const device = stubDevice({
4478
+ originalDescriptor: {
3991
4479
  id: 'usb-id',
3992
4480
  path: 'usb-path',
3993
4481
  session: 'usb-session',
@@ -4071,7 +4559,9 @@ describe('Protocol V2 firmware update targets', () => {
4071
4559
  });
4072
4560
  method.postTipMessage = jest.fn();
4073
4561
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4074
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4562
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4563
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4564
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
4075
4565
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4076
4566
  message: 'Device rebooted successfully',
4077
4567
  });
@@ -4080,9 +4570,29 @@ describe('Protocol V2 firmware update targets', () => {
4080
4570
 
4081
4571
  expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
4082
4572
  expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4573
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4083
4574
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
4084
4575
  });
4085
4576
 
4577
+ test('skips a second Normal reboot after Pro2 already returned to App mode', async () => {
4578
+ const method = new FirmwareUpdateV4({
4579
+ id: 1,
4580
+ payload: {
4581
+ method: 'firmwareUpdateV4',
4582
+ },
4583
+ });
4584
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4585
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4586
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4587
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4588
+ (method as any).protocolV2Reboot = jest.fn();
4589
+
4590
+ await (method as any).exitProtocolV2BootloaderToNormal();
4591
+
4592
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4593
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4594
+ });
4595
+
4086
4596
  test('treats iOS BLE RxError 6 during Protocol V2 reboot as expected disconnect', async () => {
4087
4597
  const method = new FirmwareUpdateV4({
4088
4598
  id: 1,
@@ -4125,6 +4635,27 @@ describe('Protocol V2 firmware update targets', () => {
4125
4635
  });
4126
4636
  });
4127
4637
 
4638
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
4639
+ const method = new FirmwareUpdateV4({
4640
+ id: 1,
4641
+ payload: {
4642
+ method: 'firmwareUpdateV4',
4643
+ },
4644
+ });
4645
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4646
+
4647
+ (method as any).device = stubDevice({
4648
+ getCommands: () => ({ typedCall }),
4649
+ });
4650
+
4651
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
4652
+ message: 'Device rebooted successfully',
4653
+ });
4654
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4655
+ DeviceRebootType.Bootloader
4656
+ );
4657
+ });
4658
+
4128
4659
  test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
4129
4660
  const method = new FirmwareUpdateV4({
4130
4661
  id: 1,
@@ -4135,9 +4666,9 @@ describe('Protocol V2 firmware update targets', () => {
4135
4666
  const typedCall = jest
4136
4667
  .fn()
4137
4668
  .mockRejectedValue(
4138
- Object.assign(
4139
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4140
- { name: 'NotFoundError' }
4669
+ new DOMException(
4670
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
4671
+ 'NotFoundError'
4141
4672
  )
4142
4673
  );
4143
4674
 
@@ -4215,7 +4746,7 @@ describe('Protocol V2 firmware update targets', () => {
4215
4746
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4216
4747
  ]);
4217
4748
 
4218
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
4749
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4219
4750
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4220
4751
  'DeviceFirmwareUpdateStatusGet',
4221
4752
  'DeviceFirmwareUpdateStatusGet',
@@ -4253,6 +4784,46 @@ describe('Protocol V2 firmware update targets', () => {
4253
4784
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4254
4785
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4255
4786
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4787
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4788
+ });
4789
+
4790
+ test('accepts an empty firmware status dump only after confirming normal mode', async () => {
4791
+ const method = new FirmwareUpdateV4({
4792
+ id: 1,
4793
+ payload: {
4794
+ method: 'firmwareUpdateV4',
4795
+ },
4796
+ });
4797
+ const typedCall = jest.fn().mockResolvedValue({
4798
+ type: 'DeviceFirmwareUpdateStatus',
4799
+ message: { records: [] },
4800
+ });
4801
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4802
+ let now = 0;
4803
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
4804
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4805
+ now += 31 * 1000;
4806
+ callback();
4807
+ return 0 as any;
4808
+ }) as typeof setTimeout);
4809
+
4810
+ (method as any).device = stubDevice({
4811
+ getCommands: () => ({ typedCall }),
4812
+ });
4813
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4814
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4815
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4816
+ method.postProgressMessage = jest.fn();
4817
+
4818
+ await expect(
4819
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4820
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4821
+ ])
4822
+ ).resolves.toBeUndefined();
4823
+
4824
+ expect(typedCall).toHaveBeenCalledTimes(1);
4825
+ expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4826
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4256
4827
  });
4257
4828
 
4258
4829
  test.each([
@@ -4318,77 +4889,6 @@ describe('Protocol V2 firmware update targets', () => {
4318
4889
  expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4319
4890
  });
4320
4891
 
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
-
4392
4892
  test('polls target status after update ACK and finishes when all targets complete', async () => {
4393
4893
  const method = new FirmwareUpdateV4({
4394
4894
  id: 1,
@@ -4431,16 +4931,161 @@ describe('Protocol V2 firmware update targets', () => {
4431
4931
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4432
4932
  });
4433
4933
 
4434
- test('uses SDK decoded enum names for Protocol V2 install polling', () => {
4934
+ test('fails clearly when a requested target stays absent from the full status dump', async () => {
4435
4935
  const method = new FirmwareUpdateV4({
4436
4936
  id: 1,
4437
4937
  payload: {
4438
4938
  method: 'firmwareUpdateV4',
4439
4939
  },
4440
4940
  });
4941
+ const typedCall = jest.fn().mockResolvedValue({
4942
+ type: 'DeviceFirmwareUpdateStatus',
4943
+ message: {
4944
+ records: [{ target_id: 4, status: 2 }],
4945
+ },
4946
+ });
4947
+ let now = 0;
4948
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
4949
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4950
+ now += 31 * 1000;
4951
+ callback();
4952
+ return 0 as any;
4953
+ }) as typeof setTimeout);
4954
+
4955
+ (method as any).device = stubDevice({
4956
+ getCommands: () => ({ typedCall }),
4957
+ });
4958
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4959
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4441
4960
  method.postProgressMessage = jest.fn();
4442
4961
 
4443
- const expectedTargetIds = new Set([4]);
4962
+ await expect(
4963
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
4964
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4965
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
4966
+ ])
4967
+ ).rejects.toMatchObject({
4968
+ errorCode: HardwareErrorCode.FirmwareError,
4969
+ message: expect.stringContaining('targetIds=3'),
4970
+ });
4971
+ expect(typedCall).toHaveBeenCalledTimes(2);
4972
+ });
4973
+
4974
+ test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
4975
+ const method = new FirmwareUpdateV4({
4976
+ id: 1,
4977
+ payload: {
4978
+ method: 'firmwareUpdateV4',
4979
+ },
4980
+ });
4981
+ const typedCall = jest
4982
+ .fn()
4983
+ .mockResolvedValueOnce({
4984
+ type: 'DeviceFirmwareUpdateStatus',
4985
+ message: { records: [{ target_id: 4, status: 2 }] },
4986
+ })
4987
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
4988
+ .mockResolvedValueOnce({
4989
+ type: 'DeviceFirmwareUpdateStatus',
4990
+ message: { records: [{ target_id: 4, status: 2 }] },
4991
+ })
4992
+ .mockResolvedValueOnce({
4993
+ type: 'DeviceFirmwareUpdateStatus',
4994
+ message: {
4995
+ records: [
4996
+ { target_id: 3, status: 2 },
4997
+ { target_id: 4, status: 2 },
4998
+ ],
4999
+ },
5000
+ });
5001
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5002
+ let now = 0;
5003
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5004
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5005
+ now += 31 * 1000;
5006
+ callback();
5007
+ return 0 as any;
5008
+ }) as typeof setTimeout);
5009
+
5010
+ (method as any).device = stubDevice({
5011
+ getCommands: () => ({ typedCall }),
5012
+ });
5013
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5014
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5015
+ method.postProgressMessage = jest.fn();
5016
+
5017
+ await expect(
5018
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5019
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
5020
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5021
+ ])
5022
+ ).resolves.toBeUndefined();
5023
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5024
+ expect(typedCall).toHaveBeenCalledTimes(4);
5025
+ });
5026
+
5027
+ test('uses an eight-minute Pro2 install status window', async () => {
5028
+ const method = new FirmwareUpdateV4({
5029
+ id: 1,
5030
+ payload: {
5031
+ method: 'firmwareUpdateV4',
5032
+ },
5033
+ });
5034
+ jest
5035
+ .spyOn(Date, 'now')
5036
+ .mockReturnValueOnce(0)
5037
+ .mockReturnValue(8 * 60 * 1000);
5038
+
5039
+ await expect(
5040
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5041
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5042
+ ])
5043
+ ).rejects.toThrow('within 480s');
5044
+ });
5045
+
5046
+ test('uses a 90-second Pro2 bootloader reconnect window', async () => {
5047
+ const method = new FirmwareUpdateV4({
5048
+ id: 1,
5049
+ payload: {
5050
+ method: 'firmwareUpdateV4',
5051
+ },
5052
+ });
5053
+ jest
5054
+ .spyOn(Date, 'now')
5055
+ .mockReturnValueOnce(0)
5056
+ .mockReturnValue(90 * 1000);
5057
+
5058
+ await expect((method as any).waitForProtocolV2BootloaderMode()).rejects.toThrow('within 90s');
5059
+ });
5060
+
5061
+ test('uses a three-minute Pro2 final reconnect window', async () => {
5062
+ const method = new FirmwareUpdateV4({
5063
+ id: 1,
5064
+ payload: {
5065
+ method: 'firmwareUpdateV4',
5066
+ },
5067
+ });
5068
+ const waitForProtocolV2ReconnectAndFeatures = jest
5069
+ .fn()
5070
+ .mockRejectedValue(new Error('stop after capturing timeout'));
5071
+ (method as any).waitForProtocolV2ReconnectAndFeatures = waitForProtocolV2ReconnectAndFeatures;
5072
+
5073
+ await expect((method as any).waitForProtocolV2FinalFeatures()).rejects.toThrow(
5074
+ 'stop after capturing timeout'
5075
+ );
5076
+ expect(waitForProtocolV2ReconnectAndFeatures).toHaveBeenCalledWith(3 * 60 * 1000);
5077
+ });
5078
+
5079
+ test('uses SDK decoded enum names for Protocol V2 install polling', () => {
5080
+ const method = new FirmwareUpdateV4({
5081
+ id: 1,
5082
+ payload: {
5083
+ method: 'firmwareUpdateV4',
5084
+ },
5085
+ });
5086
+ method.postProgressMessage = jest.fn();
5087
+
5088
+ const expectedTargetIds = new Set([4]);
4444
5089
 
4445
5090
  expect(
4446
5091
  (method as any).assertProtocolV2TargetStatus([{ target_id: 4, status: 2 }], expectedTargetIds)
@@ -4465,7 +5110,7 @@ describe('Protocol V2 firmware update targets', () => {
4465
5110
  ).toBe(true);
4466
5111
 
4467
5112
  (method.postProgressMessage as jest.Mock).mockClear();
4468
- expect(() =>
5113
+ expect(
4469
5114
  (method as any).assertProtocolV2TargetStatus(
4470
5115
  [
4471
5116
  { target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FINISHED' },
@@ -4473,8 +5118,8 @@ describe('Protocol V2 firmware update targets', () => {
4473
5118
  ],
4474
5119
  new Set([4, 10])
4475
5120
  )
4476
- ).toThrow('Protocol V2 install status conflicts with target 4');
4477
- expect(method.postProgressMessage).not.toHaveBeenCalled();
5121
+ ).toBe(false);
5122
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
4478
5123
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
4479
5124
 
4480
5125
  expect(
@@ -4563,7 +5208,7 @@ describe('Protocol V2 firmware update targets', () => {
4563
5208
  path: true,
4564
5209
  },
4565
5210
  },
4566
- { timeoutMs: 5000 }
5211
+ { timeoutMs: 15000 }
4567
5212
  );
4568
5213
  });
4569
5214
 
@@ -4578,14 +5223,10 @@ describe('Protocol V2 firmware update targets', () => {
4578
5223
  const writtenPaths: string[] = [];
4579
5224
  method.postTipMessage = jest.fn();
4580
5225
  method.postProgressMessage = jest.fn();
4581
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockImplementation(params => {
5226
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockImplementation(params => {
4582
5227
  writtenPaths.push(params.filePath);
4583
- return Number(params.processedSize ?? 0) + Number(params.source.size);
5228
+ return Number(params.processedSize ?? 0) + Number(params.payload.byteLength);
4584
5229
  });
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({});
4589
5230
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4590
5231
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4591
5232
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4625,12 +5266,10 @@ describe('Protocol V2 firmware update targets', () => {
4625
5266
  'vol0:/coprocessor.bin',
4626
5267
  1
4627
5268
  );
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, {
5269
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
5270
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
4633
5271
  targets: [
5272
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
4634
5273
  { target_id: 6, path: 'vol0:/coprocessor.bin' },
4635
5274
  { target_id: 7, path: 'vol0:/se01.bin' },
4636
5275
  { target_id: 4, path: 'vol0:/application_p1.bin' },
@@ -4640,7 +5279,7 @@ describe('Protocol V2 firmware update targets', () => {
4640
5279
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
4641
5280
  });
4642
5281
 
4643
- test('announces separate resource and component transfer phases', async () => {
5282
+ test('announces one transfer when syncing resource bundles and staging firmware', async () => {
4644
5283
  const method = new FirmwareUpdateV4({
4645
5284
  id: 1,
4646
5285
  payload: {
@@ -4650,14 +5289,11 @@ describe('Protocol V2 firmware update targets', () => {
4650
5289
 
4651
5290
  method.postTipMessage = jest.fn();
4652
5291
  method.postProgressMessage = jest.fn();
4653
- (method as any).protocolV2SourceUpdateProcess = jest
5292
+ (method as any).protocolV2CommonUpdateProcess = jest
4654
5293
  .fn()
4655
5294
  .mockImplementation(params =>
4656
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5295
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4657
5296
  );
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({});
4661
5297
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4662
5298
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4663
5299
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4677,22 +5313,21 @@ describe('Protocol V2 firmware update targets', () => {
4677
5313
  {
4678
5314
  fileName: 'application_p1.bin',
4679
5315
  binary: new Uint8Array([3]).buffer,
4680
- targetId: 4,
5316
+ targetId: 3,
4681
5317
  },
4682
5318
  ],
4683
5319
  });
4684
5320
 
4685
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
5321
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
4686
5322
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
4687
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
4688
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
4689
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5323
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5324
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4690
5325
  1,
4691
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
5326
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
4692
5327
  );
4693
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5328
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenNthCalledWith(
4694
5329
  2,
4695
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
5330
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
4696
5331
  );
4697
5332
  });
4698
5333
 
@@ -4706,8 +5341,7 @@ describe('Protocol V2 firmware update targets', () => {
4706
5341
 
4707
5342
  method.postTipMessage = jest.fn();
4708
5343
  method.postProgressMessage = jest.fn();
4709
- (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
4710
- (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
5344
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
4711
5345
  (method as any).device = stubDevice({
4712
5346
  getCommands: () => ({
4713
5347
  typedCall: jest.fn().mockResolvedValue({
@@ -4753,14 +5387,11 @@ describe('Protocol V2 firmware update targets', () => {
4753
5387
 
4754
5388
  method.postTipMessage = jest.fn();
4755
5389
  method.postProgressMessage = jest.fn();
4756
- (method as any).protocolV2SourceUpdateProcess = jest
5390
+ (method as any).protocolV2CommonUpdateProcess = jest
4757
5391
  .fn()
4758
5392
  .mockImplementation(params =>
4759
- Promise.resolve(Number(params.processedSize ?? 0) + Number(params.source.size))
5393
+ Promise.resolve(Number(params.processedSize ?? 0) + Number(params.payload.byteLength))
4760
5394
  );
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({});
4764
5395
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
4765
5396
  (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
4766
5397
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
@@ -4975,33 +5606,42 @@ describe('Protocol V2 firmware update targets', () => {
4975
5606
  getFirmwareLatestReleaseSpy.mockRestore();
4976
5607
  });
4977
5608
 
4978
- test('maps remote Pro2 resource bundles to direct-write descriptors', () => {
5609
+ test('does not download Pro2 firmware components that App did not select', async () => {
4979
5610
  const method = new FirmwareUpdateV4({
4980
5611
  id: 1,
4981
5612
  payload: {
4982
5613
  method: 'firmwareUpdateV4',
4983
5614
  platform: 'web',
4984
- targetsToUpdate: ['resource'],
4985
5615
  },
4986
5616
  });
4987
5617
  method.init();
4988
5618
 
5619
+ const getSysResourceBinarySpy = jest
5620
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
5621
+ .mockResolvedValue({ binary: new Uint8Array([1]).buffer });
4989
5622
  const getFirmwareLatestReleaseSpy = jest
4990
5623
  .spyOn(DataManager, 'getFirmwareLatestRelease')
4991
5624
  .mockReturnValue({
4992
5625
  required: false,
4993
5626
  version: [1, 0, 0],
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),
5627
+ url: 'https://example.com/applicationP1.pp.bin',
5628
+ bootloaderVersion: [1, 0, 0],
5629
+ upgradeType: 'payload-package-set',
5630
+ installOrder: ['bootloader', 'applicationP1', 'applicationP2'],
5631
+ components: {
5632
+ bootloader: {
5633
+ target: 'BOOTLOADER',
5634
+ url: 'https://example.com/bootloader.pp.bin',
5003
5635
  },
5004
- ],
5636
+ applicationP1: {
5637
+ target: 'APPLICATION_P1',
5638
+ url: 'https://example.com/applicationP1.pp.bin',
5639
+ },
5640
+ applicationP2: {
5641
+ target: 'APPLICATION_P2',
5642
+ url: 'https://example.com/applicationP2.pp.bin',
5643
+ },
5644
+ },
5005
5645
  fingerprint: '',
5006
5646
  changelog: {
5007
5647
  'zh-CN': '',
@@ -5009,408 +5649,108 @@ describe('Protocol V2 firmware update targets', () => {
5009
5649
  },
5010
5650
  });
5011
5651
 
5012
- const bundles = (method as any).prepareProtocolV2ResourceBundles('universal', {
5652
+ const remoteBinaries = await (method as any).prepareRemoteProtocolV2Binaries('universal', {
5013
5653
  deviceType: 'pro2',
5014
5654
  firmwareVersion: '1.0.0',
5655
+ bootloaderVersion: '1.0.0',
5015
5656
  capabilities: [],
5016
5657
  });
5017
5658
 
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);
5659
+ expect(remoteBinaries).toEqual({
5660
+ bootloaderBinary: null,
5661
+ fwBinaryMap: [],
5662
+ installItems: [],
5663
+ });
5664
+ expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5030
5665
 
5666
+ getSysResourceBinarySpy.mockRestore();
5031
5667
  getFirmwareLatestReleaseSpy.mockRestore();
5032
5668
  });
5033
5669
 
5034
- test('rejects remote RESC bundle paths before download or bootloader entry', async () => {
5670
+ test('uses Pro2 remote components when firmwareUpdateV4 has no explicit binaries', async () => {
5035
5671
  const method = new FirmwareUpdateV4({
5036
5672
  id: 1,
5037
5673
  payload: {
5038
5674
  method: 'firmwareUpdateV4',
5039
5675
  platform: 'web',
5040
- targetsToUpdate: ['resource'],
5041
5676
  },
5042
5677
  });
5043
5678
  method.init();
5044
5679
  (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5045
5680
 
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': '',
5681
+ const remoteBinaries = {
5682
+ bootloaderBinary: new Uint8Array([1]).buffer,
5683
+ fwBinaryMap: [
5684
+ {
5685
+ fileName: 'application_p1.bin',
5686
+ binary: new Uint8Array([2]).buffer,
5687
+ targetId: 3,
5064
5688
  },
5065
- });
5689
+ ],
5690
+ };
5066
5691
 
5067
5692
  (method as any).device = stubDevice({
5068
5693
  originalDescriptor: { protocolType: 'V2' },
5069
- features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
5694
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5070
5695
  });
5071
- (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
5072
- bootloaderBinary: null,
5073
- fwBinaryMap: [],
5696
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue(remoteBinaries);
5697
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
5698
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
5699
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
5700
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
5701
+ bootloaderVersion: '1.0.0',
5702
+ bleVersion: '0.0.0',
5703
+ firmwareVersion: '1.0.0',
5074
5704
  });
5075
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
5076
5705
  method.postTipMessage = jest.fn();
5077
5706
 
5078
- await expect(method.run()).rejects.toThrow('resourceBundles[0].devicePath');
5079
- expect(getSysResourceBinarySpy).not.toHaveBeenCalled();
5080
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5707
+ await method.run();
5081
5708
 
5082
- getSysResourceBinarySpy.mockRestore();
5083
- getFirmwareLatestReleaseSpy.mockRestore();
5709
+ expect(forceReloadDataSpy).toHaveBeenCalledTimes(1);
5710
+ expect((method as any).prepareRemoteProtocolV2Binaries).toHaveBeenCalledTimes(1);
5711
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
5712
+ bootloaderBinary: remoteBinaries.bootloaderBinary,
5713
+ fwBinaryMap: remoteBinaries.fwBinaryMap,
5714
+ });
5084
5715
  });
5085
5716
 
5086
- test('rejects prepared RESC bundle paths before opening an artifact source', async () => {
5717
+ test('stops a remote update before reboot when the latest config cannot be refreshed', async () => {
5087
5718
  const method = new FirmwareUpdateV4({
5088
5719
  id: 1,
5089
5720
  payload: {
5090
5721
  method: 'firmwareUpdateV4',
5722
+ platform: 'web',
5091
5723
  },
5092
5724
  });
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
- });
5725
+ method.init();
5726
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5727
+ (method as any).device = stubDevice({
5728
+ originalDescriptor: { protocolType: 'V2' },
5729
+ features: { deviceType: 'pro2', firmwareVersion: '0.0.0', capabilities: [] },
5730
+ });
5731
+ forceReloadDataSpy.mockRejectedValue(
5732
+ ERRORS.TypedError(HardwareErrorCode.NetworkError, 'latest config unavailable')
5733
+ );
5734
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn();
5735
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
5736
+ method.postTipMessage = jest.fn();
5127
5737
 
5128
- await expect(
5129
- (method as any).prepareProtocolV2ResourceSources('universal', {
5130
- deviceType: 'pro2',
5131
- })
5132
- ).rejects.toThrow('resourceBundles[].devicePath');
5133
- expect(openPreparedSource).not.toHaveBeenCalled();
5738
+ await expect(method.run()).rejects.toMatchObject({
5739
+ errorCode: HardwareErrorCode.NetworkError,
5740
+ message: 'latest config unavailable',
5741
+ });
5134
5742
 
5135
- getFirmwareLatestReleaseSpy.mockRestore();
5743
+ expect((method as any).prepareRemoteProtocolV2Binaries).not.toHaveBeenCalled();
5744
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
5136
5745
  });
5137
5746
 
5138
- test('downloads and validates remote RESC bundles before entering bootloader', async () => {
5747
+ test('keeps explicit Protocol V2 binaries isolated from remote component auto-fill', async () => {
5139
5748
  const method = new FirmwareUpdateV4({
5140
5749
  id: 1,
5141
5750
  payload: {
5142
5751
  method: 'firmwareUpdateV4',
5143
5752
  platform: 'web',
5144
- targetsToUpdate: ['resource'],
5145
- },
5146
- });
5147
- method.init();
5148
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
5149
-
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,
5753
+ coprocessorBinary: new Uint8Array([1]).buffer,
5414
5754
  },
5415
5755
  });
5416
5756
  method.init();
@@ -5529,10 +5869,7 @@ describe('Protocol V2 firmware update targets', () => {
5529
5869
 
5530
5870
  method.postTipMessage = jest.fn();
5531
5871
  method.postProgressMessage = jest.fn();
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);
5872
+ (method as any).protocolV2CommonUpdateProcess = jest.fn().mockResolvedValue(3);
5536
5873
  (method as any).protocolV2StartFirmwareUpdate = jest.fn();
5537
5874
  (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn();
5538
5875
 
@@ -5548,7 +5885,7 @@ describe('Protocol V2 firmware update targets', () => {
5548
5885
  fwBinaryMap: [],
5549
5886
  });
5550
5887
 
5551
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
5888
+ expect((method as any).protocolV2CommonUpdateProcess).toHaveBeenCalledTimes(1);
5552
5889
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
5553
5890
  expect((method as any).waitForProtocolV2FirmwareUpdateComplete).not.toHaveBeenCalled();
5554
5891
  });
@@ -5579,19 +5916,12 @@ describe('Protocol V2 firmware update targets', () => {
5579
5916
  });
5580
5917
  method.postProgressMessage = jest.fn();
5581
5918
 
5582
- const source = await openFirmwareByteSource({
5583
- binary: new Uint8Array(4097).buffer,
5919
+ await (method as any).protocolV2CommonUpdateProcess({
5920
+ payload: new Uint8Array(4097).buffer,
5921
+ filePath: 'vol1:firmware.bin',
5922
+ processedSize: 0,
5923
+ totalSize: 4097,
5584
5924
  });
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
- }
5595
5925
 
5596
5926
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5597
5927
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 4000]);
@@ -5648,16 +5978,12 @@ describe('Protocol V2 firmware update targets', () => {
5648
5978
  method.postProgressMessage = jest.fn();
5649
5979
 
5650
5980
  try {
5651
- const source = await openFirmwareByteSource({
5652
- binary: new Uint8Array(8001).buffer,
5653
- });
5654
- await (method as any).protocolV2SourceUpdateProcess({
5655
- source,
5981
+ await (method as any).protocolV2CommonUpdateProcess({
5982
+ payload: new Uint8Array(8001).buffer,
5656
5983
  filePath: 'vol0:/firmware.bin',
5657
5984
  processedSize: 0,
5658
5985
  totalSize: 8001,
5659
5986
  });
5660
- await source?.close();
5661
5987
  } finally {
5662
5988
  setTimeoutSpy.mockRestore();
5663
5989
  }
@@ -5692,21 +6018,16 @@ describe('Protocol V2 firmware update targets', () => {
5692
6018
  });
5693
6019
  method.postProgressMessage = jest.fn();
5694
6020
  method.postTipMessage = jest.fn();
5695
- (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
5696
6021
 
5697
- const source = await openFirmwareByteSource({
5698
- binary: new Uint8Array(4097).buffer,
5699
- });
5700
6022
  await expect(
5701
- (method as any).protocolV2SourceUpdateProcess({
5702
- source,
6023
+ (method as any).protocolV2WriteWholeFile({
6024
+ payload: new Uint8Array(4097).buffer,
5703
6025
  filePath: 'vol0:/firmware.bin',
5704
6026
  processedSize: 0,
5705
6027
  totalSize: 4097,
5706
6028
  })
5707
6029
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError });
5708
- await source?.close();
5709
- expect(typedCall).toHaveBeenCalledTimes(6);
6030
+ expect(typedCall).toHaveBeenCalledTimes(2);
5710
6031
  });
5711
6032
 
5712
6033
  test('caps native BLE firmware upload chunks below the WebUSB limit', async () => {
@@ -5739,19 +6060,12 @@ describe('Protocol V2 firmware update targets', () => {
5739
6060
  });
5740
6061
  method.postProgressMessage = jest.fn();
5741
6062
 
5742
- const source = await openFirmwareByteSource({
5743
- binary: new Uint8Array(1801).buffer,
6063
+ await (method as any).protocolV2CommonUpdateProcess({
6064
+ payload: new Uint8Array(1801).buffer,
6065
+ filePath: 'vol1:ble-firmware.bin',
6066
+ processedSize: 0,
6067
+ totalSize: 1801,
5744
6068
  });
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
- }
5755
6069
 
5756
6070
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
5757
6071
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
@@ -5868,9 +6182,9 @@ describe('Protocol V2 firmware update method', () => {
5868
6182
  const typedCall = jest
5869
6183
  .fn()
5870
6184
  .mockRejectedValue(
5871
- Object.assign(
5872
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
5873
- { name: 'NotFoundError' }
6185
+ new DOMException(
6186
+ "Failed to execute 'open' on 'USBDevice': The device was disconnected",
6187
+ 'NotFoundError'
5874
6188
  )
5875
6189
  );
5876
6190
 
@@ -6035,40 +6349,77 @@ describe('Protocol V2 firmware update method', () => {
6035
6349
  });
6036
6350
 
6037
6351
  describe('Protocol V2 firmware reconnect identity', () => {
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
- },
6046
- });
6047
-
6048
- method.init();
6049
-
6050
- expect((method as any).params.expectedDeviceId).toBe('persisted-device-id');
6051
- });
6052
-
6053
- test('rejects a different physical serial before firmware transfer resumes', () => {
6054
- expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
6055
- 'identity mismatch'
6352
+ const createResourceFilesystemTypedCall = (
6353
+ stable: Array<{ type: string; size: number; headerHash: string }>,
6354
+ missingTypes: string[] = []
6355
+ ) => {
6356
+ const missing = new Set(missingTypes);
6357
+ const resourceByPath = new Map(
6358
+ stable.map(resource => [
6359
+ PROTOCOL_V2_RESOURCE_DEVICE_PATHS[
6360
+ resource.type as keyof typeof PROTOCOL_V2_RESOURCE_DEVICE_PATHS
6361
+ ],
6362
+ resource,
6363
+ ])
6056
6364
  );
6057
- expect(() =>
6365
+ return jest.fn((requestType: string, _responseType: string, payload: Record<string, any>) => {
6366
+ if (requestType === 'ResourceInventoryGet') {
6367
+ throw new Error('ResourceInventoryGet is unavailable on released firmware');
6368
+ }
6369
+ if (requestType === 'FilesystemPathInfoQuery') {
6370
+ const resource = resourceByPath.get(payload.path);
6371
+ const exists = Boolean(resource && !missing.has(resource.type));
6372
+ return {
6373
+ message: {
6374
+ exist: exists,
6375
+ directory: false,
6376
+ size: exists ? resource?.size : 0,
6377
+ },
6378
+ };
6379
+ }
6380
+ if (requestType === 'FilesystemFileRead') {
6381
+ const resource = resourceByPath.get(payload.file.path);
6382
+ if (!resource || missing.has(resource.type)) throw new Error('missing resource');
6383
+ const header = new Uint8Array(0x52a0);
6384
+ const view = new DataView(header.buffer);
6385
+ 'OKPP'.split('').forEach((char, index) => {
6386
+ header[index] = char.charCodeAt(0);
6387
+ });
6388
+ 'RESC'.split('').forEach((char, index) => {
6389
+ header[0x08 + index] = char.charCodeAt(0);
6390
+ });
6391
+ view.setUint32(0x0c, header.byteLength, true);
6392
+ for (let index = 0; index < resource.headerHash.length / 2; index++) {
6393
+ header[0x240 + index] = Number.parseInt(
6394
+ resource.headerHash.slice(index * 2, index * 2 + 2),
6395
+ 16
6396
+ );
6397
+ }
6398
+ const offset = Number(payload.file.offset);
6399
+ const chunkLength = Number(payload.chunk_len);
6400
+ return {
6401
+ message: {
6402
+ data: header.slice(offset, offset + chunkLength),
6403
+ },
6404
+ };
6405
+ }
6406
+ throw new Error(`Unexpected request: ${requestType}`);
6407
+ });
6408
+ };
6409
+
6410
+ test('rejects a different physical serial before firmware transfer resumes', () => {
6411
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', 'other-serial')).toThrow(
6412
+ 'identity mismatch'
6413
+ );
6414
+ expect(() =>
6058
6415
  assertProtocolV2ReconnectIdentity('expected-serial', 'expected-serial')
6059
6416
  ).not.toThrow();
6060
6417
  });
6061
6418
 
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
- );
6419
+ test('allows reconnect when either physical serial is unavailable', () => {
6420
+ expect(() => assertProtocolV2ReconnectIdentity('expected-serial', undefined)).not.toThrow();
6421
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, 'actual-serial')).not.toThrow();
6422
+ expect(() => assertProtocolV2ReconnectIdentity(undefined, undefined)).not.toThrow();
6072
6423
  });
6073
6424
 
6074
6425
  test('captures physical identity from active DeviceInfo instead of wallet deviceId', async () => {
@@ -6101,7 +6452,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
6101
6452
  );
6102
6453
  });
6103
6454
 
6104
- test('rejects firmware update startup when DeviceInfo has no physical serial', async () => {
6455
+ test('allows firmware update startup when DeviceInfo has no physical serial', async () => {
6105
6456
  const method = new FirmwareUpdateV4({
6106
6457
  id: 1,
6107
6458
  payload: { method: 'firmwareUpdateV4' },
@@ -6114,39 +6465,209 @@ describe('Protocol V2 firmware reconnect identity', () => {
6114
6465
  getCommands: () => ({ typedCall }),
6115
6466
  });
6116
6467
 
6117
- await expect((method as any).captureProtocolV2PhysicalIdentity()).rejects.toMatchObject({
6118
- errorCode: HardwareErrorCode.DeviceNotFound,
6119
- });
6468
+ await expect((method as any).captureProtocolV2PhysicalIdentity()).resolves.toBeUndefined();
6469
+ expect((method as any).protocolV2ExpectedSerialNumber).toBeUndefined();
6120
6470
  });
6121
6471
 
6122
- test('uses the BLE read limit when reading an existing firmware bundle header', async () => {
6472
+ test('uses filesystem reads instead of ResourceInventoryGet for resource comparison', async () => {
6123
6473
  const method = new FirmwareUpdateV4({
6124
6474
  id: 1,
6125
6475
  payload: {
6126
6476
  method: 'firmwareUpdateV4',
6127
- platform: 'native',
6128
- chunkSize: 1800,
6477
+ platform: 'web',
6478
+ targetsToUpdate: ['resource'],
6129
6479
  },
6130
6480
  });
6131
6481
  method.init();
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}`);
6142
- });
6482
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6483
+ (type, index) => ({
6484
+ type,
6485
+ url: `https://example.com/${type}.okpkg`,
6486
+ size: 0x52a0 + index + 1,
6487
+ fileHash: 'a'.repeat(64),
6488
+ headerHash: index.toString(16).padStart(128, '0'),
6489
+ })
6490
+ );
6491
+ const typedCall = createResourceFilesystemTypedCall(stable);
6143
6492
  (method as any).device = stubDevice({
6144
6493
  getCommands: () => ({ typedCall }),
6145
6494
  });
6495
+ const resourcesSpy = jest
6496
+ .spyOn(DataManager, 'getProtocolV2Resources')
6497
+ .mockReturnValue(stable as any);
6498
+ (method as any).downloadProtocolV2Resource = jest.fn();
6499
+
6500
+ await expect((method as any).prepareProtocolV2ResourceBundles()).resolves.toEqual([]);
6501
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6502
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemFileRead')).toBe(true);
6503
+ expect((method as any).downloadProtocolV2Resource).not.toHaveBeenCalled();
6504
+ resourcesSpy.mockRestore();
6505
+ });
6506
+
6507
+ test('downloads only changed resources from loader inventory', async () => {
6508
+ const method = new FirmwareUpdateV4({
6509
+ id: 1,
6510
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6511
+ });
6512
+ method.init();
6513
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6514
+ (type, index) => ({
6515
+ type,
6516
+ url: `https://example.com/${type}.okpkg`,
6517
+ size: 0x52a0 + index + 1,
6518
+ fileHash: 'a'.repeat(64),
6519
+ headerHash: index.toString(16).padStart(128, '0'),
6520
+ })
6521
+ );
6522
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6523
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6524
+ const resourcesSpy = jest
6525
+ .spyOn(DataManager, 'getProtocolV2Resources')
6526
+ .mockReturnValue(stable as any);
6527
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6528
+ Promise.resolve({
6529
+ name: `${resource.type}.okpkg`,
6530
+ binary: new ArrayBuffer(resource.size),
6531
+ devicePath: `vol0:/${resource.type}.okpkg`,
6532
+ })
6533
+ );
6534
+
6535
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6536
+
6537
+ expect(bundles).toHaveLength(1);
6538
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6539
+ resourcesSpy.mockRestore();
6540
+ });
6541
+
6542
+ test('uses filesystem inventory for incremental Bootloader recovery', async () => {
6543
+ const method = new FirmwareUpdateV4({
6544
+ id: 1,
6545
+ payload: { method: 'firmwareUpdateV4', platform: 'web', targetsToUpdate: ['resource'] },
6546
+ });
6547
+ method.init();
6548
+ const stable = ['images', 'animation', 'wallpaper', 'translations', 'roobert', 'noto'].map(
6549
+ (type, index) => ({
6550
+ type,
6551
+ url: `https://example.com/${type}.okpkg`,
6552
+ size: 0x52a0 + index + 1,
6553
+ fileHash: 'a'.repeat(64),
6554
+ headerHash: index.toString(16).padStart(128, '0'),
6555
+ })
6556
+ );
6557
+ const typedCall = createResourceFilesystemTypedCall(stable, ['images']);
6558
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
6559
+ const resourcesSpy = jest
6560
+ .spyOn(DataManager, 'getProtocolV2Resources')
6561
+ .mockReturnValue(stable as any);
6562
+ (method as any).downloadProtocolV2Resource = jest.fn(resource =>
6563
+ Promise.resolve({
6564
+ name: `${resource.type}.okpkg`,
6565
+ binary: new ArrayBuffer(resource.size),
6566
+ devicePath: `vol0:/${resource.type}.okpkg`,
6567
+ })
6568
+ );
6569
+
6570
+ const bundles = await (method as any).prepareProtocolV2ResourceBundles();
6571
+
6572
+ expect(bundles).toHaveLength(1);
6573
+ expect(typedCall.mock.calls.some(call => call[0] === 'ResourceInventoryGet')).toBe(false);
6574
+ expect(typedCall.mock.calls.some(call => call[0] === 'FilesystemPathInfoQuery')).toBe(true);
6575
+ expect((method as any).downloadProtocolV2Resource).toHaveBeenCalledWith(stable[0]);
6576
+ resourcesSpy.mockRestore();
6577
+ });
6578
+
6579
+ const buildBootResourcesHeader = ({
6580
+ type = 'CRAT',
6581
+ payloadHash = 'ab'.repeat(64),
6582
+ headerHash = 'cd'.repeat(64),
6583
+ }: {
6584
+ type?: string;
6585
+ payloadHash?: string;
6586
+ headerHash?: string;
6587
+ } = {}) => {
6588
+ const header = new Uint8Array(0x52a0);
6589
+ const view = new DataView(header.buffer);
6590
+ 'OKPP'.split('').forEach((char, index) => {
6591
+ header[index] = char.charCodeAt(0);
6592
+ });
6593
+ type.split('').forEach((char, index) => {
6594
+ header[0x08 + index] = char.charCodeAt(0);
6595
+ });
6596
+ view.setUint32(0x0c, header.byteLength, true);
6597
+ const writeHex = (offset: number, hex: string) => {
6598
+ for (let index = 0; index < hex.length / 2; index++) {
6599
+ header[offset + index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
6600
+ }
6601
+ };
6602
+ writeHex(0x200, payloadHash);
6603
+ writeHex(0x240, headerHash);
6604
+ return header;
6605
+ };
6606
+
6607
+ test('does not resolve boot resources unless the optional target is selected', async () => {
6608
+ const method = new FirmwareUpdateV4({
6609
+ id: 1,
6610
+ payload: { method: 'firmwareUpdateV4', platform: 'web' },
6611
+ });
6612
+ method.init();
6613
+ const configSpy = jest.spyOn(DataManager, 'getProtocolV2BootResources');
6614
+ const downloadSpy = jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary');
6615
+
6616
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toBeUndefined();
6617
+
6618
+ expect(configSpy).not.toHaveBeenCalled();
6619
+ expect(downloadSpy).not.toHaveBeenCalled();
6620
+ });
6621
+
6622
+ test('downloads and maps the selected boot resources CRATE target', async () => {
6623
+ const payloadHash = 'ab'.repeat(64);
6624
+ const headerHash = 'cd'.repeat(64);
6625
+ const bytes = buildBootResourcesHeader({ payloadHash, headerHash });
6626
+ const binary = bytes.buffer as ArrayBuffer;
6627
+ const fileHash = Array.from(sha256(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
6628
+ const resource = {
6629
+ required: false as const,
6630
+ target: 'CRATE' as const,
6631
+ url: 'https://example.com/boot-resources.crate.okpkg',
6632
+ size: bytes.byteLength,
6633
+ fileHash,
6634
+ payloadHash,
6635
+ headerHash,
6636
+ };
6637
+ const method = new FirmwareUpdateV4({
6638
+ id: 1,
6639
+ payload: {
6640
+ method: 'firmwareUpdateV4',
6641
+ platform: 'web',
6642
+ targetsToUpdate: ['boot_resources'],
6643
+ },
6644
+ });
6645
+ method.init();
6646
+ jest.spyOn(DataManager, 'getProtocolV2BootResources').mockReturnValue(resource);
6647
+ jest.spyOn(firmwareBinaryApi, 'getSysResourceBinary').mockResolvedValue({ binary });
6146
6648
 
6147
- await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
6148
- expect(readChunkLengths.length).toBeGreaterThan(1);
6149
- expect(Math.max(...readChunkLengths)).toBe(900);
6649
+ await expect((method as any).prepareProtocolV2BootResources()).resolves.toEqual({
6650
+ fileName: 'boot_resources.crate.okpkg',
6651
+ binary,
6652
+ targetId: 1,
6653
+ kind: 'boot_resources',
6654
+ });
6655
+ });
6656
+
6657
+ test('rejects a non-CRATE manual boot resources package', async () => {
6658
+ const method = new FirmwareUpdateV4({
6659
+ id: 1,
6660
+ payload: {
6661
+ method: 'firmwareUpdateV4',
6662
+ platform: 'web',
6663
+ bootResourcesBinary: buildBootResourcesHeader({ type: 'RESC' }).buffer,
6664
+ },
6665
+ });
6666
+ method.init();
6667
+
6668
+ await expect((method as any).prepareProtocolV2BootResources()).rejects.toThrow(
6669
+ 'Invalid Pro2 boot resources CRATE header'
6670
+ );
6150
6671
  });
6151
6672
  });
6152
6673
 
@@ -6286,311 +6807,230 @@ describe('Protocol V2 protected method execution', () => {
6286
6807
  expect(method.unlockPolicy).toBe('none');
6287
6808
  });
6288
6809
 
6289
- test('unlocks and retries an opted-in Protocol V2 method once', async () => {
6810
+ test('checks fresh status for a wallet business method without a name allowlist', async () => {
6290
6811
  const calls: string[] = [];
6812
+ const features = { unlocked: true };
6291
6813
  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
- }),
6814
+ name: 'confluxSignMessageCIP23',
6815
+ unlockPolicy: 'none',
6816
+ useDevicePassphraseState: true,
6817
+ protocolV2UiInteraction: { reason: 'address-confirmation' },
6818
+ run: jest.fn(() => {
6819
+ calls.push('run');
6820
+ return Promise.resolve({ address: '0x1' });
6821
+ }),
6305
6822
  };
6306
6823
  const device = {
6824
+ features,
6825
+ commands: {
6826
+ typedCall: jest.fn(() => {
6827
+ calls.push('status');
6828
+ return Promise.resolve({ message: { unlocked: true } });
6829
+ }),
6830
+ },
6307
6831
  isProtocolV2: () => true,
6308
- unlockDevice: jest.fn(() => {
6309
- calls.push('unlock');
6310
- return Promise.resolve();
6832
+ isBootloader: () => false,
6833
+ isRomloader: () => false,
6834
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6835
+ features.unlocked = status.unlocked === true;
6836
+ return features;
6311
6837
  }),
6838
+ unlockDevice: jest.fn(),
6312
6839
  };
6313
6840
  const uiCoordinator = {
6314
6841
  enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6315
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6316
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6842
+ enterUnlockInteraction: jest.fn(),
6317
6843
  };
6318
6844
 
6319
6845
  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
- ]);
6846
+ runMethodWithUnlockPolicy(method as any, device as any, {
6847
+ uiCoordinator: uiCoordinator as any,
6848
+ })
6849
+ ).resolves.toEqual({ address: '0x1' });
6850
+
6851
+ expect(calls).toEqual(['status', 'method-prompt', 'run']);
6852
+ expect(device.commands.typedCall).toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
6853
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6854
+ expect(method.run).toHaveBeenCalledTimes(1);
6330
6855
  });
6331
6856
 
6332
- test('restores the expected hidden-wallet session before retrying after unlock', async () => {
6857
+ test('validates the fresh device identity before starting unlock', async () => {
6333
6858
  const calls: string[] = [];
6859
+ const identityError = new Error('Unexpected device');
6334
6860
  const method = {
6335
6861
  name: 'evmSignMessage',
6336
- payload: { passphraseState: 'hidden-state' },
6862
+ unlockPolicy: 'none',
6337
6863
  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
- }),
6864
+ run: jest.fn(),
6349
6865
  };
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
- });
6866
+ const features = { unlocked: false };
6367
6867
  const device = {
6368
- features: { unlocked: true, passphraseProtection: true },
6369
- passphraseState: 'hidden-state',
6370
- commands: { typedCall },
6868
+ features,
6869
+ commands: {
6870
+ typedCall: jest.fn(() => {
6871
+ calls.push('status');
6872
+ return Promise.resolve({ message: { unlocked: false } });
6873
+ }),
6874
+ },
6371
6875
  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')),
6876
+ isBootloader: () => false,
6877
+ isRomloader: () => false,
6878
+ updateProtocolV2Status: jest.fn(() => features),
6879
+ unlockDevice: jest.fn(),
6381
6880
  };
6382
6881
 
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
- };
6882
+ await expect(
6883
+ runMethodWithUnlockPolicy(method as any, device as any, {
6884
+ afterStatusBeforeUnlock: () => {
6885
+ calls.push('identity');
6886
+ throw identityError;
6887
+ },
6888
+ })
6889
+ ).rejects.toBe(identityError);
6427
6890
 
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();
6891
+ expect(calls).toEqual(['status', 'identity']);
6892
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6893
+ expect(method.run).not.toHaveBeenCalled();
6433
6894
  });
6434
6895
 
6435
- test('restores the expected hidden-wallet session after pre-unlock without selecting Attach PIN', async () => {
6896
+ test('unlocks before business and never replays the callback', async () => {
6436
6897
  const calls: string[] = [];
6898
+ const features = { unlocked: true };
6437
6899
  const method = {
6438
6900
  name: 'evmSignMessage',
6439
- payload: { passphraseState: 'hidden-state' },
6440
- useDevicePassphraseState: true,
6441
- unlockPolicy: 'retry-on-locked',
6901
+ unlockPolicy: 'unlock-before-run',
6902
+ protocolV2UiInteraction: { reason: 'signing-confirmation' },
6442
6903
  run: jest.fn(() => {
6443
6904
  calls.push('run');
6444
- return Promise.resolve({ message: 'ok' });
6905
+ return Promise.resolve({ signature: 'signed' });
6445
6906
  }),
6446
6907
  };
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
- };
6469
6908
  const device = {
6470
6909
  features,
6471
- passphraseState: 'hidden-state',
6472
- commands: { typedCall },
6910
+ commands: {
6911
+ typedCall: jest.fn(() => {
6912
+ calls.push('status');
6913
+ return Promise.resolve({ message: { unlocked: false } });
6914
+ }),
6915
+ },
6473
6916
  isProtocolV2: () => true,
6917
+ isBootloader: () => false,
6918
+ isRomloader: () => false,
6919
+ updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6920
+ features.unlocked = status.unlocked === true;
6921
+ return features;
6922
+ }),
6474
6923
  unlockDevice: jest.fn(() => {
6475
- calls.push('unlock-main');
6924
+ calls.push('unlock');
6476
6925
  features.unlocked = true;
6477
- features.unlockedAttachPin = false;
6478
- return Promise.resolve();
6926
+ return Promise.resolve(features);
6927
+ }),
6928
+ };
6929
+ const uiCoordinator = {
6930
+ enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6931
+ enterUnlockInteraction: jest.fn(() => {
6932
+ calls.push('unlock-prompt');
6933
+ return undefined;
6479
6934
  }),
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')),
6485
6935
  };
6486
6936
 
6487
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6488
- message: 'ok',
6489
- });
6490
- expect(device.unlockDevice).toHaveBeenCalledWith();
6937
+ await expect(
6938
+ runMethodWithUnlockPolicy(method as any, device as any, {
6939
+ uiCoordinator: uiCoordinator as any,
6940
+ prepare: () => {
6941
+ calls.push('wallet-session');
6942
+ return Promise.resolve();
6943
+ },
6944
+ })
6945
+ ).resolves.toEqual({ signature: 'signed' });
6946
+
6491
6947
  expect(calls).toEqual([
6492
- 'unlock-main',
6493
- 'negotiate-session',
6494
- 'resume-hidden-session',
6495
- 'validate-hidden-session',
6948
+ 'status',
6949
+ 'unlock-prompt',
6950
+ 'unlock',
6951
+ 'wallet-session',
6952
+ 'method-prompt',
6496
6953
  'run',
6497
6954
  ]);
6955
+ expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6956
+ expect(method.run).toHaveBeenCalledTimes(1);
6498
6957
  });
6499
6958
 
6500
- test('does not restore a hidden-wallet session for a standard-wallet pre-unlock', async () => {
6959
+ test('fails closed when fresh status does not explicitly report unlocked state', async () => {
6501
6960
  const method = {
6502
6961
  name: 'evmGetAddress',
6503
- payload: { useEmptyPassphrase: true },
6504
- useDevicePassphraseState: true,
6505
- unlockPolicy: 'retry-on-locked',
6506
- run: jest.fn().mockResolvedValue({ address: 'standard-wallet-address' }),
6962
+ unlockPolicy: 'unlock-before-run',
6963
+ run: jest.fn(),
6507
6964
  };
6508
6965
  const device = {
6509
- features: { unlocked: false },
6510
- passphraseState: 'stale-hidden-state',
6966
+ features: { unlocked: undefined },
6967
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: {} }) },
6511
6968
  isProtocolV2: () => true,
6512
- unlockDevice: jest.fn().mockResolvedValue(undefined),
6969
+ isBootloader: () => false,
6970
+ isRomloader: () => false,
6971
+ updateProtocolV2Status: jest.fn(() => ({ unlocked: undefined })),
6972
+ unlockDevice: jest.fn(),
6513
6973
  };
6514
6974
 
6515
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6516
- address: 'standard-wallet-address',
6975
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toMatchObject({
6976
+ errorCode: HardwareErrorCode.RuntimeError,
6517
6977
  });
6518
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
6519
- expect(method.run).toHaveBeenCalledTimes(1);
6978
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6979
+ expect(method.run).not.toHaveBeenCalled();
6520
6980
  });
6521
6981
 
6522
- test('refreshes status and unlocks before a protected Protocol V2 method', async () => {
6523
- const calls: string[] = [];
6982
+ test('returns a business DeviceLocked error without unlocking or replaying', async () => {
6983
+ const error = deviceLockedError();
6524
6984
  const method = {
6525
- name: 'deviceSettingsPageShow',
6985
+ name: 'evmSignMessage',
6526
6986
  unlockPolicy: 'unlock-before-run',
6527
- protocolV2UiInteraction: { reason: 'settings-page' },
6528
- run: jest.fn(() => {
6529
- calls.push('run');
6530
- return Promise.resolve({ message: 'ok' });
6531
- }),
6987
+ run: jest.fn().mockRejectedValue(error),
6532
6988
  };
6533
6989
  const features = { unlocked: true };
6534
6990
  const device = {
6535
6991
  features,
6536
- commands: {
6537
- typedCall: jest.fn(() => {
6538
- calls.push('refresh-status');
6539
- return Promise.resolve({ message: { unlocked: false } });
6540
- }),
6541
- },
6992
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6542
6993
  isProtocolV2: () => true,
6543
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6544
- features.unlocked = status.unlocked ?? features.unlocked;
6545
- return features;
6546
- }),
6547
- unlockDevice: jest.fn(() => {
6548
- calls.push('unlock');
6549
- features.unlocked = true;
6550
- return Promise.resolve();
6551
- }),
6552
- };
6553
- const uiCoordinator = {
6554
- enterMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6555
- enterUnlockInteraction: jest.fn(() => calls.push('unlock-prompt')),
6556
- resumeMethodInteraction: jest.fn(() => calls.push('method-prompt')),
6994
+ isBootloader: () => false,
6995
+ isRomloader: () => false,
6996
+ updateProtocolV2Status: jest.fn(() => features),
6997
+ unlockDevice: jest.fn(),
6557
6998
  };
6558
6999
 
6559
- await expect(
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', {});
7000
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).rejects.toBe(error);
7001
+ expect(device.unlockDevice).not.toHaveBeenCalled();
6564
7002
  expect(method.run).toHaveBeenCalledTimes(1);
6565
- expect(uiCoordinator.enterMethodInteraction).toHaveBeenCalledTimes(1);
6566
- expect(uiCoordinator.resumeMethodInteraction).not.toHaveBeenCalled();
6567
7003
  });
6568
7004
 
6569
- test('uses refreshed unlocked status instead of a stale locked cache', async () => {
6570
- const features = { unlocked: false };
6571
- const method = {
6572
- name: 'deviceSettings',
7005
+ test('reuses one lightweight preflight context across nested protected methods', async () => {
7006
+ const features = { unlocked: true };
7007
+ const context = createProtocolV2UnlockContext();
7008
+ const firstMethod = {
7009
+ name: 'allNetworkGetAddress',
6573
7010
  unlockPolicy: 'unlock-before-run',
6574
- run: jest.fn().mockResolvedValue({ message: 'ok' }),
7011
+ run: jest.fn().mockResolvedValue({ root: true }),
7012
+ };
7013
+ const nestedMethod = {
7014
+ name: 'evmGetAddress',
7015
+ unlockPolicy: 'unlock-before-run',
7016
+ run: jest.fn().mockResolvedValue({ address: '0x1' }),
6575
7017
  };
6576
7018
  const device = {
6577
7019
  features,
6578
- commands: {
6579
- typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }),
6580
- },
7020
+ commands: { typedCall: jest.fn().mockResolvedValue({ message: { unlocked: true } }) },
6581
7021
  isProtocolV2: () => true,
6582
- updateProtocolV2Status: jest.fn((status: { unlocked?: boolean }) => {
6583
- features.unlocked = status.unlocked ?? features.unlocked;
6584
- return features;
6585
- }),
7022
+ isBootloader: () => false,
7023
+ isRomloader: () => false,
7024
+ updateProtocolV2Status: jest.fn(() => features),
6586
7025
  unlockDevice: jest.fn(),
6587
7026
  };
6588
7027
 
6589
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
6590
- message: 'ok',
6591
- });
6592
- expect(device.unlockDevice).not.toHaveBeenCalled();
6593
- expect(method.run).toHaveBeenCalledTimes(1);
7028
+ await runMethodWithUnlockPolicy(firstMethod as any, device as any, { context });
7029
+ await runMethodWithUnlockPolicy(nestedMethod as any, device as any, { context });
7030
+
7031
+ expect(device.commands.typedCall).toHaveBeenCalledTimes(1);
7032
+ expect(firstMethod.run).toHaveBeenCalledTimes(1);
7033
+ expect(nestedMethod.run).toHaveBeenCalledTimes(1);
6594
7034
  });
6595
7035
 
6596
7036
  test.each(['bootloader', 'romloader'] as const)(
@@ -6610,7 +7050,7 @@ describe('Protocol V2 protected method execution', () => {
6610
7050
  unlockDevice: jest.fn(),
6611
7051
  };
6612
7052
 
6613
- await expect(runMethodWithUnlockRetry(method as any, device as any)).resolves.toEqual({
7053
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
6614
7054
  message: 'updating',
6615
7055
  });
6616
7056
  expect(device.commands.typedCall).not.toHaveBeenCalled();
@@ -6619,157 +7059,30 @@ describe('Protocol V2 protected method execution', () => {
6619
7059
  }
6620
7060
  );
6621
7061
 
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 () => {
7062
+ test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
6653
7063
  for (const [isProtocolV2, unlockPolicy] of [
6654
- [false, 'retry-on-locked'],
7064
+ [false, 'unlock-before-run'],
6655
7065
  [true, 'none'],
6656
7066
  ] as const) {
6657
- const error = deviceLockedError();
6658
7067
  const method = {
6659
7068
  unlockPolicy,
6660
- run: jest.fn().mockRejectedValue(error),
7069
+ useDevicePassphraseState: false,
7070
+ run: jest.fn().mockResolvedValue({ message: 'ok' }),
6661
7071
  };
6662
7072
  const device = {
7073
+ commands: { typedCall: jest.fn() },
6663
7074
  isProtocolV2: () => isProtocolV2,
6664
7075
  unlockDevice: jest.fn(),
6665
7076
  };
6666
7077
 
6667
- await expect(runMethodWithUnlockRetry(method as any, device as any)).rejects.toBe(error);
7078
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
7079
+ message: 'ok',
7080
+ });
7081
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
6668
7082
  expect(device.unlockDevice).not.toHaveBeenCalled();
6669
7083
  expect(method.run).toHaveBeenCalledTimes(1);
6670
7084
  }
6671
7085
  });
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
- });
6773
7086
  });
6774
7087
 
6775
7088
  describe('Protocol V2 current low-level methods', () => {
@@ -6901,7 +7214,9 @@ describe('Protocol V2 current low-level methods', () => {
6901
7214
 
6902
7215
  await method.run();
6903
7216
 
6904
- expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {});
7217
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
7218
+ eventless_wallet_session: true,
7219
+ });
6905
7220
  });
6906
7221
 
6907
7222
  test('sends DeviceFactoryInfoSet and DeviceFactoryInfoGet', async () => {
@@ -6977,6 +7292,29 @@ describe('Protocol V2 current low-level methods', () => {
6977
7292
 
6978
7293
  await expect(method.run()).resolves.toBe(features);
6979
7294
  expect(unlockDevice).toHaveBeenCalledTimes(1);
7295
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Main, {
7296
+ emitUiEvent: false,
7297
+ });
7298
+ });
7299
+
7300
+ test('forwards an explicit PIN type when unlocking a Protocol V2 device', async () => {
7301
+ const method = new DeviceUnlock({
7302
+ id: 1,
7303
+ payload: {
7304
+ method: 'deviceUnlock',
7305
+ pinType: DeviceSessionPinType.Any,
7306
+ },
7307
+ });
7308
+ method.init();
7309
+
7310
+ const unlockDevice = jest.fn().mockResolvedValue({ unlocked: true });
7311
+ (method as any).device = { unlockDevice };
7312
+
7313
+ await method.run();
7314
+
7315
+ expect(unlockDevice).toHaveBeenCalledWith(DeviceSessionPinType.Any, {
7316
+ emitUiEvent: false,
7317
+ });
6980
7318
  });
6981
7319
 
6982
7320
  test.each([